From 2f5fcf7b6db976d1a6aa3701c7ad4c88108b4fd3 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 21 Mar 2022 12:33:24 -0400 Subject: [PATCH 001/132] [Fleet] Make host|url required when creating an output (#128166) --- .../output_form_validators.test.tsx | 10 ++++++-- .../output_form_validators.tsx | 23 +++++++++++++++---- .../fleet/server/types/models/output.ts | 8 +++++-- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 7f414a8f12deb..57b8681e834bb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -14,10 +14,10 @@ import { describe('Output form validation', () => { describe('validateESHosts', () => { - it('should work without any urls', () => { + it('should not work without any urls', () => { const res = validateESHosts([]); - expect(res).toBeUndefined(); + expect(res).toEqual([{ message: 'URL is required' }]); }); it('should work with valid url', () => { @@ -57,6 +57,12 @@ describe('Output form validation', () => { }); describe('validateLogstashHosts', () => { + it('should not work without any urls', () => { + const res = validateLogstashHosts([]); + + expect(res).toEqual([{ message: 'Host is required' }]); + }); + it('should work for valid hosts', () => { const res = validateLogstashHosts(['test.fr:5044']); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index 33ee3f9678cc8..13b90fe661f61 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { safeLoad } from 'js-yaml'; export function validateESHosts(value: string[]) { - const res: Array<{ message: string; index: number }> = []; + const res: Array<{ message: string; index?: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { try { @@ -46,13 +46,21 @@ export function validateESHosts(value: string[]) { ); }); + if (value.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.elasticUrlRequiredError', { + defaultMessage: 'URL is required', + }), + }); + } + if (res.length) { return res; } } export function validateLogstashHosts(value: string[]) { - const res: Array<{ message: string; index: number }> = []; + const res: Array<{ message: string; index?: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { try { @@ -89,13 +97,20 @@ export function validateLogstashHosts(value: string[]) { .forEach((indexes) => { indexes.forEach((index) => res.push({ - message: i18n.translate('xpack.fleet.settings.outputForm.elasticHostDuplicateError', { - defaultMessage: 'Duplicate URL', + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostDuplicateError', { + defaultMessage: 'Duplicate Host', }), index, }) ); }); + if (value.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostRequiredError', { + defaultMessage: 'Host is required', + }), + }); + } if (res.length) { return res; diff --git a/x-pack/plugins/fleet/server/types/models/output.ts b/x-pack/plugins/fleet/server/types/models/output.ts index ee7854ade30a8..86b2a70a318fc 100644 --- a/x-pack/plugins/fleet/server/types/models/output.ts +++ b/x-pack/plugins/fleet/server/types/models/output.ts @@ -35,8 +35,12 @@ const OutputBaseSchema = { hosts: schema.conditional( schema.siblingRef('type'), schema.literal(outputType.Elasticsearch), - schema.arrayOf(schema.uri({ scheme: ['http', 'https'] })), - schema.arrayOf(schema.string({ validate: validateLogstashHost })) + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + minSize: 1, + }), + schema.arrayOf(schema.string({ validate: validateLogstashHost }), { + minSize: 1, + }) ), is_default: schema.boolean({ defaultValue: false }), is_default_monitoring: schema.boolean({ defaultValue: false }), From 8b73d290e288fd196d4ee1ed66379e6ec2dd7bc0 Mon Sep 17 00:00:00 2001 From: Or Ouziel Date: Mon, 21 Mar 2022 18:37:16 +0200 Subject: [PATCH 002/132] [Cloud Posture] Update time related texts in rules table (#127640) --- .../public/pages/rules/rules_container.tsx | 11 +++- .../public/pages/rules/rules_table.tsx | 2 +- .../public/pages/rules/rules_table_header.tsx | 56 +++++++++++++------ .../public/pages/rules/translations.ts | 13 +++-- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx index 39d8f38475287..9780f9ecd3778 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_container.tsx @@ -15,7 +15,7 @@ import { } from '@elastic/eui'; import { useParams } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; -import { extractErrorMessage } from '../../../common/utils/helpers'; +import { extractErrorMessage, isNonNullable } from '../../../common/utils/helpers'; import { RulesTable } from './rules_table'; import { RulesBottomBar } from './rules_bottom_bar'; import { RulesTableHeader } from './rules_table_header'; @@ -38,6 +38,7 @@ interface RulesPageData { total: number; error?: string; loading: boolean; + lastModified: string | null; } export type RulesState = RulesPageData & RulesQuery; @@ -82,9 +83,16 @@ const getRulesPageData = ( rules_map: new Map(rules.map((rule) => [rule.id, rule])), rules_page: page.map((rule) => changedRules.get(rule.attributes.id) || rule), total: data?.total || 0, + lastModified: getLastModified(rules) || null, }; }; +const getLastModified = (data: RuleSavedObject[]): string | undefined => + data + .map((v) => v.updatedAt) + .filter(isNonNullable) + .sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0]; + const getPage = (data: readonly RuleSavedObject[], { page, perPage }: RulesQuery) => data.slice(page * perPage, (page + 1) * perPage); @@ -178,6 +186,7 @@ export const RulesContainer = () => { searchValue={rulesQuery.search} totalRulesCount={rulesPageData.all_rules.length} isSearching={status === 'loading'} + lastModified={rulesPageData.lastModified} /> moment(timestamp).fromNow(), }, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx index 011cb5d8f7bc2..8f47a00ce5003 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_table_header.tsx @@ -5,10 +5,18 @@ * 2.0. */ import React, { useState } from 'react'; -import { EuiFieldSearch, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiFieldSearch, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import useDebounce from 'react-use/lib/useDebounce'; +import moment from 'moment'; import * as TEST_SUBJECTS from './test_subjects'; import * as TEXT from './translations'; import { RulesBulkActionsMenu } from './rules_bulk_actions_menu'; @@ -24,6 +32,7 @@ interface RulesTableToolbarProps { selectedRulesCount: number; searchValue: string; isSearching: boolean; + lastModified: string | null; } interface CounterProps { @@ -34,6 +43,16 @@ interface ButtonProps { onClick(): void; } +const LastModificationLabel = ({ lastModified }: { lastModified: string }) => ( + + + +); + export const RulesTableHeader = ({ search, refresh, @@ -45,22 +64,27 @@ export const RulesTableHeader = ({ selectedRulesCount, searchValue, isSearching, + lastModified, }: RulesTableToolbarProps) => ( - - - - - - - +
+ {lastModified && } + + + + + + + + +
); const Counters = ({ total, selected }: { total: number; selected: number }) => ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts index 0f9c279ae4ba5..8523e0afc06c5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/translations.ts @@ -31,19 +31,20 @@ export const BULK_ACTIONS = i18n.translate('xpack.csp.rules.bulkActionsButtonLab defaultMessage: 'Bulk Actions', }); -export const RULE_NAME = i18n.translate('xpack.csp.rules.ruleNameColumnHeaderLabel', { +export const RULE_NAME = i18n.translate('xpack.csp.rules.rulesTable.rulesTableColumn.nameLabel', { defaultMessage: 'Rule Name', }); -export const SECTION = i18n.translate('xpack.csp.rules.sectionColumnHeaderLabel', { +export const SECTION = i18n.translate('xpack.csp.rules.rulesTable.rulesTableColumn.sectionLabel', { defaultMessage: 'Section', }); -export const UPDATED_AT = i18n.translate('xpack.csp.rules.updatedAtColumnHeaderLabel', { - defaultMessage: 'Updated at', -}); +export const LAST_MODIFIED = i18n.translate( + 'xpack.csp.rules.rulesTable.rulesTableColumn.lastModifiedLabel', + { defaultMessage: 'Last modified' } +); -export const ENABLED = i18n.translate('xpack.csp.rules.enabledColumnHeaderLabel', { +export const ENABLED = i18n.translate('xpack.csp.rules.rulesTable.rulesTableColumn.enabledLabel', { defaultMessage: 'Enabled', }); From 5b682e093cde14fd723b3d3df0d9f64825c4b043 Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Mon, 21 Mar 2022 16:39:00 +0000 Subject: [PATCH 003/132] git: Ingore local snyk cache (#127586) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4704247e6f548..588c185b17a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ npm-debug.log* .vagrant .envrc +## Snyk +.dccache + ## @cypress/snapshot from apm plugin /snapshots.js From 3a4c6de7a5b6d95936fa319ff9c766e4e20374e1 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Mon, 21 Mar 2022 11:42:57 -0500 Subject: [PATCH 004/132] [ML] Show at least one correlation value and consolidate correlations columns (#126683) * Remove score column for failed transactions * Show at least one correlated value * Add very low badge for fallback transactions * Match two columns * Update constants & i18n * Update failed transactions * Fix types * Change to immutable * Add tooltip * [ML] Fix types * [ML] Fix translation Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../constants.ts | 32 +++----- .../failed_transactions_correlations/types.ts | 5 +- .../latency_correlations/types.ts | 3 +- .../plugins/apm/common/correlations/types.ts | 1 + .../failed_transactions_correlations.tsx | 82 +++++++++++++------ ...get_transaction_distribution_chart_data.ts | 9 +- .../app/correlations/latency_correlations.tsx | 39 +++++++++ .../use_failed_transactions_correlations.ts | 24 +++++- .../correlations/use_latency_correlations.ts | 24 ++++++ ...nsactions_correlation_impact_label.test.ts | 8 +- ...d_transactions_correlation_impact_label.ts | 53 ++++++++++-- .../query_correlation_with_histogram.ts | 43 +++++----- .../correlations/queries/query_p_values.ts | 41 +++++++--- .../queries/query_significant_correlations.ts | 53 ++++++++++-- .../apm/server/routes/correlations/route.ts | 2 + .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- .../tests/correlations/latency.spec.ts | 2 +- 19 files changed, 328 insertions(+), 108 deletions(-) diff --git a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts index 09e3e22a1d352..8a838360b3d44 100644 --- a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts +++ b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/constants.ts @@ -7,23 +7,17 @@ import { i18n } from '@kbn/i18n'; -export const FAILED_TRANSACTIONS_IMPACT_THRESHOLD = { - HIGH: i18n.translate( - 'xpack.apm.correlations.failedTransactions.highImpactText', - { - defaultMessage: 'High', - } - ), - MEDIUM: i18n.translate( - 'xpack.apm.correlations.failedTransactions.mediumImpactText', - { - defaultMessage: 'Medium', - } - ), - LOW: i18n.translate( - 'xpack.apm.correlations.failedTransactions.lowImpactText', - { - defaultMessage: 'Low', - } - ), +export const CORRELATIONS_IMPACT_THRESHOLD = { + HIGH: i18n.translate('xpack.apm.correlations.highImpactText', { + defaultMessage: 'High', + }), + MEDIUM: i18n.translate('xpack.apm.correlations.mediumImpactText', { + defaultMessage: 'Medium', + }), + LOW: i18n.translate('xpack.apm.correlations.lowImpactText', { + defaultMessage: 'Low', + }), + VERY_LOW: i18n.translate('xpack.apm.correlations.veryLowImpactText', { + defaultMessage: 'Very low', + }), } as const; diff --git a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts index 8b09d45c1e1b6..e63d3d6faa92e 100644 --- a/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/failed_transactions_correlations/types.ts @@ -7,7 +7,7 @@ import { FieldValuePair, HistogramItem } from '../types'; -import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from './constants'; +import { CORRELATIONS_IMPACT_THRESHOLD } from './constants'; import { FieldStats } from '../field_stats_types'; export interface FailedTransactionsCorrelation extends FieldValuePair { @@ -22,7 +22,7 @@ export interface FailedTransactionsCorrelation extends FieldValuePair { } export type FailedTransactionsCorrelationsImpactThreshold = - typeof FAILED_TRANSACTIONS_IMPACT_THRESHOLD[keyof typeof FAILED_TRANSACTIONS_IMPACT_THRESHOLD]; + typeof CORRELATIONS_IMPACT_THRESHOLD[keyof typeof CORRELATIONS_IMPACT_THRESHOLD]; export interface FailedTransactionsCorrelationsResponse { ccsWarning: boolean; @@ -31,4 +31,5 @@ export interface FailedTransactionsCorrelationsResponse { overallHistogram?: HistogramItem[]; errorHistogram?: HistogramItem[]; fieldStats?: FieldStats[]; + fallbackResult?: FailedTransactionsCorrelation; } diff --git a/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts b/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts index 23c91554b6547..cf20490774e18 100644 --- a/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/latency_correlations/types.ts @@ -10,8 +10,9 @@ import { FieldStats } from '../field_stats_types'; export interface LatencyCorrelation extends FieldValuePair { correlation: number; - histogram: HistogramItem[]; + histogram?: HistogramItem[]; ksTest: number; + isFallbackResult?: boolean; } export interface LatencyCorrelationsResponse { diff --git a/x-pack/plugins/apm/common/correlations/types.ts b/x-pack/plugins/apm/common/correlations/types.ts index 402750b72b2ab..6884d8c627fd0 100644 --- a/x-pack/plugins/apm/common/correlations/types.ts +++ b/x-pack/plugins/apm/common/correlations/types.ts @@ -11,6 +11,7 @@ export interface FieldValuePair { // but for example `http.response.status_code` which is part of // of the list of predefined field candidates is of type long/number. fieldValue: string | number; + isFallbackResult?: boolean; } export interface HistogramItem { diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx index 6d20faae89a10..c970838f8b8c3 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -28,7 +28,10 @@ import { i18n } from '@kbn/i18n'; import { useUiTracker } from '../../../../../observability/public'; -import { asPercent } from '../../../../common/utils/formatters'; +import { + asPercent, + asPreciseDecimal, +} from '../../../../common/utils/formatters'; import { FailedTransactionsCorrelation } from '../../../../common/correlations/failed_transactions_correlations/types'; import { FieldStats } from '../../../../common/correlations/field_stats_types'; @@ -36,8 +39,6 @@ import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_ import { useLocalStorage } from '../../../hooks/use_local_storage'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTheme } from '../../../hooks/use_theme'; - -import { ImpactBar } from '../../shared/impact_bar'; import { push } from '../../shared/links/url_helpers'; import { CorrelationsTable } from './correlations_table'; @@ -229,21 +230,33 @@ export function FailedTransactionsCorrelations({ width: '116px', field: 'normalizedScore', name: ( - <> - {i18n.translate( - 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', + - ), - render: (_, { normalizedScore }) => { - return ( + > <> - + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.scoreLabel', + { + defaultMessage: 'Score', + } + )} + - ); + + ), + render: (_, { normalizedScore }) => { + return
{asPreciseDecimal(normalizedScore, 2)}
; }, sortable: true, }, @@ -260,8 +273,11 @@ export function FailedTransactionsCorrelations({ )} ), - render: (_, { pValue }) => { - const label = getFailedTransactionsCorrelationImpactLabel(pValue); + render: (_, { pValue, isFallbackResult }) => { + const label = getFailedTransactionsCorrelationImpactLabel( + pValue, + isFallbackResult + ); return label ? ( {label.impact} ) : null; @@ -377,18 +393,30 @@ export function FailedTransactionsCorrelations({ sort: { field: sortField, direction: sortDirection }, }; - const correlationTerms = useMemo( - () => - orderBy( - response.failedTransactionsCorrelations, - // The smaller the p value the higher the impact - // So we want to sort by the normalized score here - // which goes from 0 -> 1 - sortField === 'pValue' ? 'normalizedScore' : sortField, - sortDirection - ), - [response.failedTransactionsCorrelations, sortField, sortDirection] - ); + const correlationTerms = useMemo(() => { + if ( + progress.loaded === 1 && + response?.failedTransactionsCorrelations?.length === 0 && + response.fallbackResult !== undefined + ) { + return [{ ...response.fallbackResult, isFallbackResult: true }]; + } + + return orderBy( + response.failedTransactionsCorrelations, + // The smaller the p value the higher the impact + // So we want to sort by the normalized score here + // which goes from 0 -> 1 + sortField === 'pValue' ? 'normalizedScore' : sortField, + sortDirection + ); + }, [ + response.failedTransactionsCorrelations, + response.fallbackResult, + progress.loaded, + sortField, + sortDirection, + ]); const [pinnedSignificantTerm, setPinnedSignificantTerm] = useState(null); diff --git a/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts b/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts index 49ddd8aec0fe4..12799e5edc726 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/get_transaction_distribution_chart_data.ts @@ -7,11 +7,10 @@ import { i18n } from '@kbn/i18n'; import { EuiTheme } from '../../../../../../../src/plugins/kibana_react/common'; -import type { - FieldValuePair, - HistogramItem, -} from '../../../../common/correlations/types'; +import type { HistogramItem } from '../../../../common/correlations/types'; import { TransactionDistributionChartData } from '../../shared/charts/transaction_distribution_chart'; +import { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; +import { FailedTransactionsCorrelation } from '../../../../common/correlations/failed_transactions_correlations/types'; export function getTransactionDistributionChartData({ euiTheme, @@ -22,7 +21,7 @@ export function getTransactionDistributionChartData({ euiTheme: EuiTheme; allTransactionsHistogram?: HistogramItem[]; failedTransactionsHistogram?: HistogramItem[]; - selectedTerm?: FieldValuePair & { histogram: HistogramItem[] }; + selectedTerm?: LatencyCorrelation | FailedTransactionsCorrelation | undefined; }) { const transactionDistributionChartData: TransactionDistributionChartData[] = []; diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 5b37a14b4e4e5..f3734dae5d247 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -17,12 +17,14 @@ import { EuiSpacer, EuiTitle, EuiToolTip, + EuiBadge, } from '@elastic/eui'; import { Direction } from '@elastic/eui/src/services/sort/sort_direction'; import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; import { useUiTracker } from '../../../../../observability/public'; import { asPreciseDecimal } from '../../../../common/utils/formatters'; @@ -48,6 +50,18 @@ import { getTransactionDistributionChartData } from './get_transaction_distribut import { useTheme } from '../../../hooks/use_theme'; import { ChartTitleToolTip } from './chart_title_tool_tip'; import { MIN_TAB_TITLE_HEIGHT } from '../transaction_details/distribution'; +import { getLatencyCorrelationImpactLabel } from './utils/get_failed_transactions_correlation_impact_label'; + +export function FallbackCorrelationBadge() { + return ( + + + + ); +} export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { const { @@ -151,6 +165,31 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { }, sortable: true, }, + { + width: '116px', + field: 'pValue', + name: ( + <> + {i18n.translate( + 'xpack.apm.correlations.failedTransactions.correlationsTable.impactLabel', + { + defaultMessage: 'Impact', + } + )} + + ), + render: (_, { correlation, isFallbackResult }) => { + const label = getLatencyCorrelationImpactLabel( + correlation, + isFallbackResult + ); + return label ? ( + {label.impact} + ) : null; + }, + sortable: true, + }, + { field: 'fieldName', name: i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts index 41a2afada6e65..26f63e1ab0c59 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/use_failed_transactions_correlations.ts @@ -77,6 +77,7 @@ export function useFailedTransactionsCorrelations() { // and histogram data for statistically significant results. const responseUpdate: FailedTransactionsCorrelationsResponse = { ccsWarning: false, + fallbackResult: undefined, }; const [overallHistogramResponse, errorHistogramRespone] = @@ -149,6 +150,7 @@ export function useFailedTransactionsCorrelations() { const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = []; + let fallbackResult: FailedTransactionsCorrelation | undefined; const fieldsToSample = new Set(); const chunkSize = 10; let chunkLoadCounter = 0; @@ -177,6 +179,21 @@ export function useFailedTransactionsCorrelations() { getFailedTransactionsCorrelationsSortedByScore([ ...failedTransactionsCorrelations, ]); + } else { + // If there's no significant correlations found and there's a fallback result + // Update the highest ranked/scored fall back result + if (pValues.fallbackResult) { + if (!fallbackResult) { + fallbackResult = pValues.fallbackResult; + } else { + if ( + pValues.fallbackResult.normalizedScore > + fallbackResult.normalizedScore + ) { + fallbackResult = pValues.fallbackResult; + } + } + } } chunkLoadCounter++; @@ -209,7 +226,12 @@ export function useFailedTransactionsCorrelations() { ); responseUpdate.fieldStats = stats; - setResponse({ ...responseUpdate, loaded: LOADED_DONE, isRunning: false }); + setResponse({ + ...responseUpdate, + fallbackResult, + loaded: LOADED_DONE, + isRunning: false, + }); setResponse.flush(); } catch (e) { if (!abortCtrl.current.signal.aborted) { diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts index 0e166344d0dec..428fdcda7cfc6 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/use_latency_correlations.ts @@ -177,6 +177,7 @@ export function useLatencyCorrelations() { chunkSize ); + const fallbackResults: LatencyCorrelation[] = []; for (const fieldValuePairChunk of fieldValuePairChunks) { const significantCorrelations = await callApmApi( 'POST /internal/apm/correlations/significant_correlations', @@ -197,6 +198,12 @@ export function useLatencyCorrelations() { ); responseUpdate.latencyCorrelations = getLatencyCorrelationsSortedByCorrelation([...latencyCorrelations]); + } else { + // If there's no correlation results that matches the criteria + // Consider the fallback results + if (significantCorrelations.fallbackResult) { + fallbackResults.push(significantCorrelations.fallbackResult); + } } chunkLoadCounter++; @@ -213,6 +220,23 @@ export function useLatencyCorrelations() { } } + if (latencyCorrelations.length === 0 && fallbackResults.length > 0) { + // Rank the fallback results and show at least one value + const sortedFallbackResults = fallbackResults + .filter((r) => r.correlation > 0) + .sort((a, b) => b.correlation - a.correlation); + + responseUpdate.latencyCorrelations = sortedFallbackResults + .slice(0, 1) + .map((r) => ({ ...r, isFallbackResult: true })); + setResponse({ + ...responseUpdate, + loaded: + LOADED_FIELD_VALUE_PAIRS + + (chunkLoadCounter / fieldValuePairChunks.length) * + PROGRESS_STEP_CORRELATIONS, + }); + } setResponse.flush(); const { stats } = await callApmApi( diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts index d35833295703f..b85121ea94a9c 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.test.ts @@ -6,19 +6,19 @@ */ import { getFailedTransactionsCorrelationImpactLabel } from './get_failed_transactions_correlation_impact_label'; -import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; +import { CORRELATIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; const EXPECTED_RESULT = { HIGH: { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH, + impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH, color: 'danger', }, MEDIUM: { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM, + impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM, color: 'warning', }, LOW: { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW, + impact: CORRELATIONS_IMPACT_THRESHOLD.LOW, color: 'default', }, }; diff --git a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts index d5d0fd4dcae51..556c13d7467bb 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts +++ b/x-pack/plugins/apm/public/components/app/correlations/utils/get_failed_transactions_correlation_impact_label.ts @@ -9,10 +9,11 @@ import { FailedTransactionsCorrelation, FailedTransactionsCorrelationsImpactThreshold, } from '../../../../../common/correlations/failed_transactions_correlations/types'; -import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; +import { CORRELATIONS_IMPACT_THRESHOLD } from '../../../../../common/correlations/failed_transactions_correlations/constants'; export function getFailedTransactionsCorrelationImpactLabel( - pValue: FailedTransactionsCorrelation['pValue'] + pValue: FailedTransactionsCorrelation['pValue'], + isFallbackResult?: boolean ): { impact: FailedTransactionsCorrelationsImpactThreshold; color: string; @@ -21,22 +22,64 @@ export function getFailedTransactionsCorrelationImpactLabel( return null; } + if (isFallbackResult) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.VERY_LOW, + color: 'default', + }; + // The lower the p value, the higher the impact if (pValue >= 0 && pValue < 1e-6) return { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.HIGH, + impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH, color: 'danger', }; if (pValue >= 1e-6 && pValue < 0.001) return { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.MEDIUM, + impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM, color: 'warning', }; if (pValue >= 0.001 && pValue < 0.02) return { - impact: FAILED_TRANSACTIONS_IMPACT_THRESHOLD.LOW, + impact: CORRELATIONS_IMPACT_THRESHOLD.LOW, + color: 'default', + }; + + return null; +} + +export function getLatencyCorrelationImpactLabel( + correlation: FailedTransactionsCorrelation['pValue'], + isFallbackResult?: boolean +): { + impact: FailedTransactionsCorrelationsImpactThreshold; + color: string; +} | null { + if (correlation === null || correlation < 0) { + return null; + } + + // The lower the p value, the higher the impact + if (isFallbackResult) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.VERY_LOW, + color: 'default', + }; + if (correlation < 0.4) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.LOW, color: 'default', }; + if (correlation < 0.6) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.MEDIUM, + color: 'warning', + }; + if (correlation < 1) + return { + impact: CORRELATIONS_IMPACT_THRESHOLD.HIGH, + color: 'danger', + }; return null; } diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts b/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts index 03b28b28d521a..5c6b8f3594aa0 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/query_correlation_with_histogram.ts @@ -32,7 +32,7 @@ export async function fetchTransactionDurationCorrelationWithHistogram( histogramRangeSteps: number[], totalDocCount: number, fieldValuePair: FieldValuePair -): Promise { +) { const { correlation, ksTest } = await fetchTransactionDurationCorrelation( esClient, params, @@ -43,23 +43,28 @@ export async function fetchTransactionDurationCorrelationWithHistogram( [fieldValuePair] ); - if ( - correlation !== null && - correlation > CORRELATION_THRESHOLD && - ksTest !== null && - ksTest < KS_TEST_THRESHOLD - ) { - const logHistogram = await fetchTransactionDurationRanges( - esClient, - params, - histogramRangeSteps, - [fieldValuePair] - ); - return { - ...fieldValuePair, - correlation, - ksTest, - histogram: logHistogram, - }; + if (correlation !== null && ksTest !== null && !isNaN(ksTest)) { + if (correlation > CORRELATION_THRESHOLD && ksTest < KS_TEST_THRESHOLD) { + const logHistogram = await fetchTransactionDurationRanges( + esClient, + params, + histogramRangeSteps, + [fieldValuePair] + ); + return { + ...fieldValuePair, + correlation, + ksTest, + histogram: logHistogram, + } as LatencyCorrelation; + } else { + return { + ...fieldValuePair, + correlation, + ksTest, + } as Omit; + } } + + return undefined; } diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts b/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts index 7c471aebd0f7a..ee59925c47f27 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/query_p_values.ts @@ -41,18 +41,39 @@ export const fetchPValues = async ( ) ); - const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = - fulfilled - .flat() - .filter( - (record) => - record && - typeof record.pValue === 'number' && - record.pValue < ERROR_CORRELATION_THRESHOLD - ); + const flattenedResults = fulfilled.flat(); + + const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = []; + let fallbackResult: FailedTransactionsCorrelation | undefined; + + flattenedResults.forEach((record) => { + if ( + record && + typeof record.pValue === 'number' && + record.pValue < ERROR_CORRELATION_THRESHOLD + ) { + failedTransactionsCorrelations.push(record); + } else { + // If there's no result matching the criteria + // Find the next highest/closest result to the threshold + // to use as a fallback result + if (!fallbackResult) { + fallbackResult = record; + } else { + if ( + record.pValue !== null && + fallbackResult && + fallbackResult.pValue !== null && + record.pValue < fallbackResult.pValue + ) { + fallbackResult = record; + } + } + } + }); const ccsWarning = rejected.length > 0 && paramsWithIndex?.index.includes(':'); - return { failedTransactionsCorrelations, ccsWarning }; + return { failedTransactionsCorrelations, ccsWarning, fallbackResult }; }; diff --git a/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts b/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts index ed5ad1c278143..2fc1e69eab356 100644 --- a/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts +++ b/x-pack/plugins/apm/server/routes/correlations/queries/query_significant_correlations.ts @@ -13,7 +13,7 @@ import type { FieldValuePair, CorrelationsParams, } from '../../../../common/correlations/types'; -import { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; +import type { LatencyCorrelation } from '../../../../common/correlations/latency_correlations/types'; import { computeExpectationsAndRanges, @@ -25,6 +25,7 @@ import { fetchTransactionDurationFractions, fetchTransactionDurationHistogramRangeSteps, fetchTransactionDurationPercentiles, + fetchTransactionDurationRanges, } from './index'; export const fetchSignificantCorrelations = async ( @@ -76,12 +77,54 @@ export const fetchSignificantCorrelations = async ( ) ); - const latencyCorrelations: LatencyCorrelation[] = fulfilled.filter( - (d): d is LatencyCorrelation => d !== undefined - ); + const latencyCorrelations = fulfilled.filter( + (d) => d && 'histogram' in d + ) as LatencyCorrelation[]; + let fallbackResult: LatencyCorrelation | undefined = + latencyCorrelations.length > 0 + ? undefined + : fulfilled + .filter((d) => !(d as LatencyCorrelation)?.histogram) + .reduce((d, result) => { + if (d?.correlation !== undefined) { + if (!result) { + result = d?.correlation > 0 ? d : undefined; + } else { + if ( + d.correlation > 0 && + d.ksTest > result.ksTest && + d.correlation > result.correlation + ) { + result = d; + } + } + } + return result; + }, undefined); + if (latencyCorrelations.length === 0 && fallbackResult) { + const { fieldName, fieldValue } = fallbackResult; + const logHistogram = await fetchTransactionDurationRanges( + esClient, + paramsWithIndex, + histogramRangeSteps, + [{ fieldName, fieldValue }] + ); + + if (fallbackResult) { + fallbackResult = { + ...fallbackResult, + histogram: logHistogram, + }; + } + } const ccsWarning = rejected.length > 0 && paramsWithIndex?.index.includes(':'); - return { latencyCorrelations, ccsWarning, totalDocCount }; + return { + latencyCorrelations, + ccsWarning, + totalDocCount, + fallbackResult, + }; }; diff --git a/x-pack/plugins/apm/server/routes/correlations/route.ts b/x-pack/plugins/apm/server/routes/correlations/route.ts index fd0bce7a62ff8..b0735c7c57b36 100644 --- a/x-pack/plugins/apm/server/routes/correlations/route.ts +++ b/x-pack/plugins/apm/server/routes/correlations/route.ts @@ -257,6 +257,7 @@ const significantCorrelationsRoute = createApmServerRoute({ >; ccsWarning: boolean; totalDocCount: number; + fallbackResult?: import('./../../../common/correlations/latency_correlations/types').LatencyCorrelation; }> => { const { context } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { @@ -314,6 +315,7 @@ const pValuesRoute = createApmServerRoute({ import('./../../../common/correlations/failed_transactions_correlations/types').FailedTransactionsCorrelation >; ccsWarning: boolean; + fallbackResult?: import('./../../../common/correlations/failed_transactions_correlations/types').FailedTransactionsCorrelation; }> => { const { context } = resources; if (!isActivePlatinumLicense(context.licensing.license)) { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index b7240d8653f90..3aaea1021494d 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5920,9 +5920,6 @@ "xpack.apm.correlations.failedTransactions.correlationsTable.impactLabel": "Impact", "xpack.apm.correlations.failedTransactions.correlationsTable.pValueLabel": "Score", "xpack.apm.correlations.failedTransactions.errorTitle": "Une erreur est survenue lors de l'exécution de corrélations sur les transactions ayant échoué", - "xpack.apm.correlations.failedTransactions.highImpactText": "Élevé", - "xpack.apm.correlations.failedTransactions.lowImpactText": "Bas", - "xpack.apm.correlations.failedTransactions.mediumImpactText": "Moyen", "xpack.apm.correlations.failedTransactions.panelTitle": "Transactions ayant échoué", "xpack.apm.correlations.latencyCorrelations.correlationsTable.actionsLabel": "Filtre", "xpack.apm.correlations.latencyCorrelations.correlationsTable.correlationColumnDescription": "Score de corrélation [0-1] d'un attribut ; plus le score est élevé, plus un attribut augmente la latence.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5a710d8f2c0df..22f1777bd67cc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6912,9 +6912,9 @@ "xpack.apm.correlations.failedTransactions.helpPopover.performanceExplanation": "この分析は多数の属性に対して統計検索を実行します。広い時間範囲やトランザクションスループットが高いサービスでは、時間がかかる場合があります。パフォーマンスを改善するには、時間範囲を絞り込みます。", "xpack.apm.correlations.failedTransactions.helpPopover.tableExplanation": "表はスコア別に並べ替えられます。これは高、中、低影響度にマッピングされます。影響度が高い属性は、失敗したトランザクションの原因である可能性が高くなります。", "xpack.apm.correlations.failedTransactions.helpPopover.title": "失敗したトランザクションの相関関係", - "xpack.apm.correlations.failedTransactions.highImpactText": "高", - "xpack.apm.correlations.failedTransactions.lowImpactText": "低", - "xpack.apm.correlations.failedTransactions.mediumImpactText": "中", + "xpack.apm.correlations.highImpactText": "高", + "xpack.apm.correlations.lowImpactText": "低", + "xpack.apm.correlations.mediumImpactText": "中", "xpack.apm.correlations.failedTransactions.panelTitle": "失敗したトランザクションの遅延分布", "xpack.apm.correlations.failedTransactions.tableTitle": "相関関係", "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "{fieldName}のフィルター:\"{value}\"", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 15ab16a10fe54..e270a12c50ef3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6927,9 +6927,9 @@ "xpack.apm.correlations.failedTransactions.helpPopover.performanceExplanation": "此分析会对大量属性执行统计搜索。对于较大时间范围和具有高事务吞吐量的服务,这可能需要些时间。减少时间范围以改善性能。", "xpack.apm.correlations.failedTransactions.helpPopover.tableExplanation": "表按分数排序,分数映射高、中或低影响级别。具有高影响级别的属性更可能造成事务失败。", "xpack.apm.correlations.failedTransactions.helpPopover.title": "失败事务相关性", - "xpack.apm.correlations.failedTransactions.highImpactText": "高", - "xpack.apm.correlations.failedTransactions.lowImpactText": "低", - "xpack.apm.correlations.failedTransactions.mediumImpactText": "中", + "xpack.apm.correlations.highImpactText": "高", + "xpack.apm.correlations.lowImpactText": "低", + "xpack.apm.correlations.mediumImpactText": "中", "xpack.apm.correlations.failedTransactions.panelTitle": "失败事务延迟分布", "xpack.apm.correlations.failedTransactions.tableTitle": "相关性", "xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel": "筛留 {fieldName}:“{value}”", diff --git a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts index e5062961e2f2c..1cdd59777c1c5 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/latency.spec.ts @@ -254,7 +254,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(correlation?.fieldValue).to.be('success'); expect(correlation?.correlation).to.be(0.6275246559191225); expect(correlation?.ksTest).to.be(4.806503252860024e-13); - expect(correlation?.histogram.length).to.be(101); + expect(correlation?.histogram?.length).to.be(101); const fieldStats = finalRawResponse?.fieldStats?.[0]; expect(typeof fieldStats).to.be('object'); From ac36edd0ab71dfb1ec3a173dba25e8f04230a2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Mon, 21 Mar 2022 18:19:52 +0100 Subject: [PATCH 005/132] [Unified Observability] Status visualization improvements (#127894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update style for status visualization boxes * Add content * Group boxes by hasData * Add links to doc-links service * Fix status visualization story * fix tests * fix doc link * fix translation keys * Update content for empty sections * Fix translation key * change button type * Update text Co-authored-by: Brandon Morelli * Update text Co-authored-by: Brandon Morelli * Update text Co-authored-by: Brandon Morelli * Update text Co-authored-by: Brandon Morelli * fix translations * Update button size Co-authored-by: Casper Hübertz * Update size Co-authored-by: Casper Hübertz * Remove custom margin Co-authored-by: Casper Hübertz * change description size * change casting * Remove link opening in a new window Co-authored-by: Casper Hübertz Co-authored-by: Brandon Morelli Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Casper Hübertz --- packages/kbn-doc-links/src/get_doc_links.ts | 6 + packages/kbn-doc-links/src/types.ts | 6 + .../app/observability_status/content.ts | 140 ++++++++++++++++++ .../app/observability_status/index.tsx | 14 +- .../observability_status.stories.tsx | 124 ++++++++-------- .../observability_status_box.test.tsx | 24 +-- .../observability_status_box.tsx | 71 ++++++--- .../observability_status_boxes.test.tsx | 21 ++- .../observability_status_boxes.tsx | 53 +++++-- .../public/pages/overview/empty_section.ts | 14 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 12 files changed, 347 insertions(+), 128 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/app/observability_status/content.ts diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index b2a8fe557bbfa..49708aa5fafc4 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -45,6 +45,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { droppedTransactionSpans: `${APM_DOCS}guide/${DOC_LINK_VERSION}/data-model-spans.html#data-model-dropped-spans`, upgrading: `${APM_DOCS}guide/${DOC_LINK_VERSION}/upgrade.html`, metaData: `${APM_DOCS}guide/${DOC_LINK_VERSION}/data-model-metadata.html`, + overview: `${APM_DOCS}guide/${DOC_LINK_VERSION}/apm-overview.html`, tailSamplingPolicies: `${APM_DOCS}guide/${DOC_LINK_VERSION}/configure-tail-based-sampling.html`, }, canvas: { @@ -398,6 +399,11 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { monitorUptime: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/monitor-uptime.html`, tlsCertificate: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/tls-certificate-alert.html`, uptimeDurationAnomaly: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/duration-anomaly-alert.html`, + monitorLogs: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/monitor-logs.html`, + analyzeMetrics: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/analyze-metrics.html`, + monitorUptimeSynthetics: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/monitor-uptime-synthetics.html`, + userExperience: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/user-experience.html`, + createAlerts: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/create-alerts.html`, }, alerting: { guide: `${KIBANA_DOCS}create-and-manage-rules.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 0922a0b2e49c1..ef3b490bbb094 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -31,6 +31,7 @@ export interface DocLinks { readonly droppedTransactionSpans: string; readonly upgrading: string; readonly metaData: string; + readonly overview: string; readonly tailSamplingPolicies: string; }; readonly canvas: { @@ -290,6 +291,11 @@ export interface DocLinks { monitorUptime: string; tlsCertificate: string; uptimeDurationAnomaly: string; + monitorLogs: string; + analyzeMetrics: string; + monitorUptimeSynthetics: string; + userExperience: string; + createAlerts: string; }>; readonly alerting: Record; readonly maps: Readonly<{ diff --git a/x-pack/plugins/observability/public/components/app/observability_status/content.ts b/x-pack/plugins/observability/public/components/app/observability_status/content.ts new file mode 100644 index 0000000000000..084d28a554472 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/content.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { HttpSetup, DocLinksStart } from 'kibana/public'; +import { ObservabilityFetchDataPlugins } from '../../../typings/fetch_overview_data'; + +export interface ObservabilityStatusContent { + id: ObservabilityFetchDataPlugins | 'alert'; + title: string; + description: string; + addTitle: string; + addLink: string; + learnMoreLink: string; + goToAppTitle: string; + goToAppLink: string; +} + +export const getContent = ( + http: HttpSetup, + docLinks: DocLinksStart +): ObservabilityStatusContent[] => { + return [ + { + id: 'infra_logs', + title: i18n.translate('xpack.observability.statusVisualization.logs.title', { + defaultMessage: 'Logs', + }), + description: i18n.translate('xpack.observability.statusVisualization.logs.description', { + defaultMessage: + 'Fast, easy, and scalable centralized log monitoring with out-of-the-box support for common data sources.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.logs.link', { + defaultMessage: 'Add integrations', + }), + addLink: http.basePath.prepend('/app/integrations/browse?q=logs'), + learnMoreLink: docLinks.links.observability.monitorLogs, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.logs.goToAppTitle', { + defaultMessage: 'Show log stream', + }), + goToAppLink: http.basePath.prepend('/app/logs/stream'), + }, + { + id: 'apm', + title: i18n.translate('xpack.observability.statusVisualization.apm.title', { + defaultMessage: 'APM', + }), + description: i18n.translate('xpack.observability.statusVisualization.apm.description', { + defaultMessage: + 'Get deeper visibility into your applications with extensive support for popular languages, OpenTelemetry, and distributed tracing.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.apm.link', { + defaultMessage: 'Add data', + }), + addLink: http.basePath.prepend('/app/home#/tutorial/apm'), + learnMoreLink: docLinks.links.apm.overview, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.apm.goToAppTitle', { + defaultMessage: 'Show services inventory', + }), + goToAppLink: http.basePath.prepend('/app/apm/services'), + }, + { + id: 'infra_metrics', + title: i18n.translate('xpack.observability.statusVisualization.metrics.title', { + defaultMessage: 'Infrastructure', + }), + description: i18n.translate('xpack.observability.statusVisualization.metrics.description', { + defaultMessage: 'Stream, visualize, and analyze your infrastructure metrics.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.metrics.link', { + defaultMessage: 'Add integrations', + }), + addLink: http.basePath.prepend('/app/integrations/browse?q=metrics'), + learnMoreLink: docLinks.links.observability.analyzeMetrics, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.metrics.goToAppTitle', { + defaultMessage: 'Show inventory', + }), + goToAppLink: http.basePath.prepend('/app/metrics/inventory'), + }, + { + id: 'synthetics', + title: i18n.translate('xpack.observability.statusVisualization.uptime.title', { + defaultMessage: 'Uptime', + }), + description: i18n.translate('xpack.observability.statusVisualization.uptime.description', { + defaultMessage: 'Proactively monitor the availability and functionality of user journeys.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.uptime.link', { + defaultMessage: 'Add monitors', + }), + addLink: http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), + learnMoreLink: docLinks.links.observability.monitorUptimeSynthetics, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.uptime.goToAppTitle', { + defaultMessage: 'Show monitors ', + }), + goToAppLink: http.basePath.prepend('/app/uptime'), + }, + { + id: 'ux', + title: i18n.translate('xpack.observability.statusVisualization.ux.title', { + defaultMessage: 'User Experience', + }), + description: i18n.translate('xpack.observability.statusVisualization.ux.description', { + defaultMessage: + 'Collect, measure, and analyze performance data that reflects real-world user experiences.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.ux.link', { + defaultMessage: 'Add data', + }), + addLink: http.basePath.prepend('/app/home#/tutorial/apm'), + learnMoreLink: docLinks.links.observability.userExperience, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.ux.goToAppTitle', { + defaultMessage: 'Show dashboard', + }), + goToAppLink: http.basePath.prepend('/app/ux'), + }, + { + id: 'alert', + title: i18n.translate('xpack.observability.statusVisualization.alert.title', { + defaultMessage: 'Alerting', + }), + description: i18n.translate('xpack.observability.statusVisualization.alert.description', { + defaultMessage: + 'Detect complex conditions in Observability and trigger actions when those conditions are met.', + }), + addTitle: i18n.translate('xpack.observability.statusVisualization.alert.link', { + defaultMessage: 'Create rules', + }), + addLink: http.basePath.prepend('/app/management/insightsAndAlerting/triggersActions/rules'), + learnMoreLink: docLinks.links.observability.createAlerts, + goToAppTitle: i18n.translate('xpack.observability.statusVisualization.alert.goToAppTitle', { + defaultMessage: 'Show alerts', + }), + goToAppLink: http.basePath.prepend('/app/observability/alerts'), + }, + ]; +}; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/index.tsx b/x-pack/plugins/observability/public/components/app/observability_status/index.tsx index 18760ea366b3c..08e8b58d19253 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/index.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/index.tsx @@ -8,25 +8,21 @@ import React from 'react'; import { useHasData } from '../../../hooks/use_has_data'; import { ObservabilityStatusBoxes } from './observability_status_boxes'; -import { getEmptySections } from '../../../pages/overview/empty_section'; +import { getContent } from './content'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityAppServices } from '../../../application/types'; export function ObservabilityStatus() { - const { http } = useKibana().services; + const { http, docLinks } = useKibana().services; const { hasDataMap } = useHasData(); - const appEmptySections = getEmptySections({ http }); + const content = getContent(http, docLinks); - const boxes = appEmptySections.map((app) => { + const boxes = content.map((app) => { return { - id: app.id, - dataSourceName: app.title, + ...app, hasData: hasDataMap[app.id]?.hasData ?? false, - description: app.description, modules: [], - integrationLink: app.href ?? '', - learnMoreLink: app.href ?? '', }; }); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx index d5e2eb9a78d46..c10ffa0500db6 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx @@ -16,82 +16,80 @@ export default { const testBoxes = [ { - id: 'logs', - dataSourceName: 'Logs', - hasData: true, - description: 'This is the description for logs', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: false }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + id: 'infra_logs', + title: 'Logs', + description: + 'Fast, easy, and scalable, centralized log monitoring with out-of-the-box support for common data sources.', + addTitle: 'Add integrations', + addLink: '/app/integrations/browse?q=logs', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show log stream', + goToAppLink: '/app/logs/stream', + hasData: false, + modules: [], }, { - id: 'metrics', - dataSourceName: 'Metrics', - hasData: true, - description: 'This is the description for metrics', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: false }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + id: 'apm', + title: 'APM', + description: + 'Get deeper visibility into your applications with extensive support for popular languages, OpenTelemetry, and distributed tracing.', + addTitle: 'Add data', + addLink: '/app/home#/tutorial/apm', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show services inventory', + goToAppLink: '/app/apm/services', + hasData: false, + modules: [], }, { - id: 'apm', - dataSourceName: 'APM', - hasData: true, - description: 'This is the description for apm', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + id: 'infra_metrics', + title: 'Infrastructure', + description: 'Stream, visualize, and analyze your infrastructure metrics.', + addTitle: 'Add integrations', + addLink: '/app/integrations/browse?q=metrics', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show inventory', + goToAppLink: '/app/metrics/inventory', + hasData: false, + modules: [], }, { - id: 'uptime', - dataSourceName: 'Uptime', + id: 'synthetics', + title: 'Uptime', + description: 'Proactively monitor the availability and functionality of user journeys.', + addTitle: 'Add monitors', + addLink: '/app/home#/tutorial/uptimeMonitors', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show monitors ', + goToAppLink: '/app/uptime', hasData: false, - description: 'This is the description for uptime', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + modules: [], }, { id: 'ux', - dataSourceName: 'User experience', - hasData: false, - description: 'This is the description for user experience', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + title: 'User Experience', + description: + 'Collect, measure, and analyze performance data that reflects real-world user experiences.', + addTitle: 'Add data', + addLink: '/app/home#/tutorial/apm', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show dashboard', + goToAppLink: '/app/ux', + hasData: true, + modules: [], }, { - id: 'alerts', - dataSourceName: 'Alerts and rules', + id: 'alert', + title: 'Alerting', + description: + 'Detect complex conditions in Observability and trigger actions when those conditions are met.', + addTitle: 'Create rules', + addLink: '/app/management/insightsAndAlerting/triggersActions/rules', + learnMoreLink: 'http://lean-more-link-example.com', + goToAppTitle: 'Show alerts', + goToAppLink: '/app/observability/alerts', hasData: true, - description: 'This is the description for alerts and rules', - modules: [ - { name: 'system', hasData: true }, - { name: 'kubernetes', hasData: true }, - { name: 'docker', hasData: true }, - ], - integrationLink: 'http://example.com', - learnMoreLink: 'http://example.com', + modules: [], }, ]; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx index 097b657f7936d..7bc9cb60ad349 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx @@ -15,12 +15,15 @@ describe('ObservabilityStatusBox', () => { beforeEach(() => { const props = { id: 'logs', - dataSourceName: 'Logs', + title: 'Logs', hasData: false, description: 'test description', modules: [], - integrationLink: 'testIntegrationUrl.com', + addTitle: 'logs add title', + addLink: 'http://example.com', learnMoreLink: 'learnMoreUrl.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }; render( @@ -35,8 +38,8 @@ describe('ObservabilityStatusBox', () => { }); it('should have a learn more button', () => { - const learnMoreLink = screen.getByRole('link') as HTMLAnchorElement; - expect(learnMoreLink.href).toContain('learnMoreUrl.com'); + const learnMoreLink = screen.getByText('Learn more') as HTMLElement; + expect(learnMoreLink.closest('a')?.href).toContain('learnMoreUrl.com'); }); }); @@ -44,7 +47,7 @@ describe('ObservabilityStatusBox', () => { beforeEach(() => { const props = { id: 'logs', - dataSourceName: 'Logs', + title: 'Logs', hasData: true, description: 'test description', modules: [ @@ -52,8 +55,11 @@ describe('ObservabilityStatusBox', () => { { name: 'module2', hasData: false }, { name: 'module3', hasData: true }, ], - integrationLink: 'addIntegrationUrl.com', - learnMoreLink: 'learnMoreUrl.com', + addTitle: 'logs add title', + addLink: 'addIntegrationUrl.com', + learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }; render( @@ -66,8 +72,8 @@ describe('ObservabilityStatusBox', () => { // it('should have a check icon', () => {}); it('should have the integration link', () => { - const addIntegrationLink = screen.getByRole('link') as HTMLAnchorElement; - expect(addIntegrationLink.href).toContain('addIntegrationUrl.com'); + const addIntegrationLink = screen.getByText('logs add title') as HTMLElement; + expect(addIntegrationLink.closest('a')?.href).toContain('addIntegrationUrl.com'); }); it('should have the list of modules', () => { diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx index 0363a98295d62..a819afab0bed5 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx @@ -15,18 +15,22 @@ import { EuiPanel, EuiText, EuiTitle, + EuiLink, } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; export interface ObservabilityStatusBoxProps { id: string; - dataSourceName: string; + title: string; hasData: boolean; description: string; modules: Array<{ name: string; hasData: boolean }>; - integrationLink: string; + addTitle: string; + addLink: string; learnMoreLink: string; + goToAppTitle: string; + goToAppLink: string; } export function ObservabilityStatusBox(props: ObservabilityStatusBoxProps) { @@ -38,12 +42,15 @@ export function ObservabilityStatusBox(props: ObservabilityStatusBoxProps) { } export function CompletedStatusBox({ - dataSourceName, + title, modules, - integrationLink, + addLink, + addTitle, + goToAppTitle, + goToAppLink, }: ObservabilityStatusBoxProps) { return ( - +
@@ -54,20 +61,26 @@ export function CompletedStatusBox({ style={{ marginRight: 8 }} /> -

{dataSourceName}

+

{title}

- - + + {addTitle}
+ + + + + + {modules.map((module) => ( @@ -81,50 +94,62 @@ export function CompletedStatusBox({ ))} + + + + + {goToAppTitle} + + +
); } export function EmptyStatusBox({ - dataSourceName, + title, description, learnMoreLink, + addTitle, + addLink, }: ObservabilityStatusBoxProps) { return ( - +
-

{dataSourceName}

+

{title}

- - -
- {description} + {description} - - - + + + + {addTitle} + + + + - +
diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx index 0e57dc28aa5dd..9ad69b2ce64f8 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx @@ -8,31 +8,42 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ObservabilityStatusBoxes } from './observability_status_boxes'; +import { I18nProvider } from '@kbn/i18n-react'; describe('ObservabilityStatusBoxes', () => { it('should render all boxes passed as prop', () => { const boxes = [ { id: 'logs', - dataSourceName: 'Logs', + title: 'Logs', hasData: true, description: 'This is the description for logs', modules: [], - integrationLink: 'http://example.com', + addTitle: 'logs add title', + addLink: 'http://example.com', learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }, { id: 'metrics', - dataSourceName: 'Metrics', + title: 'Metrics', hasData: true, description: 'This is the description for metrics', modules: [], - integrationLink: 'http://example.com', + addTitle: 'metrics add title', + addLink: 'http://example.com', learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', }, ]; - render(); + render( + + + + ); expect(screen.getByText('Logs')).toBeInTheDocument(); expect(screen.getByText('Metrics')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx index f7bec339fbe2a..0827f7f8c768c 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx @@ -6,21 +6,56 @@ */ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; -import { ObservabilityStatusBox, ObservabilityStatusBoxProps } from './observability_status_box'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + CompletedStatusBox, + EmptyStatusBox, + ObservabilityStatusBoxProps, +} from './observability_status_box'; export interface ObservabilityStatusProps { boxes: ObservabilityStatusBoxProps[]; } export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { + const hasDataBoxes = boxes.filter((box) => box.hasData); + const noHasDataBoxes = boxes.filter((box) => !box.hasData); + return ( -
- {boxes.map((box) => ( - <> - - - + + + +

+ +

+
+
+ {noHasDataBoxes.map((box) => ( + + + + ))} + + {noHasDataBoxes.length > 0 && } + + + +

+ +

+
+
+ {hasDataBoxes.map((box) => ( + + + ))} -
+ ); } diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index 98fb24c671cc3..d2050f159fc25 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -19,7 +19,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'logoLogging', description: i18n.translate('xpack.observability.emptySection.apps.logs.description', { defaultMessage: - 'Centralize logs from any source. Search, tail, automate anomaly detection, and visualize trends so you can take action quicker.', + 'Fast, easy, and scalable centralized log monitoring with out-of-the-box support for common data sources.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.logs.link', { defaultMessage: 'Install Filebeat', @@ -34,7 +34,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'logoObservability', description: i18n.translate('xpack.observability.emptySection.apps.apm.description', { defaultMessage: - 'Trace transactions through a distributed architecture and map your services’ interactions to easily spot performance bottlenecks.', + 'Get deeper visibility into your applications with extensive support for popular languages, OpenTelemetry, and distributed tracing.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.apm.link', { defaultMessage: 'Install Agent', @@ -48,8 +48,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { }), icon: 'logoMetrics', description: i18n.translate('xpack.observability.emptySection.apps.metrics.description', { - defaultMessage: - 'Analyze metrics from your infrastructure, apps, and services. Discover trends, forecast behavior, get alerts on anomalies, and more.', + defaultMessage: 'Stream, visualize, and analyze your infrastructure metrics.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.metrics.link', { defaultMessage: 'Install Metricbeat', @@ -63,8 +62,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { }), icon: 'logoUptime', description: i18n.translate('xpack.observability.emptySection.apps.uptime.description', { - defaultMessage: - 'Proactively monitor the availability of your sites and services. Receive alerts and resolve issues faster to optimize your users’ experience.', + defaultMessage: 'Proactively monitor the availability and functionality of user journeys.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.uptime.link', { defaultMessage: 'Install Heartbeat', @@ -79,7 +77,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'logoObservability', description: i18n.translate('xpack.observability.emptySection.apps.ux.description', { defaultMessage: - 'Performance is a distribution. Measure the experiences of all visitors to your web application and understand how to improve the experience for everyone.', + 'Collect, measure, and analyze performance data that reflects real-world user experiences.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.ux.link', { defaultMessage: 'Install RUM Agent', @@ -94,7 +92,7 @@ export const getEmptySections = ({ http }: { http: HttpSetup }): ISection[] => { icon: 'watchesApp', description: i18n.translate('xpack.observability.emptySection.apps.alert.description', { defaultMessage: - 'Are 503 errors stacking up? Are services responding? Is CPU and RAM utilization jumping? See warnings as they happen—not as part of the post-mortem.', + 'Detect complex conditions within Observability and trigger actions when those conditions are met.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.alert.link', { defaultMessage: 'Create rule', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 22f1777bd67cc..6969f983ab430 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20955,7 +20955,6 @@ "xpack.observability.seriesEditor.edit": "系列を編集", "xpack.observability.seriesEditor.hide": "系列を非表示", "xpack.observability.seriesEditor.sampleDocuments": "新しいタブでサンプルドキュメントを表示", - "xpack.observability.status.addIntegrationLink": "追加", "xpack.observability.status.learnMoreButton": "詳細", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "ワイルドカード*{wildcard}*を使用", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e270a12c50ef3..e1b4231f9479d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20983,7 +20983,6 @@ "xpack.observability.seriesEditor.edit": "编辑序列", "xpack.observability.seriesEditor.hide": "隐藏序列", "xpack.observability.seriesEditor.sampleDocuments": "在新选项卡中查看样例文档", - "xpack.observability.status.addIntegrationLink": "添加", "xpack.observability.status.learnMoreButton": "了解详情", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.urlFilter.wildcard": "使用通配符 *{wildcard}*", From 052861df9d8c9ee2b02f8eee06ebee8fe5ac863a Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 21 Mar 2022 10:32:23 -0700 Subject: [PATCH 006/132] [kibana_react/WithSolutionNavBar] fix dislpayName calculation (#128091) --- .../__snapshots__/page_template.test.tsx.snap | 20 +------------------ .../page_template/page_template.test.tsx | 3 +-- .../page_template/with_solution_nav.tsx | 7 ++++++- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap index 5889338c37d8c..4f2fd7cee4a5a 100644 --- a/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/__snapshots__/page_template.test.tsx.snap @@ -112,25 +112,7 @@ exports[`KibanaPageTemplate render default empty prompt 1`] = ` `; exports[`KibanaPageTemplate render noDataContent 1`] = ` - { - const { - className, - noDataConfig, - ...rest - } = props; - - if (!noDataConfig) { - return null; - } - - const template = _util.NO_DATA_PAGE_TEMPLATE_PROPS.template; - const classes = (0, _util.getClasses)(template, className); - return /*#__PURE__*/_react.default.createElement(_eui.EuiPageTemplate, (0, _extends2.default)({ - "data-test-subj": props['data-test-subj'], - template: template, - className: classes - }, rest, _util.NO_DATA_PAGE_TEMPLATE_PROPS), /*#__PURE__*/_react.default.createElement(_no_data_page.NoDataPage, noDataConfig)); -} + { expect(component.find('div.kbnPageTemplate__pageSideBar').length).toBe(1); }); - // https://github.com/elastic/kibana/issues/127951 - test.skip('render noDataContent', () => { + test('render noDataContent', () => { const component = shallow( ) { + return Component.displayName || Component.name || 'UnnamedComponent'; +} + type SolutionNavProps = KibanaPageTemplateProps & { solutionNav: KibanaPageTemplateSolutionNavProps; }; @@ -70,6 +75,6 @@ export const withSolutionNav = (WrappedComponent: ComponentType ); }; - WithSolutionNav.displayName = `WithSolutionNavBar${WrappedComponent}`; + WithSolutionNav.displayName = `WithSolutionNavBar(${getDisplayName(WrappedComponent)})`; return WithSolutionNav; }; From 8df17b9835855f4be6fd6d611a0341733386bbae Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Mon, 21 Mar 2022 18:53:36 +0100 Subject: [PATCH 007/132] [Workplace Search] Show external connector URL and API key on source settings page (#128164) * [Workplace Search] Show external connector URL and API key on source settings page --- .../source_config_fields.test.tsx | 25 ++++++- .../source_config_fields.tsx | 70 +++++++++++++++++-- .../workplace_search/constants.ts | 14 ++++ .../external_connector_form_fields.tsx | 4 +- .../components/source_settings.tsx | 12 +++- 5 files changed, 116 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx index 9aa0286b2bef0..671d5efd0641d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.test.tsx @@ -12,7 +12,7 @@ import { shallow } from 'enzyme'; import { ApiKey } from '../api_key'; import { CredentialItem } from '../credential_item'; -import { SourceConfigFields } from './'; +import { SourceConfigFields } from './source_config_fields'; describe('SourceConfigFields', () => { it('renders empty with no items', () => { @@ -31,11 +31,14 @@ describe('SourceConfigFields', () => { publicKey="abc" consumerKey="def" baseUrl="ghi" + externalConnectorUrl="https://url.com" + externalConnectorApiKey="apiKey" /> ); expect(wrapper.find(ApiKey)).toHaveLength(0); - expect(wrapper.find(CredentialItem)).toHaveLength(3); + expect(wrapper.find(CredentialItem)).toHaveLength(4); + expect(wrapper.find('[data-test-subj="external-connector-url-input"]')).toHaveLength(1); }); it('shows API keys', () => { @@ -51,4 +54,22 @@ describe('SourceConfigFields', () => { expect(wrapper.find(ApiKey)).toHaveLength(2); }); + + it('handles select all button click', () => { + const wrapper = shallow(); + const simulatedEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + currentTarget: { select: jest.fn() }, + preventDefault: jest.fn(), + }; + + const input = wrapper + .find('[data-test-subj="external-connector-url-input"]') + .dive() + .find('input'); + input.simulate('click', simulatedEvent); + + expect(simulatedEvent.currentTarget.select).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx index e33e7817b5209..6f2975a8ab7a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/source_config_fields/source_config_fields.tsx @@ -7,7 +7,15 @@ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiCopy, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; import { PUBLIC_KEY_LABEL, @@ -15,6 +23,10 @@ import { BASE_URL_LABEL, CLIENT_ID_LABEL, CLIENT_SECRET_LABEL, + EXTERNAL_CONNECTOR_API_KEY_LABEL, + EXTERNAL_CONNECTOR_URL_LABEL, + COPIED_TOOLTIP, + COPY_TOOLTIP, } from '../../../constants'; import { ApiKey } from '../api_key'; import { CredentialItem } from '../credential_item'; @@ -26,6 +38,8 @@ interface SourceConfigFieldsProps { publicKey?: string; consumerKey?: string; baseUrl?: string; + externalConnectorUrl?: string; + externalConnectorApiKey?: string; } export const SourceConfigFields: React.FC = ({ @@ -35,9 +49,16 @@ export const SourceConfigFields: React.FC = ({ publicKey, consumerKey, baseUrl, + externalConnectorApiKey, + externalConnectorUrl, }) => { const credentialItem = (label: string, item?: string) => - item && ; + item && ( + <> + + + + ); const keyElement = ( <> @@ -60,10 +81,51 @@ export const SourceConfigFields: React.FC = ({ <> {isOauth1 && keyElement} {!isOauth1 && credentialItem(CLIENT_ID_LABEL, clientId)} - {!isOauth1 && credentialItem(CLIENT_SECRET_LABEL, clientSecret)} - {credentialItem(BASE_URL_LABEL, baseUrl)} + {credentialItem(EXTERNAL_CONNECTOR_API_KEY_LABEL, externalConnectorApiKey)} + {externalConnectorUrl && ( + <> + + + + + {EXTERNAL_CONNECTOR_URL_LABEL} + + + + + + + {(copy) => ( + + )} + + + + ) => e.currentTarget.select()} + /> + + + + + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index dba459df04380..5a06cd0907187 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -479,6 +479,20 @@ export const BASE_URL_LABEL = i18n.translate( } ); +export const EXTERNAL_CONNECTOR_URL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.externalConnectorUrl.label', + { + defaultMessage: 'Connector URL', + } +); + +export const EXTERNAL_CONNECTOR_API_KEY_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.externalConnectorApiKey.label', + { + defaultMessage: 'Connector API key', + } +); + export const CLIENT_ID_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.clientId.label', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx index c4f5c5479fb32..2f987f9266223 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx @@ -54,7 +54,7 @@ export const ExternalConnectorFormFields: React.FC = () => { label={i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.urlLabel', { - defaultMessage: 'URL', + defaultMessage: 'Connector URL', } )} isInvalid={!urlValid} @@ -92,7 +92,7 @@ export const ExternalConnectorFormFields: React.FC = () => { label={i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.apiKeyLabel', { - defaultMessage: 'API key', + defaultMessage: 'Connector API key', } )} > diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index d57dc49683275..c2c53bc33a64a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -105,7 +105,15 @@ export const SourceSettings: React.FC = () => { const showOauthConfig = !isGithubApp && isOrganization && !isEmpty(configuredFields); const showGithubAppConfig = isGithubApp; - const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {}; + const { + clientId, + clientSecret, + publicKey, + consumerKey, + baseUrl, + externalConnectorUrl, + externalConnectorApiKey, + } = configuredFields || {}; const handleNameChange = (e: ChangeEvent) => setValue(e.target.value); @@ -190,6 +198,8 @@ export const SourceSettings: React.FC = () => { publicKey={publicKey} consumerKey={consumerKey} baseUrl={baseUrl} + externalConnectorUrl={externalConnectorUrl} + externalConnectorApiKey={externalConnectorApiKey} /> From ebc98cc41e8243b58ca1720af549226922c75f4c Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Mon, 21 Mar 2022 19:00:16 +0100 Subject: [PATCH 008/132] [Fleet] Update elastic_agent bundled package 1.3.1 (#128138) --- fleet_packages.json | 2 +- .../server/integration_tests/helpers/docker_registry_helper.ts | 2 +- x-pack/test/fleet_api_integration/config.ts | 2 +- x-pack/test/functional/config.js | 2 +- x-pack/test/functional_synthetics/config.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fleet_packages.json b/fleet_packages.json index 69fd83f12037c..c620d438d4f8d 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -19,7 +19,7 @@ }, { "name": "elastic_agent", - "version": "1.3.0" + "version": "1.3.1" }, { "name": "endpoint", diff --git a/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts b/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts index 04d75f15c7f81..b44f0bf59d03e 100644 --- a/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts +++ b/x-pack/plugins/fleet/server/integration_tests/helpers/docker_registry_helper.ts @@ -24,7 +24,7 @@ export function useDockerRegistry() { let dockerProcess: ChildProcess | undefined; async function startDockerRegistryServer() { - const dockerImage = `docker.elastic.co/package-registry/distribution@sha256:b3dfc6a11ff7dce82ba8689ea9eeb54e353c6b4bfd2d28127b20ef72fd8883e9`; + const dockerImage = `docker.elastic.co/package-registry/distribution@sha256:536fcac0b66de593bd21851fd3553892a28e6e838e191ee25818acb4a23ecc7f`; const args = ['run', '--rm', '-p', `${packageRegistryPort}:8080`, dockerImage]; diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index 3c5f19f6896d4..38c0d2593070d 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -15,7 +15,7 @@ import { defineDockerServersConfig } from '@kbn/test'; // example: https://beats-ci.elastic.co/blue/organizations/jenkins/Ingest-manager%2Fpackage-storage/detail/snapshot/74/pipeline/257#step-302-log-1. // It should be updated any time there is a new Docker image published for the Snapshot Distribution of the Package Registry. export const dockerImage = - 'docker.elastic.co/package-registry/distribution@sha256:b3dfc6a11ff7dce82ba8689ea9eeb54e353c6b4bfd2d28127b20ef72fd8883e9'; + 'docker.elastic.co/package-registry/distribution@sha256:536fcac0b66de593bd21851fd3553892a28e6e838e191ee25818acb4a23ecc7f'; export const BUNDLED_PACKAGE_DIR = '/tmp/fleet_bundled_packages'; diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 71c82b05961f0..1c627dc8af6da 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -15,7 +15,7 @@ import { pageObjects } from './page_objects'; // example: https://beats-ci.elastic.co/blue/organizations/jenkins/Ingest-manager%2Fpackage-storage/detail/snapshot/74/pipeline/257#step-302-log-1. // It should be updated any time there is a new Docker image published for the Snapshot Distribution of the Package Registry. export const dockerImage = - 'docker.elastic.co/package-registry/distribution@sha256:b3dfc6a11ff7dce82ba8689ea9eeb54e353c6b4bfd2d28127b20ef72fd8883e9'; + 'docker.elastic.co/package-registry/distribution@sha256:536fcac0b66de593bd21851fd3553892a28e6e838e191ee25818acb4a23ecc7f'; // the default export of config files must be a config provider // that returns an object with the projects config values diff --git a/x-pack/test/functional_synthetics/config.js b/x-pack/test/functional_synthetics/config.js index 58a6f85196ba0..42bbfcf88d924 100644 --- a/x-pack/test/functional_synthetics/config.js +++ b/x-pack/test/functional_synthetics/config.js @@ -17,7 +17,7 @@ import { pageObjects } from './page_objects'; // example: https://beats-ci.elastic.co/blue/organizations/jenkins/Ingest-manager%2Fpackage-storage/detail/snapshot/74/pipeline/257#step-302-log-1. // It should be updated any time there is a new Docker image published for the Snapshot Distribution of the Package Registry that updates Synthetics. export const dockerImage = - 'docker.elastic.co/package-registry/distribution@sha256:b3dfc6a11ff7dce82ba8689ea9eeb54e353c6b4bfd2d28127b20ef72fd8883e9'; + 'docker.elastic.co/package-registry/distribution@sha256:536fcac0b66de593bd21851fd3553892a28e6e838e191ee25818acb4a23ecc7f'; // the default export of config files must be a config provider // that returns an object with the projects config values From bdc08fbf4f12ce76a11347341ad856fe34c07c21 Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Mon, 21 Mar 2022 14:06:13 -0400 Subject: [PATCH 009/132] [Global Search Bar] Updated placeholder text to be more directive (#127903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit And: * Fixed search bar’s width on smaller screens * Added an `append` with the symbol form of the keyboard shortcut --- .../public/components/search_bar.scss | 7 +++- .../public/components/search_bar.tsx | 36 ++++++++++++++++++- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.scss b/x-pack/plugins/global_search_bar/public/components/search_bar.scss index 7e6c3ddaa3126..045b0b5324630 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.scss +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.scss @@ -16,7 +16,6 @@ //TODO add these overrides to EUI so that search behaves the same globally (eui/issues/4363) .kbnSearchBar { - width: 400px; max-width: 100%; will-change: width; } @@ -27,6 +26,12 @@ } } +@include euiBreakpoint('m', 'l', 'xl') { + .kbnSearchBar { + width: 400px; + } +} + @include euiBreakpoint('l', 'xl') { .kbnSearchBar:focus { animation: kbnAnimateSearchBar $euiAnimSpeedFast forwards; diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index 95dc6a555564e..ab7bea265c626 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -11,6 +11,8 @@ import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; import { Subscription } from 'rxjs'; import { + useEuiTheme, + EuiFormLabel, EuiHeaderSectionItemButton, EuiIcon, EuiSelectableTemplateSitewide, @@ -69,6 +71,7 @@ export const SearchBar: FC = ({ darkMode, }) => { const isMounted = useMountedState(); + const { euiTheme } = useEuiTheme(); const [initialLoad, setInitialLoad] = useState(false); const [searchValue, setSearchValue] = useState(''); const [searchTerm, setSearchTerm] = useState(''); @@ -77,6 +80,7 @@ export const SearchBar: FC = ({ const searchSubscription = useRef(null); const [options, _setOptions] = useState([]); const [searchableTypes, setSearchableTypes] = useState([]); + const [showAppend, setShowAppend] = useState(true); const UNKNOWN_TAG_ID = '__unknown__'; useEffect(() => { @@ -252,8 +256,25 @@ export const SearchBar: FC = ({ const emptyMessage = ; const placeholderText = i18n.translate('xpack.globalSearchBar.searchBar.placeholder', { - defaultMessage: 'Search Elastic', + defaultMessage: 'Find apps, content, and more. Ex: Discover', }); + const keyboardShortcutTooltip = `${i18n.translate( + 'xpack.globalSearchBar.searchBar.shortcutTooltip.description', + { + defaultMessage: 'Keyboard shortcut', + } + )}: ${ + isMac + ? i18n.translate('xpack.globalSearchBar.searchBar.shortcutTooltip.macCommandDescription', { + defaultMessage: 'Command + /', + }) + : i18n.translate( + 'xpack.globalSearchBar.searchBar.shortcutTooltip.windowsCommandDescription', + { + defaultMessage: 'Control + /', + } + ) + }`; useEvent('keydown', onKeyDown); @@ -277,7 +298,20 @@ export const SearchBar: FC = ({ onFocus: () => { trackUiMetric(METRIC_TYPE.COUNT, 'search_focus'); setInitialLoad(true); + setShowAppend(false); + }, + onBlur: () => { + setShowAppend(true); }, + fullWidth: true, + append: showAppend ? ( + + {isMac ? '⌘/' : '^/'} + + ) : undefined, }} emptyMessage={emptyMessage} noMatchesMessage={emptyMessage} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 3aaea1021494d..31521ca81e037 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -10449,7 +10449,6 @@ "xpack.globalSearchBar.searchBar.noResultsImageAlt": "Illustration d'un trou noir", "xpack.globalSearchBar.searchBar.optionTagListAriaLabel": "Balises", "xpack.globalSearchBar.searchbar.overflowTagsAriaLabel": "{n} {n, plural, one {balise} other {balises}} de plus : {tags}", - "xpack.globalSearchBar.searchBar.placeholder": "Rechercher dans Elastic", "xpack.globalSearchBar.searchBar.shortcutDescription.macCommandDescription": "Commande + /", "xpack.globalSearchBar.searchBar.shortcutDescription.shortcutDetail": "{shortcutDescription} {commandDescription}", "xpack.globalSearchBar.searchBar.shortcutDescription.shortcutInstructionDescription": "Raccourci", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6969f983ab430..d64b50fde976c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12473,7 +12473,6 @@ "xpack.globalSearchBar.searchBar.noResultsImageAlt": "ブラックホールの図", "xpack.globalSearchBar.searchBar.optionTagListAriaLabel": "タグ", "xpack.globalSearchBar.searchbar.overflowTagsAriaLabel": "{n} その他の {n, plural, other {個のタグ}}:{tags}", - "xpack.globalSearchBar.searchBar.placeholder": "Elastic を検索", "xpack.globalSearchBar.searchBar.shortcutDescription.macCommandDescription": "コマンド+ /", "xpack.globalSearchBar.searchBar.shortcutDescription.shortcutDetail": "{shortcutDescription} {commandDescription}", "xpack.globalSearchBar.searchBar.shortcutDescription.shortcutInstructionDescription": "ショートカット", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e1b4231f9479d..fda2023a170f5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12496,7 +12496,6 @@ "xpack.globalSearchBar.searchBar.noResultsImageAlt": "黑洞的图示", "xpack.globalSearchBar.searchBar.optionTagListAriaLabel": "标签", "xpack.globalSearchBar.searchbar.overflowTagsAriaLabel": "另外 {n} 个{n, plural, other {标签}}:{tags}", - "xpack.globalSearchBar.searchBar.placeholder": "搜索 Elastic", "xpack.globalSearchBar.searchBar.shortcutDescription.macCommandDescription": "Command + /", "xpack.globalSearchBar.searchBar.shortcutDescription.shortcutDetail": "{shortcutDescription} {commandDescription}", "xpack.globalSearchBar.searchBar.shortcutDescription.shortcutInstructionDescription": "快捷方式", From ed7b51ffd6e3f961c667079bd6b8eec658f9e9d9 Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Mon, 21 Mar 2022 14:09:13 -0400 Subject: [PATCH 010/132] [Security Solution] [Security Platform] Allow users without any actions privileges to still import rules (#126203) allow users without any actions privileges to still import rules, adds tests to cover this case Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/actions/server/index.ts | 1 + .../security_solution/common/test/index.ts | 1 + .../routes/rules/import_rules_route.ts | 24 +++- .../detection_engine/routes/rules/utils.ts | 34 ++++- .../roles_users/hunter/detections_role.json | 6 +- .../roles_users/hunter_no_actions/README.md | 11 ++ .../delete_detections_user.sh | 11 ++ .../hunter_no_actions/detections_role.json | 43 +++++++ .../hunter_no_actions/detections_user.json | 6 + .../hunter_no_actions/get_detections_role.sh | 11 ++ .../roles_users/hunter_no_actions/index.ts | 10 ++ .../hunter_no_actions/post_detections_role.sh | 14 ++ .../hunter_no_actions/post_detections_user.sh | 14 ++ .../scripts/roles_users/index.ts | 1 + .../security_solution/roles_users_utils.ts | 11 +- .../security_and_spaces/tests/import_rules.ts | 121 ++++++++++++++++++ 16 files changed, 301 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/README.md create mode 100755 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/delete_detections_user.sh create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_role.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_user.json create mode 100755 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/get_detections_role.sh create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/index.ts create mode 100755 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_role.sh create mode 100755 x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_user.sh diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index e1c60b9fd0491..9af6db47d076c 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -22,6 +22,7 @@ export type { ActionType, PreConfiguredAction, ActionsApiRequestHandlerContext, + FindActionResult, } from './types'; export type { diff --git a/x-pack/plugins/security_solution/common/test/index.ts b/x-pack/plugins/security_solution/common/test/index.ts index 53261d54e84b0..cac0a3184dd39 100644 --- a/x-pack/plugins/security_solution/common/test/index.ts +++ b/x-pack/plugins/security_solution/common/test/index.ts @@ -12,6 +12,7 @@ export enum ROLES { t1_analyst = 't1_analyst', t2_analyst = 't2_analyst', hunter = 'hunter', + hunter_no_actions = 'hunter_no_actions', rule_author = 'rule_author', platform_engineer = 'platform_engineer', detections_admin = 'detections_admin', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 3cb8ff029a68a..b0f09a8c76d3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -45,6 +45,7 @@ import { } from './utils/import_rules_utils'; import { getReferencedExceptionLists } from './utils/gather_referenced_exceptions'; import { importRuleExceptions } from './utils/import_rule_exceptions'; +import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request'; const CHUNK_PARSED_OBJECT_SIZE = 50; @@ -138,22 +139,33 @@ export const importRulesRoute = ( actionSOClient ); - const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors( - migratedParsedObjectsWithoutDuplicateErrors, - actionsClient + let parsedRules; + let actionErrors: BulkError[] = []; + const actualRules = rules.filter( + (rule): rule is ImportRulesSchemaDecoded => !(rule instanceof Error) ); + if (actualRules.some((rule) => rule.actions.length > 0)) { + const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors( + migratedParsedObjectsWithoutDuplicateErrors, + actionsClient + ); + parsedRules = uniqueParsedObjects; + actionErrors = nonExistentActionErrors; + } else { + parsedRules = migratedParsedObjectsWithoutDuplicateErrors; + } // gather all exception lists that the imported rules reference const foundReferencedExceptionLists = await getReferencedExceptionLists({ - rules: uniqueParsedObjects, + rules: parsedRules, savedObjectsClient, }); - const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); + const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, parsedRules); const importRuleResponse: ImportRuleResponse[] = await importRulesHelper({ ruleChunks: chunkParseObjects, - rulesResponseAcc: [...nonExistentActionErrors, ...duplicateIdErrors], + rulesResponseAcc: [...actionErrors, ...duplicateIdErrors], mlAuthz, overwriteRules: request.query.overwrite, rulesClient, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index ff2eab1f799b9..da07f5ae1a23a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -16,7 +16,7 @@ import { RulesSchema } from '../../../../../common/detection_engine/schemas/resp import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/import_rules_schema'; import { CreateRulesBulkSchema } from '../../../../../common/detection_engine/schemas/request/create_rules_bulk_schema'; import { PartialAlert, FindResult } from '../../../../../../alerting/server'; -import { ActionsClient } from '../../../../../../actions/server'; +import { ActionsClient, FindActionResult } from '../../../../../../actions/server'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { RuleAlertType, isAlertType } from '../../rules/types'; import { createBulkErrorObject, BulkError, OutputError } from '../utils'; @@ -305,7 +305,32 @@ export const getInvalidConnectors = async ( rules: PromiseFromStreams[], actionsClient: ActionsClient ): Promise<[BulkError[], PromiseFromStreams[]]> => { - const actionsFind = await actionsClient.getAll(); + let actionsFind: FindActionResult[] = []; + const reducerAccumulator = { + errors: new Map(), + rulesAcc: new Map(), + }; + try { + actionsFind = await actionsClient.getAll(); + } catch (exc) { + if (exc?.output?.statusCode === 403) { + reducerAccumulator.errors.set( + uuid.v4(), + createBulkErrorObject({ + statusCode: exc.output.statusCode, + message: `You may not have actions privileges required to import rules with actions: ${exc.output.payload.message}`, + }) + ); + } else { + reducerAccumulator.errors.set( + uuid.v4(), + createBulkErrorObject({ + statusCode: 404, + message: JSON.stringify(exc), + }) + ); + } + } const actionIds = new Set(actionsFind.map((action) => action.id)); const { errors, rulesAcc } = rules.reduce( (acc, parsedRule) => { @@ -339,10 +364,7 @@ export const getInvalidConnectors = async ( } return acc; }, // using map (preserves ordering) - { - errors: new Map(), - rulesAcc: new Map(), - } + reducerAccumulator ); return [Array.from(errors.values()), Array.from(rulesAcc.values())]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json index 57464e028a656..42ef9ba1122c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter/detections_role.json @@ -24,11 +24,7 @@ "privileges": ["read", "write"] }, { - "names": [ - "metrics-endpoint.metadata_current_*", - ".fleet-agents*", - ".fleet-actions*" - ], + "names": ["metrics-endpoint.metadata_current_*", ".fleet-agents*", ".fleet-actions*"], "privileges": ["read"] } ] diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/README.md new file mode 100644 index 0000000000000..7708972614098 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/README.md @@ -0,0 +1,11 @@ +This user can CRUD rules and signals. The main difference here is the user has + +```json +"builtInAlerts": ["all"], +``` + +privileges whereas the T1 and T2 have "read" privileges which prevents them from creating rules + +| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts | +| :-----------------: | :----------: | :------------------: | :---: | :--------------: | :---------------: | :------------: | +| Hunter / T3 Analyst | read, write | read | read | read, write | none | read, write | diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/delete_detections_user.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/delete_detections_user.sh new file mode 100755 index 0000000000000..8f2ffcb27f111 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/delete_detections_user.sh @@ -0,0 +1,11 @@ + +# +# 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. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/hunter_no_actions diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_role.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_role.json new file mode 100644 index 0000000000000..e8000d6bb50e7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_role.json @@ -0,0 +1,43 @@ +{ + "elasticsearch": { + "cluster": [], + "indices": [ + { + "names": [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "privileges": ["read", "write"] + }, + { + "names": [".alerts-security*", ".siem-signals-*"], + "privileges": ["read", "write"] + }, + { + "names": [".lists*", ".items*"], + "privileges": ["read", "write"] + }, + { + "names": ["metrics-endpoint.metadata_current_*", ".fleet-agents*", ".fleet-actions*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "ml": ["read"], + "siem": ["all", "read_alerts", "crud_alerts"], + "securitySolutionCases": ["all"], + "builtInAlerts": ["all"] + }, + "spaces": ["*"] + } + ] +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_user.json new file mode 100644 index 0000000000000..c059863b3ca1f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["hunter_no_actions"], + "full_name": "Hunter No Actions", + "email": "detections-reader@example.com" +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/get_detections_role.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/get_detections_role.sh new file mode 100755 index 0000000000000..49deae0c6c450 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/get_detections_role.sh @@ -0,0 +1,11 @@ + +# +# 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. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/hunter_no_actions | jq -S . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/index.ts new file mode 100644 index 0000000000000..16d50f9b59daa --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/index.ts @@ -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 * as hunterNoActionsUser from './detections_user.json'; +import * as hunterNoActionsRole from './detections_role.json'; +export { hunterNoActionsUser, hunterNoActionsRole }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_role.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_role.sh new file mode 100755 index 0000000000000..aa4f832649b08 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_role.sh @@ -0,0 +1,14 @@ + +# +# 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. +# + +ROLE=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/hunter_no_actions \ +-d @${ROLE} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_user.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_user.sh new file mode 100755 index 0000000000000..4840cf3c903eb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/hunter_no_actions/post_detections_user.sh @@ -0,0 +1,14 @@ + +# +# 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. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/hunter_no_actions \ +-d @${USER} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/index.ts index bcdc472477531..7bcef506a6671 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/roles_users/index.ts @@ -7,6 +7,7 @@ export * from './detections_admin'; export * from './hunter'; +export * from './hunter_no_actions'; export * from './platform_engineer'; export * from './reader'; export * from './rule_author'; diff --git a/x-pack/test/common/services/security_solution/roles_users_utils.ts b/x-pack/test/common/services/security_solution/roles_users_utils.ts index 681e710aa896c..34829e360cd20 100644 --- a/x-pack/test/common/services/security_solution/roles_users_utils.ts +++ b/x-pack/test/common/services/security_solution/roles_users_utils.ts @@ -11,6 +11,7 @@ import { t1AnalystUser, t2AnalystUser, hunterUser, + hunterNoActionsUser, ruleAuthorUser, socManagerUser, platformEngineerUser, @@ -19,6 +20,7 @@ import { t1AnalystRole, t2AnalystRole, hunterRole, + hunterNoActionsRole, ruleAuthorRole, socManagerRole, platformEngineerRole, @@ -53,6 +55,13 @@ export const createUserAndRole = async ( return postRoleAndUser(ROLES.t2_analyst, t2AnalystRole, t2AnalystUser, getService); case ROLES.hunter: return postRoleAndUser(ROLES.hunter, hunterRole, hunterUser, getService); + case ROLES.hunter_no_actions: + return postRoleAndUser( + ROLES.hunter_no_actions, + hunterNoActionsRole, + hunterNoActionsUser, + getService + ); case ROLES.rule_author: return postRoleAndUser(ROLES.rule_author, ruleAuthorRole, ruleAuthorUser, getService); case ROLES.soc_manager: @@ -105,7 +114,7 @@ interface RoleInterface { feature: { ml: string[]; siem: string[]; - actions: string[]; + actions?: string[]; builtInAlerts: string[]; }; spaces: string[]; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index ab509a7cba825..e26471b0316ed 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -28,6 +28,8 @@ import { getImportExceptionsListSchemaMock, } from '../../../../plugins/lists/common/schemas/request/import_exceptions_schema.mock'; import { deleteAllExceptions } from '../../../lists_api_integration/utils'; +import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution'; +import { ROLES } from '../../../../plugins/security_solution/common/test'; const getImportRuleBuffer = (connectorId: string) => { const rule1 = { @@ -95,8 +97,127 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); const esArchiver = getService('esArchiver'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('import_rules', () => { + describe('importing rules with different roles', () => { + before(async () => { + await createUserAndRole(getService, ROLES.hunter_no_actions); + await createUserAndRole(getService, ROLES.hunter); + }); + after(async () => { + await deleteUserAndRole(getService, ROLES.hunter_no_actions); + await deleteUserAndRole(getService, ROLES.hunter); + }); + beforeEach(async () => { + await createSignalsIndex(supertest, log); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest, log); + await deleteAllAlerts(supertest, log); + }); + it('should successfully import rules without actions when user has no actions privileges', async () => { + const { body } = await supertestWithoutAuth + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .auth(ROLES.hunter_no_actions, 'changeme') + .set('kbn-xsrf', 'true') + .attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson') + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 1, + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + }); + }); + it('should successfully import rules with actions when user has "read" actions privileges', async () => { + // create a new action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + const simpleRule: ReturnType = { + ...getSimpleRule('rule-1'), + actions: [ + { + group: 'default', + id: hookAction.id, + action_type_id: hookAction.actionTypeId, + params: {}, + }, + ], + }; + const { body } = await supertestWithoutAuth + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .auth(ROLES.hunter, 'changeme') + .set('kbn-xsrf', 'true') + .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .expect(200); + + expect(body).to.eql({ + errors: [], + success: true, + success_count: 1, + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + }); + }); + it('should not import rules with actions when a user has no actions privileges', async () => { + // create a new action + const { body: hookAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send(getWebHookAction()) + .expect(200); + const simpleRule: ReturnType = { + ...getSimpleRule('rule-1'), + actions: [ + { + group: 'default', + id: hookAction.id, + action_type_id: hookAction.actionTypeId, + params: {}, + }, + ], + }; + const { body } = await supertestWithoutAuth + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .auth(ROLES.hunter_no_actions, 'changeme') + .set('kbn-xsrf', 'true') + .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .expect(200); + expect(body).to.eql({ + success: false, + success_count: 0, + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + { + error: { + message: `1 connector is missing. Connector id missing is: ${hookAction.id}`, + status_code: 404, + }, + rule_id: 'rule-1', + }, + ], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + }); + }); + }); describe('importing rules with an index', () => { beforeEach(async () => { await createSignalsIndex(supertest, log); From e930e907b37d698a8cad761bc692117768c7c83b Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Mon, 21 Mar 2022 12:47:02 -0600 Subject: [PATCH 011/132] [Security Solution] [Investigations] [Tech Debt] `StatefulEventsViewer`: remove `deepEqual` checks and replace `connect` + `mapStateToProps` with `useSelector` (#128028) ## [Security Solution] [Investigations] [Tech Debt] `StatefulEventsViewer`: remove `deepEqual` checks and replace `connect` + `mapStateToProps` with `useSelector` This tech debt PR updates the `StatefulEventsViewer` component to: - Remove `deepEqual` checks, as detailed in - Replace the usage of legacy Redux APIs `connect` and `mapStateToProps` with `useSelector`, as detailed in ### Methodology Before making changes, the Alerts page, which uses the `StatefulEventsViewer` was profiled with the `Record why each component rendered wile profiling` setting in the React dev tools Profiler, shown in the screenshot below: ![record_why_each_component_rendered](https://user-images.githubusercontent.com/4459398/158903740-8122e2d3-11a6-4927-916a-f895717835ae.png) _Above: The `Record why each component rendered wile profiling` setting in the React dev tools Profiler_ After the page fully loaded, the profiler was started before clicking the `Refresh` button, and stopped after the page completed the refresh. With a baseline of performance established, the code was refactored to: - Remove the custom equality check passed to `React.memo`, and all the calls to `deepEqual` it contained - Remove `mapStateToProps`, which contained multiple invocations of selectors created by [reselect](https://github.com/reduxjs/reselect)'s [createSelector](https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc-selectoroptions) API - Using [reselect](https://github.com/reduxjs/reselect)'s [createSelector](https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc-selectoroptions) API, combine those multiple selectors into a single selector: ```ts export const eventsViewerSelector = createSelector( globalFiltersQuerySelector(), getTimelineSelector(), globalQuerySelector(), globalQuery(), timelineQueryByIdSelector(), getTimelineByIdSelector(), (filters, input, query, globalQueries, timelineQuery, timeline) => ({ /** an array representing filters added to the search bar */ filters, /** an object containing the timerange set in the global date picker, and other page level state */ input, /** a serialized representation of the KQL / Lucence query in the search bar */ query, /** an array of objects with metadata and actions related to queries on the page */ globalQueries, /** an object with metadata and actions related to the table query */ timelineQuery, /** a specific timeline from the state's timelineById collection, or undefined */ timeline, }) ); ``` - Replace the usage of `connect` with a single invocation of `useSelector`, which is provided the new `eventsViewerSelector` selector: ```ts const { filters, input, // ... } = useSelector((state: State) => eventsViewerSelector(state, id)); ``` - A unit test was created for the new `eventsViewerSelector`, using mock Redux state from the Alerts page - After making the changes above, the Alerts page was profiled again to verify `StatefulEventsViewer` re-renders as expected, per the screenshot below: ![testing_methodology](https://user-images.githubusercontent.com/4459398/158907345-2ec974ec-c733-4eaa-8ad6-da53a9d6a21b.png) _Above: In the profiler, each `Why did this render?` entry for the `StatefulEventsViewer` was examined after the changes_ --- .../security_solution/cypress/tasks/alerts.ts | 4 +- .../common/components/events_viewer/index.tsx | 162 +++--------- .../events_viewer/selectors/index.test.ts | 27 ++ .../events_viewer/selectors/index.ts | 48 ++++ .../events_viewer/selectors/mock_state.ts | 247 ++++++++++++++++++ .../components/alerts_table/index.tsx | 25 +- 6 files changed, 379 insertions(+), 134 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/mock_state.ts diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index f73bdda551449..219f308b01e9c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -72,8 +72,8 @@ export const expandFirstAlert = () => { cy.get(EXPAND_ALERT_BTN) .first() - .pipe(($el) => $el.trigger('click')) - .should('exist'); + .should('exist') + .pipe(($el) => $el.trigger('click')); }; export const viewThreatIntelTab = () => cy.get(THREAT_INTEL_TAB).click(); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 836734ef1d4ca..0053ed13923d4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -6,20 +6,20 @@ */ import React, { useRef, useCallback, useMemo, useEffect } from 'react'; -import { connect, ConnectedProps, useDispatch } from 'react-redux'; -import deepEqual from 'fast-deep-equal'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import type { Filter } from '@kbn/es-query'; -import { inputsModel, inputsSelectors, State } from '../../store'; +import { inputsModel, State } from '../../store'; import { inputsActions } from '../../store/actions'; import { ControlColumnProps, RowRenderer, TimelineId } from '../../../../common/types/timeline'; import { APP_ID, APP_UI_ID } from '../../../../common/constants'; -import { timelineSelectors, timelineActions } from '../../../timelines/store/timeline'; -import type { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/timeline/model'; +import { timelineActions } from '../../../timelines/store/timeline'; +import type { SubsetTimelineModel } from '../../../timelines/store/timeline/model'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { InspectButtonContainer } from '../inspect'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +import { eventsViewerSelector } from './selectors'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererDataView } from '../../containers/sourcerer'; import type { EntityType } from '../../../../../timelines/common'; @@ -43,7 +43,7 @@ const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` width: 100%; `; -export interface OwnProps { +export interface Props { defaultCellActions?: TGridCellAction[]; defaultModel: SubsetTimelineModel; end: string; @@ -64,51 +64,52 @@ export interface OwnProps { unit?: (n: number) => string; } -type Props = OwnProps & PropsFromRedux; - /** * The stateful events viewer component is the highest level component that is utilized across the security_solution pages layer where * timeline is used BESIDES the flyout. The flyout makes use of the `EventsViewer` component which is a subcomponent here * NOTE: As of writting, it is not used in the Case_View component */ const StatefulEventsViewerComponent: React.FC = ({ - createTimeline, - columns, - defaultColumns, - dataProviders, defaultCellActions, - deletedEventIds, - deleteEventQuery, + defaultModel, end, entityType, - excludedRowRendererIds, - filters, - globalQuery, id, - isLive, - itemsPerPage, - itemsPerPageOptions, - kqlMode, leadingControlColumns, pageFilters, currentFilter, onRuleChange, - query, renderCellValue, rowRenderers, start, scopeId, - showCheckboxes, - sort, - timelineQuery, utilityBar, additionalFilters, - // If truthy, the graph viewer (Resolver) is showing - graphEventId, hasAlertsCrud = false, unit, }) => { const dispatch = useDispatch(); + const { + filters, + input, + query, + globalQueries, + timelineQuery, + timeline: { + columns, + dataProviders, + defaultColumns, + deletedEventIds, + excludedRowRendererIds, + graphEventId, // If truthy, the graph viewer (Resolver) is showing + itemsPerPage, + itemsPerPageOptions, + kqlMode, + showCheckboxes, + sort, + } = defaultModel, + } = useSelector((state: State) => eventsViewerSelector(state, id)); + const { timelines: timelinesUi, cases } = useKibana().services; const { browserFields, @@ -128,8 +129,8 @@ const StatefulEventsViewerComponent: React.FC = ({ const editorActionsRef = useRef(null); useEffect(() => { - if (createTimeline != null) { - createTimeline({ + dispatch( + timelineActions.createTimeline({ columns, dataViewId: selectedDataViewId, defaultColumns, @@ -139,10 +140,11 @@ const StatefulEventsViewerComponent: React.FC = ({ itemsPerPage, showCheckboxes, sort, - }); - } + }) + ); + return () => { - deleteEventQuery({ id, inputId: 'global' }); + dispatch(inputsActions.deleteOneQuery({ id, inputId: 'global' })); if (editorActionsRef.current) { // eslint-disable-next-line react-hooks/exhaustive-deps editorActionsRef.current.closeEditor(); @@ -172,9 +174,9 @@ const StatefulEventsViewerComponent: React.FC = ({ if (id === TimelineId.active) { refetchQuery([timelineQuery]); } else { - refetchQuery(globalQuery); + refetchQuery(globalQueries); } - }, [id, timelineQuery, globalQuery]); + }, [id, timelineQuery, globalQueries]); const bulkActions = useMemo(() => ({ onAlertStatusActionSuccess }), [onAlertStatusActionSuccess]); const fieldBrowserOptions = useFieldBrowserOptions({ @@ -185,6 +187,7 @@ const StatefulEventsViewerComponent: React.FC = ({ const casesPermissions = useGetUserCasesPermissions(); const CasesContext = cases.ui.getCasesContext(); + const isLive = input.policy.kind === 'interval'; return ( <> @@ -249,93 +252,4 @@ const StatefulEventsViewerComponent: React.FC = ({ ); }; -const makeMapStateToProps = () => { - const getInputsTimeline = inputsSelectors.getTimelineSelector(); - const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); - const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getTimeline = timelineSelectors.getTimelineByIdSelector(); - const getGlobalQueries = inputsSelectors.globalQuery(); - const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector(); - const mapStateToProps = (state: State, { id, defaultModel }: OwnProps) => { - const input: inputsModel.InputsRange = getInputsTimeline(state); - const timeline: TimelineModel = getTimeline(state, id) ?? defaultModel; - const { - columns, - defaultColumns, - dataProviders, - deletedEventIds, - excludedRowRendererIds, - graphEventId, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - sort, - showCheckboxes, - } = timeline; - - return { - columns, - defaultColumns, - dataProviders, - deletedEventIds, - excludedRowRendererIds, - filters: getGlobalFiltersQuerySelector(state), - id, - isLive: input.policy.kind === 'interval', - itemsPerPage, - itemsPerPageOptions, - kqlMode, - query: getGlobalQuerySelector(state), - sort, - showCheckboxes, - // Used to determine whether the footer should show (since it is hidden if the graph is showing.) - // `getTimeline` actually returns `TimelineModel | undefined` - graphEventId, - globalQuery: getGlobalQueries(state), - timelineQuery: getTimelineQuery(state, id), - }; - }; - return mapStateToProps; -}; - -const mapDispatchToProps = { - createTimeline: timelineActions.createTimeline, - deleteEventQuery: inputsActions.deleteOneQuery, -}; - -const connector = connect(makeMapStateToProps, mapDispatchToProps); - -type PropsFromRedux = ConnectedProps; - -export const StatefulEventsViewer = connector( - React.memo( - StatefulEventsViewerComponent, - // eslint-disable-next-line complexity - (prevProps, nextProps) => - prevProps.id === nextProps.id && - prevProps.scopeId === nextProps.scopeId && - deepEqual(prevProps.columns, nextProps.columns) && - deepEqual(prevProps.dataProviders, nextProps.dataProviders) && - prevProps.defaultCellActions === nextProps.defaultCellActions && - deepEqual(prevProps.excludedRowRendererIds, nextProps.excludedRowRendererIds) && - prevProps.deletedEventIds === nextProps.deletedEventIds && - prevProps.end === nextProps.end && - deepEqual(prevProps.filters, nextProps.filters) && - prevProps.isLive === nextProps.isLive && - prevProps.itemsPerPage === nextProps.itemsPerPage && - deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && - prevProps.kqlMode === nextProps.kqlMode && - prevProps.leadingControlColumns === nextProps.leadingControlColumns && - deepEqual(prevProps.query, nextProps.query) && - prevProps.renderCellValue === nextProps.renderCellValue && - prevProps.rowRenderers === nextProps.rowRenderers && - deepEqual(prevProps.sort, nextProps.sort) && - prevProps.start === nextProps.start && - deepEqual(prevProps.pageFilters, nextProps.pageFilters) && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.start === nextProps.start && - prevProps.utilityBar === nextProps.utilityBar && - prevProps.additionalFilters === nextProps.additionalFilters && - prevProps.graphEventId === nextProps.graphEventId - ) -); +export const StatefulEventsViewer = React.memo(StatefulEventsViewerComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.test.ts b/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.test.ts new file mode 100644 index 0000000000000..76efe26bd3e85 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.test.ts @@ -0,0 +1,27 @@ +/* + * 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 { mockState } from './mock_state'; + +import { eventsViewerSelector } from '.'; + +describe('selectors', () => { + describe('eventsViewerSelector', () => { + it('returns the expected results', () => { + const id = 'detections-page'; + + expect(eventsViewerSelector(mockState, id)).toEqual({ + filters: mockState.inputs.global.filters, + input: mockState.inputs.timeline, + query: mockState.inputs.global.query, + globalQueries: mockState.inputs.global.queries, + timelineQuery: mockState.inputs.timeline.queries[0], + timeline: mockState.timeline.timelineById[id], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.ts b/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.ts new file mode 100644 index 0000000000000..cd2af53c42d80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/index.ts @@ -0,0 +1,48 @@ +/* + * 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 { createSelector } from 'reselect'; + +import { + getTimelineSelector, + globalFiltersQuerySelector, + globalQuery, + globalQuerySelector, + timelineQueryByIdSelector, +} from '../../../store/inputs/selectors'; +import { getTimelineByIdSelector } from '../../../../timelines/store/timeline/selectors'; + +/** + * This selector is invoked with two arguments: + * @param state - the state of the store as defined by State in common/store/types.ts + * @param id - a timeline id e.g. `detections-page` + * + * Example: + * `useSelector((state: State) => eventsViewerSelector(state, id))` + */ +export const eventsViewerSelector = createSelector( + globalFiltersQuerySelector(), + getTimelineSelector(), + globalQuerySelector(), + globalQuery(), + timelineQueryByIdSelector(), + getTimelineByIdSelector(), + (filters, input, query, globalQueries, timelineQuery, timeline) => ({ + /** an array representing filters added to the search bar */ + filters, + /** an object containing the timerange set in the global date picker, and other page level state */ + input, + /** a serialized representation of the KQL / Lucence query in the search bar */ + query, + /** an array of objects with metadata and actions related to queries on the page */ + globalQueries, + /** an object with metadata and actions related to the table query */ + timelineQuery, + /** a specific timeline from the state's timelineById collection, or undefined */ + timeline, + }) +); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/mock_state.ts b/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/mock_state.ts new file mode 100644 index 0000000000000..4b51ae9f329a7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/selectors/mock_state.ts @@ -0,0 +1,247 @@ +/* + * 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 { set } from '@elastic/safer-lodash-set'; +import { pipe } from 'lodash/fp'; + +import { mockGlobalState } from '../../../mock'; + +const filters = [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'kibana.alert.severity', + params: { query: 'low' }, + }, + $state: { store: 'appState' }, + query: { match_phrase: { 'kibana.alert.severity': { query: 'low' } } }, + }, +]; + +const input = { + timerange: { + kind: 'relative', + fromStr: 'now/d', + toStr: 'now/d', + from: '2022-03-17T06:00:00.000Z', + to: '2022-03-18T05:59:59.999Z', + }, + queries: [ + { + id: 'timeline-1-eql', + inspect: { dsl: [], response: [] }, + isInspected: false, + loading: false, + selectedInspectIndex: 0, + }, + ], + policy: { kind: 'manual', duration: 300000 }, + linkTo: ['global'], + query: { query: '', language: 'kuery' }, + filters: [], + fullScreen: false, +}; + +const query = { query: 'host.ip: *', language: 'kuery' }; + +const globalQueries = [ + { + id: 'detections-page', + inspect: { + dsl: ['{\n "allow_no_indices": ...}'], + }, + isInspected: false, + loading: false, + selectedInspectIndex: 0, + }, + { + id: 'detections-alerts-count-3e92347c-7e0c-44dc-a13d-fbe71706a0f0', + inspect: { + dsl: ['{\n "index": [\n ...}'], + response: ['{\n "took": 3,\n ...}'], + }, + isInspected: false, + loading: false, + selectedInspectIndex: 0, + }, + { + id: 'detections-histogram-a9c910ff-bcc9-48f2-9224-fad55cd5fd31', + inspect: { + dsl: ['{\n "index": [\n ...}'], + response: ['{\n "took": 4,\n ...}'], + }, + isInspected: false, + loading: false, + selectedInspectIndex: 0, + }, +]; + +const timelineQueries = [ + { + id: 'detections-page', + inspect: { + dsl: ['{\n "allow_no_indices": ...}'], + }, + isInspected: false, + loading: false, + selectedInspectIndex: 0, + }, +]; + +const timeline = { + id: 'detections-page', + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 105, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 100, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 450, + }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'process.name' }, + { columnHeaderType: 'not-filtered', id: 'file.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + ], + defaultColumns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 105, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 100, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 450, + }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'process.name' }, + { columnHeaderType: 'not-filtered', id: 'file.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + ], + dataViewId: 'security-solution-default', + dateRange: { start: '2022-03-17T06:00:00.000Z', end: '2022-03-18T05:59:59.999Z' }, + deletedEventIds: [], + excludedRowRendererIds: [ + 'alerts', + 'auditd', + 'auditd_file', + 'library', + 'netflow', + 'plain', + 'registry', + 'suricata', + 'system', + 'system_dns', + 'system_endgame_process', + 'system_file', + 'system_fim', + 'system_security_event', + 'system_socket', + 'threat_match', + 'zeek', + ], + expandedDetail: {}, + filters: [], + kqlQuery: { filterQuery: null }, + indexNames: ['.alerts-security.alerts-default'], + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + loadingEventIds: [], + selectedEventIds: {}, + showCheckboxes: true, + sort: [{ columnId: '@timestamp', columnType: 'date', sortDirection: 'desc' }], + savedObjectId: null, + version: null, + footerText: 'alerts', + title: '', + initialized: true, + activeTab: 'query', + prevActiveTab: 'query', + dataProviders: [], + description: '', + eqlOptions: { + eventCategoryField: 'event.category', + tiebreakerField: '', + timestampField: '@timestamp', + query: '', + size: 100, + }, + eventType: 'all', + eventIdToNoteIds: {}, + highlightedDropAndProviderId: '', + historyIds: [], + isFavorite: false, + isLive: false, + isSaving: false, + kqlMode: 'filter', + timelineType: 'default', + templateTimelineId: null, + templateTimelineVersion: null, + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + show: false, + status: 'draft', + updated: 1647542283361, + documentType: '', + isLoading: false, + queryFields: [], + selectAll: false, +}; + +export const mockState = pipe( + (state) => set(state, 'inputs.global.filters', filters), + (state) => set(state, 'inputs.timeline', input), + (state) => set(state, 'inputs.global.query', query), + (state) => set(state, 'inputs.global.queries', globalQueries), + (state) => set(state, 'inputs.timeline.queries', timelineQueries), + (state) => set(state, 'timeline.timelineById.detections-page', timeline) +)(mockGlobalState); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index c52d7a55d8449..c82c0c11237ee 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -306,14 +306,23 @@ export const AlertsTableComponent: React.FC = ({ ] ); - const additionalFiltersComponent = ( - 0} - onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} - showBuildingBlockAlerts={showBuildingBlockAlerts} - onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsChanged} - showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} - /> + const additionalFiltersComponent = useMemo( + () => ( + 0} + onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} + showBuildingBlockAlerts={showBuildingBlockAlerts} + onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsChanged} + showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} + /> + ), + [ + loadingEventIds.length, + onShowBuildingBlockAlertsChanged, + onShowOnlyThreatIndicatorAlertsChanged, + showBuildingBlockAlerts, + showOnlyThreatIndicatorAlerts, + ] ); const defaultFiltersMemo = useMemo(() => { From bdb20ede445c5dde7892c1090732adf18b34704d Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 21 Mar 2022 14:14:36 -0500 Subject: [PATCH 012/132] [App Search] Remove elasticsearch index card (#128176) --- .../document_creation_buttons.test.tsx | 13 +------ .../document_creation_buttons.tsx | 34 +------------------ 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx index 8fa0f86896c30..3671f69356de7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.test.tsx @@ -34,7 +34,7 @@ describe('DocumentCreationButtons', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiCard)).toHaveLength(3); + expect(wrapper.find(EuiCard)).toHaveLength(2); expect(wrapper.find(EuiCardTo)).toHaveLength(1); }); @@ -59,9 +59,6 @@ describe('DocumentCreationButtons', () => { wrapper.find(EuiCard).at(1).simulate('click'); expect(actions.openDocumentCreation).toHaveBeenCalledWith('api'); - - wrapper.find(EuiCard).at(2).simulate('click'); - expect(actions.openDocumentCreation).toHaveBeenCalledWith('elasticsearchIndex'); }); it('renders the crawler button with a link to the crawler page', () => { @@ -85,12 +82,4 @@ describe('DocumentCreationButtons', () => { shallow(); expect(actions.openDocumentCreation).toHaveBeenCalledWith('api'); }); - - it('calls openDocumentCreation("elasticsearchIndex") if ?method=elasticsearchIndex', () => { - const search = '?method=elasticsearchIndex'; - (useLocation as jest.Mock).mockImplementationOnce(() => ({ search })); - - shallow(); - expect(actions.openDocumentCreation).toHaveBeenCalledWith('elasticsearchIndex'); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index 03f4c62a959cc..838c47e62cc5d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -57,9 +57,6 @@ export const DocumentCreationButtons: React.FC = ({ case 'api': openDocumentCreation('api'); break; - case 'elasticsearchIndex': - openDocumentCreation('elasticsearchIndex'); - break; } }, []); @@ -69,7 +66,7 @@ export const DocumentCreationButtons: React.FC = ({

{i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.helperText', { defaultMessage: - 'There are four ways to send documents to your engine for indexing. You can paste or upload a JSON file, POST to the documents API endpoint, connect to an existing Elasticsearch index (beta), or use the Elastic Web Crawler to automatically index documents from a URL.', + 'There are three ways to send documents to your engine for indexing. You can paste or upload a JSON file, POST to the documents API endpoint, or use the Elastic Web Crawler to automatically index documents from a URL.', })}

); @@ -183,35 +180,6 @@ export const DocumentCreationButtons: React.FC = ({ onClick={() => openDocumentCreation('api')} isDisabled={disabled} /> - - } - onClick={() => openDocumentCreation('elasticsearchIndex')} - isDisabled={disabled} - /> {!isFlyout && emptyState} From bd76573764d9868f0e53567bc069f1f73e918cf9 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 21 Mar 2022 20:17:09 +0100 Subject: [PATCH 013/132] [Data View Editor] Replace EmptyIndexPattern component (#127941) * [Data View Editor] Replace EmptyIndexPattern component * Fix i18n & functional tests * Remove lazy loading * Fix settings page * Position component centrally * Applying Clint's comments * Fix flyout scroll * Remove unnecessary flex wrappers Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: cchaos --- .../src/empty_state/index.tsx | 2 +- .../src/empty_state/no_data_views/index.tsx | 1 + .../kbn-shared-ux-components/src/index.ts | 17 + .../empty_index_pattern_prompt.test.tsx.snap | 105 ---- .../assets/index_pattern_illustration.scss | 9 - .../assets/index_pattern_illustration.tsx | 541 ------------------ .../empty_index_pattern_prompt.scss | 31 - .../empty_index_pattern_prompt.test.tsx | 25 - .../empty_index_pattern_prompt.tsx | 102 ---- .../empty_index_pattern_prompt/index.tsx | 9 - .../empty_prompts/empty_prompts.tsx | 34 +- test/functional/page_objects/settings_page.ts | 8 +- .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - .../data_views/feature_controls/security.ts | 4 +- 16 files changed, 45 insertions(+), 858 deletions(-) delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.scss delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.tsx delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.scss delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.test.tsx delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.tsx delete mode 100644 src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/index.tsx diff --git a/packages/kbn-shared-ux-components/src/empty_state/index.tsx b/packages/kbn-shared-ux-components/src/empty_state/index.tsx index fc05199aae207..902a9cd3614d5 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/index.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/index.tsx @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { NoDataViews } from './no_data_views'; +export { NoDataViews, NoDataViewsComponent } from './no_data_views'; diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx index fc05199aae207..6719fffa36740 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/index.tsx @@ -7,3 +7,4 @@ */ export { NoDataViews } from './no_data_views'; +export { NoDataViews as NoDataViewsComponent } from './no_data_views.component'; diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index ed95c8dc5d167..a43b53a6e7cd1 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -58,6 +58,23 @@ export const LazyNoDataViews = React.lazy(() => */ export const NoDataViews = withSuspense(LazyNoDataViews); +/** + * A pure `NoDataViews` component, with no services hooks. Consumers should use `React.Suspennse` or the + * `withSuspense` HOC to load this component. + */ +export const LazyNoDataViewsComponent = React.lazy(() => + import('./empty_state/no_data_views').then(({ NoDataViewsComponent }) => ({ + default: NoDataViewsComponent, + })) +); + +/** + * A pure `NoDataViews` component, with no services hooks. The component is wrapped by the `withSuspense` HOC. + * This component can be used directly by consumers and will load the `LazyNoDataViewsComponent` lazily with + * a predefined fallback and error boundary. + */ +export const NoDataViewsComponent = withSuspense(LazyNoDataViewsComponent); + /** * The Lazily-loaded `IconButtonGroup` component. Consumers should use `React.Suspennse` or the * `withSuspense` HOC to load this component. diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap deleted file mode 100644 index 877ca2434395f..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/__snapshots__/empty_index_pattern_prompt.test.tsx.snap +++ /dev/null @@ -1,105 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmptyIndexPatternPrompt should render normally 1`] = ` - - - - - } - > - - - - - -

- -
- -

-

- -

- - - -
-
-
- - - - - - - - - - - -
-`; diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.scss b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.scss deleted file mode 100644 index 8133b0dd487d6..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.scss +++ /dev/null @@ -1,9 +0,0 @@ -.indexPatternIllustration { - &__verticalStripes { - fill: $euiColorFullShade; - } - - &__dots { - fill: $euiColorLightShade; - } -} \ No newline at end of file diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.tsx deleted file mode 100644 index 09b18e25e9d00..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/assets/index_pattern_illustration.tsx +++ /dev/null @@ -1,541 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './index_pattern_illustration.scss'; -import React from 'react'; - -const IndexPatternIllustration = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -/* eslint-disable import/no-default-export */ -export default IndexPatternIllustration; diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.scss b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.scss deleted file mode 100644 index f6db2fc89f353..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.scss +++ /dev/null @@ -1,31 +0,0 @@ -@import '../../variables'; -@import '../../templates'; - -.inpEmptyIndexPatternPrompt { - // override EUI specificity - max-width: $inpEmptyStateMaxWidth !important; // sass-lint:disable-line no-important -} - -.inpEmptyIndexPatternPrompt__footer { - @extend %inp-empty-state-footer; - // override EUI specificity - align-items: baseline !important; // sass-lint:disable-line no-important -} - -.inpEmptyIndexPatternPrompt__title { - // override EUI specificity - width: auto !important; // sass-lint:disable-line no-important -} - -@include euiBreakpoint('xs', 's') { - .inpEmptyIndexPatternPrompt__illustration > svg { - width: $euiSize * 12; - height: auto; - margin: 0 auto; - } - - .inpEmptyIndexPatternPrompt__text { - text-align: center; - align-items: center; - } -} diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.test.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.test.tsx deleted file mode 100644 index 242f124b326b8..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.test.tsx +++ /dev/null @@ -1,25 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EmptyIndexPatternPrompt } from '../empty_index_pattern_prompt'; -import { shallowWithI18nProvider } from '@kbn/test-jest-helpers'; - -describe('EmptyIndexPatternPrompt', () => { - it('should render normally', () => { - const component = shallowWithI18nProvider( - {}} - canSaveIndexPattern={true} - indexPatternsIntroUrl={'http://elastic.co/'} - /> - ); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.tsx deleted file mode 100644 index e99ef24858ca7..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/empty_index_pattern_prompt.tsx +++ /dev/null @@ -1,102 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import './empty_index_pattern_prompt.scss'; - -import React, { lazy, Suspense } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { EuiPageContent, EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import { EuiDescriptionListTitle } from '@elastic/eui'; -import { EuiDescriptionListDescription, EuiDescriptionList } from '@elastic/eui'; -import { EuiLink, EuiButton, EuiLoadingSpinner } from '@elastic/eui'; -interface Props { - goToCreate: () => void; - canSaveIndexPattern: boolean; - indexPatternsIntroUrl: string; -} - -const Illustration = lazy(() => import('./assets/index_pattern_illustration')); - -export const EmptyIndexPatternPrompt = ({ - goToCreate, - canSaveIndexPattern, - indexPatternsIntroUrl, -}: Props) => { - return ( - - - - }> - - - - - -

- -
- -

-

- -

- {canSaveIndexPattern && ( - - - - )} -
-
-
- - - - - - - - - - - -
- ); -}; diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/index.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/index.tsx deleted file mode 100644 index 0a58c6f86a8d3..0000000000000 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_index_pattern_prompt/index.tsx +++ /dev/null @@ -1,9 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export { EmptyIndexPatternPrompt } from './empty_index_pattern_prompt'; diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index 0560c66ab1f9b..ec5ba142df0d4 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -9,6 +9,8 @@ import React, { useState, FC, useEffect } from 'react'; import useAsync from 'react-use/lib/useAsync'; +import { NoDataViewsComponent } from '@kbn/shared-ux-components'; +import { EuiFlyoutBody } from '@elastic/eui'; import { useKibana } from '../../shared_imports'; import { MatchedItem, DataViewEditorContext } from '../../types'; @@ -16,7 +18,6 @@ import { MatchedItem, DataViewEditorContext } from '../../types'; import { getIndices } from '../../lib'; import { EmptyIndexListPrompt } from './empty_index_list_prompt'; -import { EmptyIndexPatternPrompt } from './empty_index_pattern_prompt'; import { PromptFooter } from './prompt_footer'; import { DEFAULT_ASSETS_TO_IGNORE } from '../../../../data/common'; @@ -79,14 +80,16 @@ export const EmptyPrompts: FC = ({ allSources, onCancel, children, loadSo // load data return ( <> - setGoToForm(true)} - canSaveIndexPattern={application.capabilities.indexPatterns.save as boolean} - navigateToApp={application.navigateToApp} - addDataUrl={docLinks.links.indexPatterns.introduction} - /> + + setGoToForm(true)} + canSaveIndexPattern={!!application.capabilities.indexPatterns.save} + navigateToApp={application.navigateToApp} + addDataUrl={docLinks.links.indexPatterns.introduction} + /> + ); @@ -94,11 +97,14 @@ export const EmptyPrompts: FC = ({ allSources, onCancel, children, loadSo // first time return ( <> - setGoToForm(true)} - indexPatternsIntroUrl={docLinks.links.indexPatterns.introduction} - canSaveIndexPattern={dataViews.getCanSaveSync()} - /> + + setGoToForm(true)} + canCreateNewDataView={application.capabilities.indexPatterns.save as boolean} + dataViewsDocLink={docLinks.links.indexPatterns.introduction} + emptyPromptColor={'subdued'} + /> + ); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 26701359f6ea3..dfaaecff0a0c7 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -453,9 +453,9 @@ export class SettingsPageObject extends FtrService { await this.common.scrollKibanaBodyTop(); // if flyout is open - const flyoutView = await this.testSubjects.exists('createIndexPatternButtonFlyout'); + const flyoutView = await this.testSubjects.exists('createDataViewButtonFlyout'); if (flyoutView) { - await this.testSubjects.click('createIndexPatternButtonFlyout'); + await this.testSubjects.click('createDataViewButtonFlyout'); return; } @@ -463,9 +463,9 @@ export class SettingsPageObject extends FtrService { if (tableView) { await this.testSubjects.click('createIndexPatternButton'); } - const flyoutView2 = await this.testSubjects.exists('createIndexPatternButtonFlyout'); + const flyoutView2 = await this.testSubjects.exists('createDataViewButtonFlyout'); if (flyoutView2) { - await this.testSubjects.click('createIndexPatternButtonFlyout'); + await this.testSubjects.click('createDataViewButtonFlyout'); } } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 31521ca81e037..b4832c4cd24c2 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3642,9 +3642,6 @@ "indexPatternEditor.editor.form.titleLabel": "Nom", "indexPatternEditor.editor.form.TypeLabel": "Type de modèle d'indexation", "indexPatternEditor.editor.form.typeSelectAriaLabel": "Champ Type", - "indexPatternEditor.emptyIndexPatternPrompt.documentation": "Lire la documentation", - "indexPatternEditor.emptyIndexPatternPrompt.learnMore": "Envie d'en savoir plus ?", - "indexPatternEditor.emptyIndexPatternPrompt.youHaveData": "Vous avez des données dans Elasticsearch.", "indexPatternEditor.form.allowHiddenAriaLabel": "Autoriser les index masqués et système", "indexPatternEditor.form.customIndexPatternIdLabel": "ID de modèle d'indexation personnalisé", "indexPatternEditor.form.titleAriaLabel": "Champ de titre", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d64b50fde976c..846ef5ef2ad28 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4205,7 +4205,6 @@ "indexPatternEditor.dataStreamLabel": "データストリーム", "indexPatternEditor.dataView.unableSaveLabel": "データビューの保存に失敗しました。", "indexPatternEditor.dataViewExists.ValidationErrorMessage": "このタイトルのデータビューはすでに存在します。", - "indexPatternEditor.dataViewTable.createBtn": "データビューを作成", "indexPatternEditor.editor.emptyPrompt.flyoutCloseButtonLabel": "閉じる", "indexPatternEditor.editor.flyoutCloseButtonLabel": "閉じる", "indexPatternEditor.editor.flyoutSaveButtonLabel": "データビューを作成", @@ -4223,11 +4222,6 @@ "indexPatternEditor.editor.form.titleLabel": "名前", "indexPatternEditor.editor.form.TypeLabel": "データビュータイプ", "indexPatternEditor.editor.form.typeSelectAriaLabel": "タイプフィールド", - "indexPatternEditor.emptyDataViewPrompt.indexPatternExplanation": "Kibanaでは、探索するデータストリーム、インデックス、インデックスエイリアスを特定するためにデータビューが必要です。データビューは、昨日のログデータなど特定のインデックス、またはログデータを含むすべてのインデックスを参照できます。", - "indexPatternEditor.emptyDataViewPrompt.nowCreate": "ここでデータビューを作成します。", - "indexPatternEditor.emptyIndexPatternPrompt.documentation": "ドキュメンテーションを表示", - "indexPatternEditor.emptyIndexPatternPrompt.learnMore": "詳細について", - "indexPatternEditor.emptyIndexPatternPrompt.youHaveData": "Elasticsearchにデータがあります。", "indexPatternEditor.form.allowHiddenAriaLabel": "非表示のインデックスとシステムインデックスを許可", "indexPatternEditor.form.customIndexPatternIdLabel": "カスタムデータビューID", "indexPatternEditor.form.titleAriaLabel": "タイトルフィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index fda2023a170f5..3a9065a878085 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4214,7 +4214,6 @@ "indexPatternEditor.dataStreamLabel": "数据流", "indexPatternEditor.dataView.unableSaveLabel": "无法保存数据视图。", "indexPatternEditor.dataViewExists.ValidationErrorMessage": "具有此名称的数据视图已存在。", - "indexPatternEditor.dataViewTable.createBtn": "创建数据视图", "indexPatternEditor.editor.emptyPrompt.flyoutCloseButtonLabel": "关闭", "indexPatternEditor.editor.flyoutCloseButtonLabel": "关闭", "indexPatternEditor.editor.flyoutSaveButtonLabel": "创建数据视图", @@ -4232,11 +4231,6 @@ "indexPatternEditor.editor.form.titleLabel": "名称", "indexPatternEditor.editor.form.TypeLabel": "数据视图类型", "indexPatternEditor.editor.form.typeSelectAriaLabel": "类型字段", - "indexPatternEditor.emptyDataViewPrompt.indexPatternExplanation": "Kibana 需要数据视图来识别您要浏览的数据流、索引和索引别名。数据视图可以指向特定索引(例如昨天的日志数据),或包含日志数据的所有索引。", - "indexPatternEditor.emptyDataViewPrompt.nowCreate": "现在,创建数据视图。", - "indexPatternEditor.emptyIndexPatternPrompt.documentation": "阅读文档", - "indexPatternEditor.emptyIndexPatternPrompt.learnMore": "希望了解详情?", - "indexPatternEditor.emptyIndexPatternPrompt.youHaveData": "您在 Elasticsearch 中有数据。", "indexPatternEditor.form.allowHiddenAriaLabel": "允许使用隐藏索引和系统索引", "indexPatternEditor.form.customIndexPatternIdLabel": "定制数据视图 ID", "indexPatternEditor.form.titleAriaLabel": "标题字段", diff --git a/x-pack/test/functional/apps/data_views/feature_controls/security.ts b/x-pack/test/functional/apps/data_views/feature_controls/security.ts index 96682302b5713..bef2cbc5e900c 100644 --- a/x-pack/test/functional/apps/data_views/feature_controls/security.ts +++ b/x-pack/test/functional/apps/data_views/feature_controls/security.ts @@ -133,8 +133,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it(`index pattern listing doesn't show create button`, async () => { await PageObjects.settings.clickKibanaIndexPatterns(); - await testSubjects.existOrFail('emptyIndexPatternPrompt'); - await testSubjects.missingOrFail('createIndexPatternButtonFlyout'); + await testSubjects.existOrFail('noDataViewsPrompt'); + await testSubjects.missingOrFail('createDataViewButtonFlyout'); }); it(`shows read-only badge`, async () => { From defba4b5c2a44f2ee5fabe2df62aa50b73b2dfc0 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 21 Mar 2022 12:17:36 -0700 Subject: [PATCH 014/132] [type-summarizer] summarize @kbn/alerts types with source-maps (#128191) --- packages/kbn-alerts/BUILD.bazel | 1 + packages/kbn-alerts/tsconfig.json | 1 + packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/kbn-alerts/BUILD.bazel b/packages/kbn-alerts/BUILD.bazel index e6ebdaa202701..8a81707e8fb03 100644 --- a/packages/kbn-alerts/BUILD.bazel +++ b/packages/kbn-alerts/BUILD.bazel @@ -73,6 +73,7 @@ ts_project( srcs = SRCS, deps = TYPES_DEPS, declaration = True, + declaration_map = True, emit_declaration_only = True, out_dir = "target_types", root_dir = "src", diff --git a/packages/kbn-alerts/tsconfig.json b/packages/kbn-alerts/tsconfig.json index dfe59c663d3e1..56db519b1a4c1 100644 --- a/packages/kbn-alerts/tsconfig.json +++ b/packages/kbn-alerts/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.bazel.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "emitDeclarationOnly": true, "outDir": "target_types", "rootDir": "src", diff --git a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts index 71daec6642fb1..160e33174d9f7 100644 --- a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts +++ b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts @@ -18,6 +18,7 @@ const TYPE_SUMMARIZER_PACKAGES = [ '@kbn/generate', '@kbn/mapbox-gl', '@kbn/ace', + '@kbn/alerts', ]; type TypeSummarizerType = 'api-extractor' | 'type-summarizer'; From a4df273e606123f0232593abdf6b7069a09837a3 Mon Sep 17 00:00:00 2001 From: Brandon Morelli Date: Mon, 21 Mar 2022 13:35:48 -0600 Subject: [PATCH 015/132] [APM] Add service overview tooltips (#127937) --- .../index.tsx | 34 ++++++++-- .../service_overview_throughput_chart.tsx | 2 +- .../app/trace_overview/trace_list.tsx | 2 +- .../failed_transaction_rate_chart/index.tsx | 30 +++++++-- .../transaction_breakdown_chart/index.tsx | 41 +++++++++--- .../shared/dependencies_table/index.tsx | 56 ++++++++++++++-- .../shared/transactions_table/get_columns.tsx | 66 +++++++++++++++++-- 7 files changed, 193 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 335578cd88e10..ba5674e558e44 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiIcon, EuiToolTip } from '@elastic/eui'; import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import React, { ReactNode } from 'react'; @@ -134,12 +135,33 @@ export function ServiceOverviewDependenciesTable({ dependencies={dependencies} fixedHeight={fixedHeight} isSingleColumn={isSingleColumn} - title={i18n.translate( - 'xpack.apm.serviceOverview.dependenciesTableTitle', - { - defaultMessage: 'Dependencies', - } - )} + title={ + + <> + {i18n.translate( + 'xpack.apm.serviceOverview.dependenciesTableTitle', + { + defaultMessage: 'Dependencies', + } + )} +   + + + + } nameColumnTitle={i18n.translate( 'xpack.apm.serviceOverview.dependenciesTableColumn', { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index a0a8f7babe640..f7d0e618025f5 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -152,7 +152,7 @@ export function ServiceOverviewThroughputChart({ diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx index 5646a3c47231d..a3e48ee00e0d8 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/trace_list.tsx @@ -121,7 +121,7 @@ export function getTraceListColumns({ 'xpack.apm.tracesTable.impactColumnDescription', { defaultMessage: - 'The most used and slowest endpoints in your service. It is the result of multiplying latency and throughput', + 'The most used and slowest endpoints in your service. Calculated by multiplying latency by throughput.', } )} > diff --git a/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx index a6647f2691edd..77e30c2c67731 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx @@ -8,6 +8,7 @@ import { EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; import { asPercent } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; @@ -148,13 +149,28 @@ export function FailedTransactionRateChart({ return ( - -

- {i18n.translate('xpack.apm.errorRate', { - defaultMessage: 'Failed transaction rate', - })} -

-
+ + + +

+ {i18n.translate('xpack.apm.errorRate', { + defaultMessage: 'Failed transaction rate', + })} +

+
+
+ + + + +
+ - - -

- {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { - defaultMessage: 'Time spent by span type', - })} -

-
-
+ + + +

+ {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { + defaultMessage: 'Time spent by span type', + })} +

+
+
+ + + + +
+ <> + {i18n.translate('xpack.apm.dependenciesTable.columnErrorRate', { + defaultMessage: 'Failed transaction rate', + })} +   + + + + ), align: RIGHT_ALIGNMENT, render: (_, { currentStats, previousStats }) => { const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( @@ -156,9 +179,30 @@ export function DependenciesTable(props: Props) { }, { field: 'impactValue', - name: i18n.translate('xpack.apm.dependenciesTable.columnImpact', { - defaultMessage: 'Impact', - }), + name: ( + + <> + {i18n.translate('xpack.apm.dependenciesTable.columnImpact', { + defaultMessage: 'Impact', + })} +   + + + + ), align: RIGHT_ALIGNMENT, render: (_, { currentStats, previousStats }) => { return ( diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx index 82db4760b3b7c..ecfe277247d4c 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx @@ -5,7 +5,13 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, RIGHT_ALIGNMENT } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiToolTip, + RIGHT_ALIGNMENT, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { ValuesType } from 'utility-types'; @@ -147,9 +153,32 @@ export function getColumns({ { field: 'errorRate', sortable: true, - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', - { defaultMessage: 'Failed transaction rate' } + name: ( + + <> + {i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', + { + defaultMessage: 'Failed transaction rate', + } + )} +   + + + ), align: RIGHT_ALIGNMENT, render: (_, { errorRate, name }) => { @@ -180,9 +209,32 @@ export function getColumns({ { field: 'impact', sortable: true, - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnImpact', - { defaultMessage: 'Impact' } + name: ( + + <> + {i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnImpact', + { + defaultMessage: 'Impact', + } + )} +   + + + ), align: RIGHT_ALIGNMENT, render: (_, { name }) => { From f820e92ffe6e3a40a6b13d3e3e5f3110a91be7f7 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 21 Mar 2022 15:48:02 -0500 Subject: [PATCH 016/132] [App Search] Add a new Audit log toggle to App Search settings (#128187) * Add new server validation * Add types and constants and util for Audit * Update modal and panel Also fixes a test with the logic file * Fix i18n duplicate ID --- .../components/log_retention_callout.tsx | 5 ++ .../log_retention/log_retention_logic.test.ts | 20 ++++++++ .../log_retention/messaging/constants.tsx | 10 ++++ .../components/log_retention/types.ts | 3 ++ .../utils/convert_log_retention.test.ts | 20 ++++++++ .../utils/convert_log_retention.ts | 3 ++ .../log_retention_confirmation_modal.tsx | 46 ++++++++++++++++++- .../log_retention/log_retention_panel.tsx | 28 +++++++++++ .../server/routes/app_search/settings.ts | 5 ++ 9 files changed, 139 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx index 2f7d02d0912df..6c0f6bd3dd31f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/components/log_retention_callout.tsx @@ -23,9 +23,14 @@ import { CRAWLER_TITLE } from '../../crawler'; import { LogRetentionLogic, LogRetentionOptions, renderLogRetentionDate } from '../index'; +export const AUDIT_LOGS_TITLE = i18n.translate('xpack.enterpriseSearch.appSearch.audit.title', { + defaultMessage: 'Audit', +}); + const TITLE_MAP = { [LogRetentionOptions.Analytics]: ANALYTICS_TITLE, [LogRetentionOptions.API]: API_LOGS_TITLE, + [LogRetentionOptions.Audit]: AUDIT_LOGS_TITLE, [LogRetentionOptions.Crawler]: CRAWLER_TITLE, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts index c254a978f8789..0a41ae4e87266 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts @@ -33,6 +33,11 @@ describe('LogRetentionLogic', () => { enabled: true, retention_policy: { is_default: true, min_age_days: 180 }, }, + audit: { + disabled_at: null, + enabled: true, + retention_policy: { is_default: true, min_age_days: 180 }, + }, crawler: { disabled_at: null, enabled: true, @@ -51,6 +56,11 @@ describe('LogRetentionLogic', () => { enabled: true, retentionPolicy: { isDefault: true, minAgeDays: 180 }, }, + audit: { + disabledAt: null, + enabled: true, + retentionPolicy: { isDefault: true, minAgeDays: 180 }, + }, crawler: { disabledAt: null, enabled: true, @@ -156,6 +166,11 @@ describe('LogRetentionLogic', () => { enabled: true, retentionPolicy: null, }, + audit: { + disabledAt: null, + enabled: true, + retentionPolicy: null, + }, crawler: { disabledAt: null, enabled: true, @@ -176,6 +191,11 @@ describe('LogRetentionLogic', () => { enabled: true, retentionPolicy: null, }, + audit: { + disabledAt: null, + enabled: true, + retentionPolicy: null, + }, crawler: { disabledAt: null, enabled: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/messaging/constants.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/messaging/constants.tsx index 6ea9c361cd25e..deef693166711 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/messaging/constants.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/messaging/constants.tsx @@ -37,6 +37,16 @@ const CAPITALIZATION_MAP = { { defaultMessage: 'API' } ), }, + [LogRetentionOptions.Audit]: { + capitalized: i18n.translate( + 'xpack.enterpriseSearch.appSearch.logRetention.type.audit.title.capitalized', + { defaultMessage: 'Audit' } + ), + lowercase: i18n.translate( + 'xpack.enterpriseSearch.appSearch.logRetention.type.audit.title.lowercase', + { defaultMessage: 'audit' } + ), + }, [LogRetentionOptions.Crawler]: { capitalized: i18n.translate( 'xpack.enterpriseSearch.appSearch.logRetention.type.crawler.title.capitalized', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/types.ts index a2c615aee3a0e..79d555b99a803 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/types.ts @@ -8,12 +8,14 @@ export enum LogRetentionOptions { Analytics = 'analytics', API = 'api', + Audit = 'audit', Crawler = 'crawler', } export interface LogRetention { [LogRetentionOptions.Analytics]: LogRetentionSettings; [LogRetentionOptions.API]: LogRetentionSettings; + [LogRetentionOptions.Audit]: LogRetentionSettings; [LogRetentionOptions.Crawler]: LogRetentionSettings; } @@ -31,6 +33,7 @@ export interface LogRetentionSettings { export interface LogRetentionServer { [LogRetentionOptions.Analytics]: LogRetentionServerSettings; [LogRetentionOptions.API]: LogRetentionServerSettings; + [LogRetentionOptions.Audit]: LogRetentionServerSettings; [LogRetentionOptions.Crawler]: LogRetentionServerSettings; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.test.ts index b6a5997209247..a99c3d6888d3f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.test.ts @@ -21,6 +21,11 @@ describe('convertLogRetentionFromServerToClient', () => { enabled: true, retention_policy: { is_default: true, min_age_days: 180 }, }, + audit: { + disabled_at: null, + enabled: true, + retention_policy: { is_default: true, min_age_days: 180 }, + }, crawler: { disabled_at: null, enabled: true, @@ -38,6 +43,11 @@ describe('convertLogRetentionFromServerToClient', () => { enabled: true, retentionPolicy: { isDefault: true, minAgeDays: 180 }, }, + audit: { + disabledAt: null, + enabled: true, + retentionPolicy: { isDefault: true, minAgeDays: 180 }, + }, crawler: { disabledAt: null, enabled: true, @@ -59,6 +69,11 @@ describe('convertLogRetentionFromServerToClient', () => { enabled: true, retention_policy: { is_default: true, min_age_days: null }, }, + audit: { + disabled_at: null, + enabled: true, + retention_policy: { is_default: true, min_age_days: null }, + }, crawler: { disabled_at: null, enabled: true, @@ -76,6 +91,11 @@ describe('convertLogRetentionFromServerToClient', () => { enabled: true, retentionPolicy: { isDefault: true, minAgeDays: null }, }, + audit: { + disabledAt: null, + enabled: true, + retentionPolicy: { isDefault: true, minAgeDays: null }, + }, crawler: { disabledAt: null, enabled: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.ts index fd2452d05f530..50af7ea54e2e2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/utils/convert_log_retention.ts @@ -24,6 +24,9 @@ export const convertLogRetentionFromServerToClient = ( [LogRetentionOptions.API]: convertLogRetentionSettingsFromServerToClient( logRetention[LogRetentionOptions.API] ), + [LogRetentionOptions.Audit]: convertLogRetentionSettingsFromServerToClient( + logRetention[LogRetentionOptions.Audit] + ), [LogRetentionOptions.Crawler]: convertLogRetentionSettingsFromServerToClient( logRetention[LogRetentionOptions.Crawler] ), diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx index 694d41f11bbff..53a80f6703ef3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx @@ -133,7 +133,51 @@ export const LogRetentionConfirmationModal: React.FC = () => { onSave={() => saveLogRetention(LogRetentionOptions.API, false)} /> )} - + {openedModal === LogRetentionOptions.Audit && ( + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.settings.logRetention.modal.audit.description', + { + defaultMessage: + 'When you disable writing, engines stop logging audit events. Your existing data is deleted according to the storage time frame.', + } + )} +

+

+ + {CANNOT_BE_RECOVERED_TEXT} + +

+ + } + target={DISABLE_TEXT} + onClose={closeModals} + onSave={() => saveLogRetention(LogRetentionOptions.Audit, false)} + /> + )} {openedModal === LogRetentionOptions.Crawler && ( { const hasILM = logRetention !== null; const analyticsLogRetentionSettings = logRetention?.[LogRetentionOptions.Analytics]; const apiLogRetentionSettings = logRetention?.[LogRetentionOptions.API]; + const auditLogRetentionSettings = logRetention?.[LogRetentionOptions.Audit]; const crawlerLogRetentionSettings = logRetention?.[LogRetentionOptions.Crawler]; useEffect(() => { @@ -128,6 +129,33 @@ export const LogRetentionPanel: React.FC = () => { data-test-subj="LogRetentionPanelCrawlerSwitch" /> + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.settings.logRetention.audit.label', + { + defaultMessage: 'Log audit events', + } + )} + + {': '} + {hasILM && ( + + + + )} + + } + checked={!!auditLogRetentionSettings?.enabled} + onChange={() => toggleLogRetention(LogRetentionOptions.Audit)} + disabled={isLogRetentionUpdating} + data-test-subj="LogRetentionPanelAuditSwitch" + /> +

diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.ts index 2d0ce1411761a..e77002c6c3ed3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/settings.ts @@ -38,6 +38,11 @@ export function registerSettingsRoutes({ enabled: schema.boolean(), }) ), + audit: schema.maybe( + schema.object({ + enabled: schema.boolean(), + }) + ), crawler: schema.maybe( schema.object({ enabled: schema.boolean(), From 66422f2e1414288e9a7118e115fb4753b7eccaac Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Mon, 21 Mar 2022 16:50:14 -0400 Subject: [PATCH 017/132] [RAM] Follow up on _snooze api (#128163) * apply Snooze write operation + make sure to get snooze to null when mute and/or unmute * fix unit test * fix snooze api integration API + adding test on snooze_end_time for mute_all/unmute_all so when we are bringing back this field back form teh public API we will have to update the test to what we expect --- .../server/rules_client/rules_client.ts | 4 +- .../rules_client/tests/mute_all.test.ts | 1 + .../rules_client/tests/unmute_all.test.ts | 1 + .../alerting.test.ts | 184 +++++++++--------- .../feature_privilege_builder/alerting.ts | 1 + .../tests/alerting/mute_all.ts | 4 + .../tests/alerting/snooze.ts | 14 +- .../tests/alerting/unmute_all.ts | 4 + .../spaces_only/tests/alerting/mute_all.ts | 3 +- .../spaces_only/tests/alerting/unmute_all.ts | 2 + 10 files changed, 119 insertions(+), 99 deletions(-) diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index e396b4fd94943..652afb1b92152 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -1595,7 +1595,7 @@ export class RulesClient { await this.authorization.ensureAuthorized({ ruleTypeId: attributes.alertTypeId, consumer: attributes.consumer, - operation: WriteOperations.MuteAll, + operation: WriteOperations.Snooze, entity: AlertingAuthorizationEntity.Rule, }); @@ -1693,6 +1693,7 @@ export class RulesClient { const updateAttributes = this.updateMeta({ muteAll: true, mutedInstanceIds: [], + snoozeEndTime: null, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }); @@ -1755,6 +1756,7 @@ export class RulesClient { const updateAttributes = this.updateMeta({ muteAll: false, mutedInstanceIds: [], + snoozeEndTime: null, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index 17df0d655c5cc..49bd8022cffe2 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -82,6 +82,7 @@ describe('muteAll()', () => { { muteAll: true, mutedInstanceIds: [], + snoozeEndTime: null, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index 794e86206b6d6..8ed04caba62aa 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -82,6 +82,7 @@ describe('unmuteAll()', () => { { muteAll: false, mutedInstanceIds: [], + snoozeEndTime: null, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', }, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index a3b4b76980cd3..62aa9aca6ef2d 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -209,24 +209,25 @@ describe(`feature_privilege_builder`, () => { }); expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` - Array [ - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", - ] - `); + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + ] + `); }); test('grants `all` privileges to alerts under feature consumer', () => { @@ -303,27 +304,28 @@ describe(`feature_privilege_builder`, () => { }); expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` - Array [ - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", - ] - `); + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", + ] + `); }); test('grants both `all` and `read` to rules privileges under feature consumer', () => { @@ -357,29 +359,30 @@ describe(`feature_privilege_builder`, () => { }); expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` - Array [ - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", - ] - `); + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", + ] + `); }); test('grants both `all` and `read` to alerts privileges under feature consumer', () => { @@ -458,34 +461,35 @@ describe(`feature_privilege_builder`, () => { }); expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` - Array [ - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/get", - "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/find", - "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/update", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/get", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/find", - ] - `); + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/find", + "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/update", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/find", + ] + `); }); }); }); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index 4d2cc97f75d89..13aa45d54f66e 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -32,6 +32,7 @@ const writeOperations: Record = { 'unmuteAll', 'muteAlert', 'unmuteAlert', + 'snooze', ], alert: ['update'], }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts index bb570e5754e99..1cac93cb52b78 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts @@ -99,6 +99,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -155,6 +156,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -222,6 +224,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -289,6 +292,7 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(true); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/snooze.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/snooze.ts index 406373a756da3..929b95535e195 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/snooze.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/snooze.ts @@ -77,7 +77,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getConsumerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.noop', 'alertsFixture' ), @@ -145,7 +145,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getConsumerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.restricted-noop', 'alertsRestrictedFixture' ), @@ -202,7 +202,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getConsumerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.unrestricted-noop', 'alertsFixture' ), @@ -215,7 +215,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getProducerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.unrestricted-noop', 'alertsRestrictedFixture' ), @@ -271,7 +271,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getConsumerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.restricted-noop', 'alerts' ), @@ -285,7 +285,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getProducerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.restricted-noop', 'alertsRestrictedFixture' ), @@ -358,7 +358,7 @@ export default function createSnoozeRuleTests({ getService }: FtrProviderContext expect(response.body).to.eql({ error: 'Forbidden', message: getConsumerUnauthorizedErrorMessage( - 'muteAll', + 'snooze', 'test.noop', 'alertsFixture' ), diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts index f9c1bce2b0318..e97e7e73abe44 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts @@ -104,6 +104,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -165,6 +166,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -237,6 +239,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, @@ -309,6 +312,7 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex .auth(user.username, user.password) .expect(200); expect(updatedAlert.mute_all).to.eql(false); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts index 27475049ac9a6..53517b191bab6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mute_all.ts @@ -41,7 +41,7 @@ export default function createMuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(true); - + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ supertest: supertestWithoutAuth, @@ -70,6 +70,7 @@ export default function createMuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(true); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts index 47f61250157a3..782df6d86d542 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/unmute_all.ts @@ -42,6 +42,7 @@ export default function createUnmuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(false); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ @@ -75,6 +76,7 @@ export default function createUnmuteTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .expect(200); expect(updatedAlert.mute_all).to.eql(false); + expect(updatedAlert.snooze_end_time).to.eql(undefined); // Ensure AAD isn't broken await checkAAD({ From c4a52e4f97ffdc1e7d73e8f204b233ed75995a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Mon, 21 Mar 2022 21:55:34 +0100 Subject: [PATCH 018/132] [App Search] Create Elasticsearch index based engine UI (#128062) Adds the UI selector for creating an Elasticsearch index based engine. Co-authored-by: Davey Holler --- .../engine_creation/engine_creation.test.tsx | 3 +- .../engine_creation/engine_creation.tsx | 135 +++++++++- .../engine_creation_logic.test.ts | 246 ++++++++++++++++++ .../engine_creation/engine_creation_logic.ts | 67 ++++- .../search_index_selectable.scss | 5 + .../search_index_selectable.test.tsx | 127 +++++++++ .../search_index_selectable.tsx | 164 ++++++++++++ .../components/engine_creation/utils.ts | 16 ++ 8 files changed, 753 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx index 2316227a27fc7..7d033cf3d8282 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx @@ -23,6 +23,7 @@ describe('EngineCreation', () => { name: '', rawName: '', language: 'Universal', + isSubmitDisabled: false, }; const MOCK_ACTIONS = { @@ -85,7 +86,7 @@ describe('EngineCreation', () => { describe('NewEngineSubmitButton', () => { it('is disabled when name is empty', () => { - setMockValues({ ...DEFAULT_VALUES, name: '', rawName: '' }); + setMockValues({ ...DEFAULT_VALUES, name: '', rawName: '', isSubmitDisabled: true }); const wrapper = shallow(); expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx index 4736c2d4ac55a..28cbc818b60c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -13,18 +13,26 @@ import { Location } from 'history'; import { useActions, useValues } from 'kea'; import { - EuiForm, + EuiAccordion, + EuiBadge, + EuiButton, + EuiCheckableCard, + EuiFieldText, EuiFlexGroup, - EuiFormRow, EuiFlexItem, - EuiFieldText, - EuiSelect, + EuiForm, + EuiFormFieldset, + EuiFormRow, + EuiLink, EuiPanel, + EuiSelect, EuiSpacer, + EuiText, EuiTitle, - EuiButton, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + import { parseQueryParams } from '../../../shared/query_params'; import { ENGINES_TITLE } from '../engines'; import { AppSearchPageTemplate } from '../layout'; @@ -41,13 +49,15 @@ import { SUPPORTED_LANGUAGES, } from './constants'; import { EngineCreationLogic } from './engine_creation_logic'; +import { SearchIndexSelectable } from './search_index_selectable'; export const EngineCreation: React.FC = () => { const { search } = useLocation() as Location; const { method } = parseQueryParams(search); - const { name, rawName, language, isLoading } = useValues(EngineCreationLogic); - const { setIngestionMethod, setLanguage, setRawName, submitEngine } = + const { name, rawName, language, isLoading, engineType, isSubmitDisabled } = + useValues(EngineCreationLogic); + const { setIngestionMethod, setLanguage, setRawName, submitEngine, setEngineType } = useActions(EngineCreationLogic); useEffect(() => { @@ -116,8 +126,117 @@ export const EngineCreation: React.FC = () => { + + + + + + {i18n.translate('xpack.enterpriseSearch.engineCreation.engineTypeLabel', { + defaultMessage: + "Select how you'd like to manage the index for this engine", + })} + + + ), + }} + > + + + setEngineType('appSearch')} + checked={engineType === 'appSearch'} + label={ + <> + + + {i18n.translate( + 'xpack.enterpriseSearch.engineCreation.appSearchManagedLabel', + { defaultMessage: 'I want App Search to ingest and manage my data' } + )} + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.engineCreation.appSearchManagedDescription', + { + defaultMessage: + 'Create and engine and add documents via Web Crawler, API, or JSON file.', + } + )} +

+
+ + } + /> +
+ + setEngineType('elasticsearch')} + checked={engineType === 'elasticsearch'} + label={ + <> + + {i18n.translate( + 'xpack.enterpriseSearch.engineCreation.elasticsearchTechPreviewBadge', + { defaultMessage: 'Technical Preview' } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.engineCreation.elasticsearchManagedLabel', + { defaultMessage: 'I want to manage my data with Elasticsearch' } + )} + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.engineCreation.elasticsearchIndexedLabel', + { + defaultMessage: + 'Create an engine based on data managed in an Elasticsearch index.', + } + )} +

+

+ + + {i18n.translate( + 'xpack.enterpriseSearch.engineCreation.elasticsearchIndexedLink', + { + defaultMessage: + 'Learn more about using an existing Elasticsearch index', + } + )} + + +

+
+ + } + /> +
+
+ + + {engineType === 'elasticsearch' && } + +
+ { const { mount } = new LogicMounter(EngineCreationLogic); @@ -28,8 +29,79 @@ describe('EngineCreationLogic', () => { name: '', rawName: '', language: 'Universal', + isLoadingIndices: false, + indices: [], + indicesFormatted: [], + selectedIndex: '', + engineType: 'appSearch', + isSubmitDisabled: true, }; + const mockElasticsearchIndices = [ + { + health: 'yellow', + status: 'open', + name: 'search-my-index-1', + uuid: 'ydlR_QQJTeyZP66tzQSmMQ', + total: { + docs: { + count: 0, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + }, + { + health: 'green', + status: 'open', + name: 'search-my-index-2', + uuid: '4dlR_QQJTe2ZP6qtzQSmMQ', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + aliases: ['search-index-123'], + }, + ]; + + const mockSearchIndexOptions: SearchIndexSelectableOption[] = [ + { + label: 'search-my-index-1', + health: 'yellow', + status: 'open', + total: { + docs: { + count: 0, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + }, + { + label: 'search-my-index-2', + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + }, + ]; + it('has expected default values', () => { mount(); expect(EngineCreationLogic.values).toEqual(DEFAULT_VALUES); @@ -97,6 +169,146 @@ describe('EngineCreationLogic', () => { isLoading: false, }); }); + + it('resets selectedIndex', () => { + mount({ selectedIndex: 'search-selected-index' }); + EngineCreationLogic.actions.onSubmitError(); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + }); + }); + }); + + describe('loadIndices', () => { + it('sets isLoadingIndices to true', () => { + mount({ isLoadingIndices: false }); + EngineCreationLogic.actions.loadIndices(); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + isLoadingIndices: true, + }); + }); + }); + + describe('onLoadIndicesSuccess', () => { + it('sets isLoadingIndices to false', () => { + mount({ isLoadingIndices: true }); + EngineCreationLogic.actions.onLoadIndicesSuccess([]); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + isLoadingIndices: false, + }); + }); + }); + + describe('setSelectedIndex', () => { + it('sets selected index name', () => { + mount(); + EngineCreationLogic.actions.setSelectedIndex('search-test-index'); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + selectedIndex: 'search-test-index', + }); + }); + }); + + describe('setEngineType', () => { + it('sets engine type', () => { + mount(); + EngineCreationLogic.actions.setEngineType('elasticsearch'); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + engineType: 'elasticsearch', + }); + }); + }); + }); + + describe('selectors', () => { + beforeEach(() => { + mount(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('indicesFormatted', () => { + it('should return empty array when no index available', () => { + expect(EngineCreationLogic.values.indices).toEqual([]); + expect(EngineCreationLogic.values.indicesFormatted).toEqual([]); + }); + + it('should return SearchIndexSelectableOption list calculated from indices', () => { + mount({ + indices: mockElasticsearchIndices, + }); + + expect(EngineCreationLogic.values.indicesFormatted).toEqual(mockSearchIndexOptions); + }); + + it('should handle checked condition correctly', () => { + mount({ + indices: mockElasticsearchIndices, + selectedIndex: 'search-my-index-1', + }); + const mockCheckedSearchIndexOptions = [...mockSearchIndexOptions]; + mockCheckedSearchIndexOptions[0].checked = 'on'; + + expect(EngineCreationLogic.values.indicesFormatted).toEqual(mockCheckedSearchIndexOptions); + }); + }); + + describe('isSubmitDisabled', () => { + describe('App Search based engine', () => { + it('should disable button if engine name is empty', () => { + mount({ + rawName: '', + engineType: 'appSearch', + }); + + expect(EngineCreationLogic.values.isSubmitDisabled).toBe(true); + }); + it('should enable button if engine name is entered', () => { + mount({ + rawName: 'my-engine-name', + engineType: 'appSearch', + }); + + expect(EngineCreationLogic.values.isSubmitDisabled).toBe(false); + }); + }); + describe('Elasticsearch Index based engine', () => { + it('should disable button if engine name is empty', () => { + mount({ + rawName: '', + selectedIndex: 'search-my-index-1', + engineType: 'elasticsearch', + }); + + expect(EngineCreationLogic.values.isSubmitDisabled).toBe(true); + }); + + it('should disable button if no index selected', () => { + mount({ + rawName: 'my-engine-name', + selectedIndex: '', + engineType: 'elasticsearch', + }); + + expect(EngineCreationLogic.values.isSubmitDisabled).toBe(true); + }); + + it('should enable button if all selected', () => { + mount({ + rawName: 'my-engine-name', + selectedIndex: 'search-my-index-1', + engineType: 'elasticsearch', + }); + + expect(EngineCreationLogic.values.isSubmitDisabled).toBe(false); + }); + }); }); }); @@ -153,5 +365,39 @@ describe('EngineCreationLogic', () => { expect(flashAPIErrors).toHaveBeenCalledTimes(1); }); }); + + describe('loadIndices', () => { + beforeEach(() => { + mount(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('GETs to /internal/enterprise_search/indices', () => { + EngineCreationLogic.actions.loadIndices(); + expect(http.get).toHaveBeenCalledWith('/internal/enterprise_search/indices'); + }); + + it('calls onLoadIndicesSuccess with payload on load is successful', async () => { + jest.spyOn(EngineCreationLogic.actions, 'onLoadIndicesSuccess'); + http.get.mockReturnValueOnce(Promise.resolve([mockElasticsearchIndices[0]])); + EngineCreationLogic.actions.loadIndices(); + await nextTick(); + expect(EngineCreationLogic.actions.onLoadIndicesSuccess).toHaveBeenCalledWith([ + mockElasticsearchIndices[0], + ]); + }); + + it('calls flashAPIErros on indices load fails', async () => { + jest.spyOn(EngineCreationLogic.actions, 'onSubmitError'); + http.get.mockRejectedValueOnce({}); + EngineCreationLogic.actions.loadIndices(); + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + expect(EngineCreationLogic.actions.onSubmitError).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts index d62ed6dc33032..f972993b32ca4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts @@ -7,14 +7,17 @@ import { kea, MakeLogicType } from 'kea'; +import { ElasticsearchIndex } from '../../../../../common/types'; import { flashAPIErrors, flashSuccessToast } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { KibanaLogic } from '../../../shared/kibana'; import { formatApiName } from '../../utils/format_api_name'; import { DEFAULT_LANGUAGE, ENGINE_CREATION_SUCCESS_MESSAGE } from './constants'; -import { getRedirectToAfterEngineCreation } from './utils'; +import { SearchIndexSelectableOption } from './search_index_selectable'; +import { getRedirectToAfterEngineCreation, formatIndicesToSelectable } from './utils'; +type EngineType = 'appSearch' | 'elasticsearch'; interface EngineCreationActions { onEngineCreationSuccess(): void; setIngestionMethod(method: string): { method: string }; @@ -22,6 +25,10 @@ interface EngineCreationActions { setRawName(rawName: string): { rawName: string }; submitEngine(): void; onSubmitError(): void; + loadIndices(): void; + onLoadIndicesSuccess(indices: ElasticsearchIndex[]): { indices: ElasticsearchIndex[] }; + setSelectedIndex(selectedIndexName: string): { selectedIndexName: string }; + setEngineType(engineType: EngineType): { engineType: EngineType }; } interface EngineCreationValues { @@ -30,6 +37,12 @@ interface EngineCreationValues { language: string; name: string; rawName: string; + isLoadingIndices: boolean; + indices: ElasticsearchIndex[]; + indicesFormatted: SearchIndexSelectableOption[]; + selectedIndex: string; + engineType: EngineType; + isSubmitDisabled: boolean; } export const EngineCreationLogic = kea>({ @@ -41,6 +54,10 @@ export const EngineCreationLogic = kea ({ rawName }), submitEngine: true, onSubmitError: true, + loadIndices: true, + onLoadIndicesSuccess: (indices) => ({ indices }), + setSelectedIndex: (selectedIndexName) => ({ selectedIndexName }), + setEngineType: (engineType) => ({ engineType }), }, reducers: { ingestionMethod: [ @@ -68,9 +85,47 @@ export const EngineCreationLogic = kea rawName, }, ], + isLoadingIndices: [ + false, + { + loadIndices: () => true, + onLoadIndicesSuccess: () => false, + onSubmitError: () => false, + }, + ], + indices: [ + [], + { + onLoadIndicesSuccess: (_, { indices }) => indices, + }, + ], + selectedIndex: [ + '', + { + setSelectedIndex: (_, { selectedIndexName }) => selectedIndexName, + onSubmitError: () => '', + }, + ], + engineType: [ + 'appSearch', + { + setEngineType: (_, { engineType }) => engineType, + }, + ], }, selectors: ({ selectors }) => ({ name: [() => [selectors.rawName], (rawName) => formatApiName(rawName)], + indicesFormatted: [ + () => [selectors.indices, selectors.selectedIndex], + (indices: ElasticsearchIndex[], selectedIndexName) => + formatIndicesToSelectable(indices, selectedIndexName), + ], + isSubmitDisabled: [ + () => [selectors.name, selectors.engineType, selectors.selectedIndex], + (name: string, engineType: EngineType, selectedIndex: string) => + (name.length === 0 && engineType === 'appSearch') || + ((name.length === 0 || selectedIndex.length === 0) && engineType === 'elasticsearch'), + ], }), listeners: ({ values, actions }) => ({ submitEngine: async () => { @@ -95,5 +150,15 @@ export const EngineCreationLogic = kea { + const { http } = HttpLogic.values; + try { + const indices = await http.get('/internal/enterprise_search/indices'); + actions.onLoadIndicesSuccess(indices as ElasticsearchIndex[]); + } catch (e) { + flashAPIErrors(e); + actions.onSubmitError(); + } + }, }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.scss new file mode 100644 index 0000000000000..7d50e8607d442 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.scss @@ -0,0 +1,5 @@ +.selectableSecondaryContentLabel { + &:not(:last-child) { + padding-right: $euiSizeL; + } +} \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.test.tsx new file mode 100644 index 0000000000000..4adaf4050ffe5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, mount } from 'enzyme'; + +import { EuiSelectable, EuiIcon, EuiHighlight } from '@elastic/eui'; + +import { SearchIndexSelectable, SearchIndexSelectableOption } from './search_index_selectable'; + +describe('SearchIndexSelectable', () => { + const DEFAULT_VALUES = { + isIndicesLoading: false, + indicesFormatted: [ + { + label: 'search-test-index-1', + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: '108Kb', + }, + }, + }, + { + label: 'search-test-index-2', + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: '108Kb', + }, + }, + checked: 'on', + }, + ], + }; + const MOCK_ACTIONS = { loadIndices: jest.fn(), setSelectedIndex: jest.fn() }; + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(DEFAULT_VALUES); + setMockActions(MOCK_ACTIONS); + }); + + it('renders', () => { + const wrapper = shallow(); + const selectable = wrapper.find(EuiSelectable); + expect(wrapper.find('[data-test-subj="SearchIndexSelectable"]')).toHaveLength(1); + expect(selectable.prop('emptyMessage')).toEqual('No Elasticsearch indices available'); + expect(selectable.prop('loadingMessage')).toEqual('Loading Elasticsearch indices'); + }); + + it('renders custom options', () => { + const wrapper = shallow(); + const selectable = wrapper.find(EuiSelectable); + const customOptions = selectable.prop('renderOption')!; + const renderedOptions = mount( + <> + {customOptions( + DEFAULT_VALUES.indicesFormatted[0] as SearchIndexSelectableOption, + 'search-' + )} + + ); + + expect(renderedOptions.find(EuiHighlight).text()).toEqual('search-test-index-1'); + expect(renderedOptions.find(EuiIcon).prop('color')).toEqual('success'); + expect(renderedOptions.find('[data-test-subj="optionStatus"]').text()).toEqual( + 'Status:\u00a0open' + ); + expect(renderedOptions.find('[data-test-subj="optionDocs"]').text()).toEqual( + 'Docs count:\u00a0100' + ); + expect(renderedOptions.find('[data-test-subj="optionStorage"]').text()).toEqual( + 'Storage size:\u00a0108Kb' + ); + }); + + it('calls loadIndices on mount', () => { + shallow(); + expect(MOCK_ACTIONS.loadIndices).toHaveBeenCalled(); + }); + + it('renders - on rows for missing information', () => { + const wrapper = shallow(); + const selectable = wrapper.find(EuiSelectable); + const customOptions = selectable.prop('renderOption')!; + const renderedOptions = mount( + <>{customOptions({ label: 'search-missing-data' }, 'search-')} + ); + + expect(renderedOptions.find(EuiHighlight).text()).toEqual('search-missing-data'); + expect(renderedOptions.find(EuiIcon).prop('color')).toEqual(''); + expect(renderedOptions.find('[data-test-subj="optionStatus"]').text()).toEqual( + 'Status:\u00a0-' + ); + expect(renderedOptions.find('[data-test-subj="optionDocs"]').text()).toEqual( + 'Docs count:\u00a0-' + ); + expect(renderedOptions.find('[data-test-subj="optionStorage"]').text()).toEqual( + 'Storage size:\u00a0-' + ); + }); + + it('calls setSelectedIndex onChange', () => { + const wrapper = shallow(); + const onChangeHandler = wrapper.find(EuiSelectable).prop('onChange')!; + onChangeHandler(DEFAULT_VALUES.indicesFormatted as SearchIndexSelectableOption[]); + expect(MOCK_ACTIONS.setSelectedIndex).toHaveBeenCalledWith('search-test-index-2'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.tsx new file mode 100644 index 0000000000000..f5ac4f67cc61e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/search_index_selectable.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useValues, useActions } from 'kea'; + +import { + EuiSelectable, + EuiPanel, + EuiFormRow, + EuiHighlight, + EuiIcon, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { EngineCreationLogic } from './engine_creation_logic'; + +import './search_index_selectable.scss'; + +export type HealthStrings = 'red' | 'green' | 'yellow' | 'unavailable'; +export interface SearchIndexSelectableOption { + label: string; + health: HealthStrings; + status?: string; + total: { + docs: { + count: number; + deleted: number; + }; + store: { + size_in_bytes: string; + }; + }; + checked?: 'on'; +} + +const healthColorsMap = { + red: 'danger', + green: 'success', + yellow: 'warning', + unavailable: '', +}; + +const renderIndexOption = (option: SearchIndexSelectableOption, searchValue: string) => { + return ( + <> + {option.label ?? ''} + + + + + +  {option.health ?? '-'} + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.elasticsearchIndex.status', + { + defaultMessage: 'Status:', + } + )} + +  {option.status ?? '-'} + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.elasticsearchIndex.docCount', + { + defaultMessage: 'Docs count:', + } + )} + +  {option.total?.docs?.count ?? '-'} + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documentCreation.elasticsearchIndex.storage', + { + defaultMessage: 'Storage size:', + } + )} + +  {option.total?.store?.size_in_bytes ?? '-'} + + + + + ); +}; + +export const SearchIndexSelectable: React.FC = () => { + const { indicesFormatted, isLoadingIndices } = useValues(EngineCreationLogic); + const { loadIndices, setSelectedIndex } = useActions(EngineCreationLogic); + + const onChange = (options: SearchIndexSelectableOption[]) => { + const selected = options.find((option) => option.checked === 'on'); + setSelectedIndex(selected?.label ?? ''); + }; + + useEffect(() => { + loadIndices(); + }, []); + + return ( + + + + {(list, search) => ( + <> + {search} + {list} + + )} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/utils.ts index 1f50a5c31e11a..f93d72fd3a2e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/utils.ts @@ -7,8 +7,11 @@ import { generatePath } from 'react-router-dom'; +import { ElasticsearchIndex } from '../../../../../common/types'; import { ENGINE_CRAWLER_PATH, ENGINE_PATH } from '../../routes'; +import { HealthStrings, SearchIndexSelectableOption } from './search_index_selectable'; + export const getRedirectToAfterEngineCreation = ({ ingestionMethod, engineName, @@ -27,3 +30,16 @@ export const getRedirectToAfterEngineCreation = ({ return enginePath; }; + +export const formatIndicesToSelectable = ( + indices: ElasticsearchIndex[], + selectedIndexName: string +): SearchIndexSelectableOption[] => { + return indices.map((index) => ({ + ...(selectedIndexName === index.name ? { checked: 'on' } : {}), + label: index.name, + health: (index.health as HealthStrings) ?? 'unavailable', + status: index.status, + total: index.total, + })); +}; From c9dfe167258db64a32075167a6ee722e08793f1c Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 21 Mar 2022 14:59:54 -0600 Subject: [PATCH 019/132] [Controls] Improve controls management UX (#127524) * Move control type selection in to flyout * Set default icon type if getIconType undefined * Fix create control functional tests * Fix factories for multiple types * Show only selected type icon when editing * Add optional tooltip support * Rename promise variable * Fix imports * Fix nits * Edit tooltip text for options list control --- .../control_group/control_group_strings.ts | 4 + .../control_group/editor/control_editor.tsx | 184 +++++++++++------- .../control_group/editor/create_control.tsx | 115 ++++------- .../control_group/editor/edit_control.tsx | 4 +- .../embeddable/control_group_container.tsx | 3 +- .../options_list/options_list_strings.ts | 2 +- .../page_objects/dashboard_page_controls.ts | 3 +- 7 files changed, 161 insertions(+), 154 deletions(-) diff --git a/src/plugins/controls/public/control_group/control_group_strings.ts b/src/plugins/controls/public/control_group/control_group_strings.ts index 73b81d8c5d459..dbfa3ed30e7f7 100644 --- a/src/plugins/controls/public/control_group/control_group_strings.ts +++ b/src/plugins/controls/public/control_group/control_group_strings.ts @@ -48,6 +48,10 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { defaultMessage: 'Title', }), + getControlTypeTitle: () => + i18n.translate('controls.controlGroup.manageControl.controlTypesTitle', { + defaultMessage: 'Control type', + }), getWidthInputTitle: () => i18n.translate('controls.controlGroup.manageControl.widthInputTitle', { defaultMessage: 'Control size', diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index 94c15b0d88430..0a26f966503f7 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -29,6 +29,10 @@ import { EuiForm, EuiButtonEmpty, EuiSpacer, + EuiKeyPadMenu, + EuiKeyPadMenuItem, + EuiIcon, + EuiToolTip, } from '@elastic/eui'; import { ControlGroupStrings } from '../control_group_strings'; @@ -39,14 +43,15 @@ import { IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; +import { pluginServices } from '../../services'; +import { EmbeddableFactoryDefinition } from '../../../../embeddable/public'; interface EditControlProps { - factory: IEditableControlFactory; embeddable?: ControlEmbeddable; - width: ControlWidth; isCreate: boolean; title?: string; - onSave: () => void; + width: ControlWidth; + onSave: (type: string) => void; onCancel: () => void; removeControl?: () => void; updateTitle: (title?: string) => void; @@ -55,25 +60,75 @@ interface EditControlProps { } export const ControlEditor = ({ - onTypeEditorChange, - removeControl, - updateTitle, - updateWidth, embeddable, isCreate, - onCancel, - factory, - onSave, title, width, + onSave, + onCancel, + removeControl, + updateTitle, + updateWidth, + onTypeEditorChange, }: EditControlProps) => { + const { controls } = pluginServices.getServices(); + const { getControlTypes, getControlFactory } = controls; + + const [selectedType, setSelectedType] = useState( + !isCreate && embeddable ? embeddable.type : getControlTypes()[0] + ); + const [defaultTitle, setDefaultTitle] = useState(); const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); - const [controlEditorValid, setControlEditorValid] = useState(false); - const [defaultTitle, setDefaultTitle] = useState(); - const ControlTypeEditor = factory.controlEditorComponent; + const getControlTypeEditor = (type: string) => { + const factory = getControlFactory(type); + const ControlTypeEditor = (factory as IEditableControlFactory).controlEditorComponent; + return ControlTypeEditor ? ( + { + if (!currentTitle || currentTitle === defaultTitle) { + setCurrentTitle(newDefaultTitle); + updateTitle(newDefaultTitle); + } + setDefaultTitle(newDefaultTitle); + }} + /> + ) : null; + }; + + const getTypeButtons = (controlTypes: string[]) => { + return controlTypes.map((type) => { + const factory = getControlFactory(type); + const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); + const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); + const menuPadItem = ( + { + setSelectedType(type); + }} + > + + + ); + + return tooltip ? ( + + {menuPadItem} + + ) : ( + menuPadItem + ); + }); + }; return ( <> @@ -88,58 +143,53 @@ export const ControlEditor = ({ - - {ControlTypeEditor && ( - { - if (!currentTitle || currentTitle === defaultTitle) { - setCurrentTitle(newDefaultTitle); - updateTitle(newDefaultTitle); - } - setDefaultTitle(newDefaultTitle); - }} - /> - )} - - { - updateTitle(e.target.value || defaultTitle); - setCurrentTitle(e.target.value); - }} - /> - - - { - setCurrentWidth(newWidth as ControlWidth); - updateWidth(newWidth as ControlWidth); - }} - /> + + + {isCreate ? getTypeButtons(getControlTypes()) : getTypeButtons([selectedType])} + - - {removeControl && ( - { - onCancel(); - removeControl(); - }} - > - {ControlGroupStrings.management.getDeleteButtonTitle()} - + {selectedType && ( + <> + {getControlTypeEditor(selectedType)} + + { + updateTitle(e.target.value || defaultTitle); + setCurrentTitle(e.target.value); + }} + /> + + + { + setCurrentWidth(newWidth as ControlWidth); + updateWidth(newWidth as ControlWidth); + }} + /> + + + {removeControl && ( + { + onCancel(); + removeControl(); + }} + > + {ControlGroupStrings.management.getDeleteButtonTitle()} + + )} + )} @@ -150,9 +200,7 @@ export const ControlEditor = ({ aria-label={`cancel-${title}`} data-test-subj="control-editor-cancel" iconType="cross" - onClick={() => { - onCancel(); - }} + onClick={() => onCancel()} > {ControlGroupStrings.manageControl.getCancelTitle()} @@ -164,7 +212,7 @@ export const ControlEditor = ({ iconType="check" color="primary" disabled={!controlEditorValid} - onClick={() => onSave()} + onClick={() => onSave(selectedType)} > {ControlGroupStrings.manageControl.getSaveChangesTitle()} diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 57e74d7c1b5d7..218024433802b 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -6,22 +6,15 @@ * Side Public License, v 1. */ -import { - EuiButton, - EuiButtonIconColor, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiPopover, -} from '@elastic/eui'; -import React, { useState, ReactElement } from 'react'; +import { EuiButton, EuiContextMenuItem } from '@elastic/eui'; +import React from 'react'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { OverlayRef } from '../../../../../core/public'; import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; -import { EmbeddableFactoryNotFoundError } from '../../../../embeddable/public'; -import { ControlWidth, IEditableControlFactory, ControlInput } from '../../types'; +import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; import { toMountPoint } from '../../../../kibana_react/public'; export type CreateControlButtonTypes = 'toolbar' | 'callout'; @@ -33,6 +26,11 @@ export interface CreateControlButtonProps { closePopover?: () => void; } +interface CreateControlResult { + type: string; + controlInput: Omit; +} + export const CreateControlButton = ({ defaultControlWidth, updateDefaultWidth, @@ -44,14 +42,11 @@ export const CreateControlButton = ({ const { overlays, controls } = pluginServices.getServices(); const { getControlTypes, getControlFactory } = controls; const { openFlyout, openConfirm } = overlays; - const [isControlTypePopoverOpen, setIsControlTypePopoverOpen] = useState(false); - const createNewControl = async (type: string) => { + const createNewControl = async () => { const PresentationUtilProvider = pluginServices.getContextProvider(); - const factory = getControlFactory(type); - if (!factory) throw new EmbeddableFactoryNotFoundError(type); - const initialInputPromise = new Promise>((resolve, reject) => { + const initialInputPromise = new Promise((resolve, reject) => { let inputToReturn: Partial = {}; const onCancel = (ref: OverlayRef) => { @@ -73,28 +68,26 @@ export const CreateControlButton = ({ }); }; - const editableFactory = factory as IEditableControlFactory; - const flyoutInstance = openFlyout( toMountPoint( (inputToReturn.title = newTitle)} updateWidth={updateDefaultWidth} - onTypeEditorChange={(partialInput) => - (inputToReturn = { ...inputToReturn, ...partialInput }) - } - onSave={() => { - if (editableFactory.presaveTransformFunction) { - inputToReturn = editableFactory.presaveTransformFunction(inputToReturn); + onSave={(type: string) => { + const factory = getControlFactory(type) as IEditableControlFactory; + if (factory.presaveTransformFunction) { + inputToReturn = factory.presaveTransformFunction(inputToReturn); } - resolve(inputToReturn); + resolve({ type, controlInput: inputToReturn }); flyoutInstance.close(); }} onCancel={() => onCancel(flyoutInstance)} + onTypeEditorChange={(partialInput) => + (inputToReturn = { ...inputToReturn, ...partialInput }) + } /> ), @@ -105,8 +98,8 @@ export const CreateControlButton = ({ }); initialInputPromise.then( - async (explicitInput) => { - await addNewEmbeddable(type, explicitInput); + async (promise) => { + await addNewEmbeddable(promise.type, promise.controlInput); }, () => {} // swallow promise rejection because it can be part of normal flow ); @@ -115,60 +108,24 @@ export const CreateControlButton = ({ if (getControlTypes().length === 0) return null; const commonButtonProps = { - color: 'primary' as EuiButtonIconColor, + key: 'addControl', + onClick: () => { + createNewControl(); + if (closePopover) { + closePopover(); + } + }, 'data-test-subj': 'controls-create-button', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; - const items: ReactElement[] = []; - getControlTypes().forEach((type) => { - const factory = getControlFactory(type); - items.push( - { - if (buttonType === 'callout' && isControlTypePopoverOpen) { - setIsControlTypePopoverOpen(false); - } else if (closePopover) { - closePopover(); - } - createNewControl(type); - }} - toolTipContent={factory.getDescription()} - > - {factory.getDisplayName()} - - ); - }); - - if (buttonType === 'callout') { - const onCreateButtonClick = () => { - if (getControlTypes().length > 1) { - setIsControlTypePopoverOpen(!isControlTypePopoverOpen); - return; - } - createNewControl(getControlTypes()[0]); - }; - - const createControlButton = ( - - {ControlGroupStrings.emptyState.getAddControlButtonTitle()} - - ); - return ( - setIsControlTypePopoverOpen(false)} - > - - - ); - } - return ; + return buttonType === 'callout' ? ( + + {ControlGroupStrings.emptyState.getAddControlButtonTitle()} + + ) : ( + + {ControlGroupStrings.emptyState.getAddControlButtonTitle()} + + ); }; diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index 210000e4f617c..b70b7ef5f7e40 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -84,15 +84,12 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => }); }; - const editableFactory = factory as IEditableControlFactory; - const flyoutInstance = openFlyout( forwardAllContext( onCancel(flyoutInstance)} updateTitle={(newTitle) => (inputToReturn.title = newTitle)} @@ -101,6 +98,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => (inputToReturn = { ...inputToReturn, ...partialInput }) } onSave={() => { + const editableFactory = factory as IEditableControlFactory; if (editableFactory.presaveTransformFunction) { inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); } diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index f9b18eb2e61c9..4bae605e0ef49 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -12,7 +12,7 @@ import ReactDOM from 'react-dom'; import deepEqual from 'fast-deep-equal'; import { Filter, uniqFilters } from '@kbn/es-query'; import { EMPTY, merge, pipe, Subject, Subscription } from 'rxjs'; -import { EuiContextMenuPanel, EuiHorizontalRule } from '@elastic/eui'; +import { EuiContextMenuPanel } from '@elastic/eui'; import { distinctUntilChanged, debounceTime, @@ -140,7 +140,6 @@ export class ControlGroupContainer extends Container< , this.getEditControlGroupButton(closePopover), ]} /> diff --git a/src/plugins/controls/public/control_types/options_list/options_list_strings.ts b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts index c7074eb478a5f..62fb54163c2bd 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_strings.ts +++ b/src/plugins/controls/public/control_types/options_list/options_list_strings.ts @@ -15,7 +15,7 @@ export const OptionsListStrings = { }), getDescription: () => i18n.translate('controls.optionsList.description', { - defaultMessage: 'Add control that allows options to be selected from a dropdown.', + defaultMessage: 'Add a menu for selecting field values.', }), summary: { getSeparator: () => diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 0e355e0820a6e..33053306243fe 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -66,10 +66,11 @@ export class DashboardPageControls extends FtrService { public async openCreateControlFlyout(type: string) { this.log.debug(`Opening flyout for ${type} control`); await this.testSubjects.click('dashboard-controls-menu-button'); - await this.testSubjects.click(`create-${type}-control`); + await this.testSubjects.click('controls-create-button'); await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); + await this.testSubjects.click(`create-${type}-control`); } /* ----------------------------------------------------------- From 0682219a8dcc8d72ee88e39257fc567e518a8991 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 21 Mar 2022 14:57:57 -0700 Subject: [PATCH 020/132] [type-summarizer] handle enums, extended interfaces, and export-type'd types (#128200) --- .../lib/export_collector/collector_results.ts | 5 +- .../lib/export_collector/exports_collector.ts | 33 ++++- .../lib/export_collector/imported_symbol.ts | 16 +-- .../kbn-type-summarizer/src/lib/printer.ts | 28 +++- .../kbn-type-summarizer/src/lib/ts_nodes.ts | 6 +- .../src/tests/integration_helpers.ts | 11 +- .../src/tests/integration_tests/class.test.ts | 61 +++++++++ .../src/tests/integration_tests/enum.test.ts | 111 ++++++++++++++++ .../integration_tests/import_boundary.test.ts | 120 ++++++++++++++++++ .../tests/integration_tests/interface.test.ts | 43 +++++++ .../tests/integration_tests/literals.test.ts | 41 ++++++ .../integration_tests/type_alias.test.ts | 39 ++++++ 12 files changed, 491 insertions(+), 23 deletions(-) create mode 100644 packages/kbn-type-summarizer/src/tests/integration_tests/enum.test.ts create mode 100644 packages/kbn-type-summarizer/src/tests/integration_tests/literals.test.ts diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts index 6b1f98a10f69a..abcdf7e6f7b0a 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts @@ -41,10 +41,11 @@ export class CollectorResults { addImportFromNodeModules( exportInfo: ExportInfo | undefined, - symbol: DecSymbol, + sourceSymbol: DecSymbol, + importSymbol: DecSymbol, moduleId: string ) { - const imp = ImportedSymbol.fromSymbol(symbol, moduleId); + const imp = ImportedSymbol.fromSymbol(sourceSymbol, importSymbol, moduleId); imp.exportInfo ||= exportInfo; this.importedSymbols.add(imp); } diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts index b6e7a8382bc0a..65ee07d9716fb 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts @@ -27,7 +27,8 @@ import { isNodeModule } from '../is_node_module'; interface ResolvedNmImport { type: 'import_from_node_modules'; - symbol: DecSymbol; + importSymbol: DecSymbol; + sourceSymbol: DecSymbol; moduleId: string; } interface ResolvedSymbol { @@ -101,18 +102,31 @@ export class ExportCollector { const targetPaths = [ ...new Set(aliased.declarations.map((d) => this.sourceMapper.getSourceFile(d).fileName)), ]; - if (targetPaths.length > 1) { - throw new Error('importing a symbol from multiple locations is unsupported at this time'); + + let nmCount = 0; + let localCount = 0; + for (const targetPath of targetPaths) { + if (isNodeModule(this.dtsDir, targetPath)) { + nmCount += 1; + } else { + localCount += 1; + } } - const targetPath = targetPaths[0]; - if (isNodeModule(this.dtsDir, targetPath)) { + if (nmCount === targetPaths.length) { return { type: 'import_from_node_modules', - symbol, + importSymbol: symbol, + sourceSymbol: aliased, moduleId: parentImport.moduleSpecifier.text, }; } + + if (localCount === targetPaths.length) { + return undefined; + } + + throw new Error('using a symbol which is locally extended is unsupported at this time'); } } @@ -148,7 +162,12 @@ export class ExportCollector { const source = this.resolveAliasSymbol(symbol); if (source.type === 'import_from_node_modules') { - results.addImportFromNodeModules(exportInfo, source.symbol, source.moduleId); + results.addImportFromNodeModules( + exportInfo, + source.sourceSymbol, + source.importSymbol, + source.moduleId + ); return; } diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts index 04ddd7730df0c..0ec05e98e4568 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts @@ -13,17 +13,17 @@ import { ExportInfo } from '../export_info'; const cache = new WeakMap(); export class ImportedSymbol { - static fromSymbol(symbol: DecSymbol, moduleId: string) { - const cached = cache.get(symbol); + static fromSymbol(source: DecSymbol, importSymbol: DecSymbol, moduleId: string) { + const cached = cache.get(source); if (cached) { return cached; } - if (symbol.declarations.length !== 1) { + if (importSymbol.declarations.length !== 1) { throw new Error('expected import symbol to have exactly one declaration'); } - const dec = symbol.declarations[0]; + const dec = importSymbol.declarations[0]; if ( !ts.isImportClause(dec) && !ts.isExportSpecifier(dec) && @@ -41,18 +41,18 @@ export class ImportedSymbol { } const imp = ts.isImportClause(dec) - ? new ImportedSymbol(symbol, 'default', dec.name.text, dec.isTypeOnly, moduleId) + ? new ImportedSymbol(importSymbol, 'default', dec.name.text, dec.isTypeOnly, moduleId) : ts.isNamespaceImport(dec) - ? new ImportedSymbol(symbol, '*', dec.name.text, dec.parent.isTypeOnly, moduleId) + ? new ImportedSymbol(importSymbol, '*', dec.name.text, dec.parent.isTypeOnly, moduleId) : new ImportedSymbol( - symbol, + importSymbol, dec.name.text, dec.propertyName?.text, dec.isTypeOnly || dec.parent.parent.isTypeOnly, moduleId ); - cache.set(symbol, imp); + cache.set(source, imp); return imp; } diff --git a/packages/kbn-type-summarizer/src/lib/printer.ts b/packages/kbn-type-summarizer/src/lib/printer.ts index ac95537c2be9f..8688ab2af66ec 100644 --- a/packages/kbn-type-summarizer/src/lib/printer.ts +++ b/packages/kbn-type-summarizer/src/lib/printer.ts @@ -124,6 +124,10 @@ export class Printer { return 'interface'; } + if (node.kind === ts.SyntaxKind.EnumDeclaration) { + return 'enum'; + } + if (ts.isVariableDeclaration(node)) { return this.getVariableDeclarationType(node); } @@ -131,9 +135,18 @@ export class Printer { private printModifiers(exportInfo: ExportInfo | undefined, node: ts.Declaration) { const flags = ts.getCombinedModifierFlags(node); + const keyword = this.getDeclarationKeyword(node); const modifiers: string[] = []; if (exportInfo) { - modifiers.push(exportInfo.type); + // always use `export` for explicit types + if (keyword) { + modifiers.push('export'); + } else { + modifiers.push(exportInfo.type); + } + } + if ((keyword === 'var' || keyword === 'const') && !exportInfo) { + modifiers.push('declare'); } if (flags & ts.ModifierFlags.Default) { modifiers.push('default'); @@ -160,7 +173,6 @@ export class Printer { modifiers.push('async'); } - const keyword = this.getDeclarationKeyword(node); if (keyword) { modifiers.push(keyword); } @@ -292,7 +304,13 @@ export class Printer { case ts.SyntaxKind.BigIntLiteral: case ts.SyntaxKind.NumericLiteral: case ts.SyntaxKind.StringKeyword: - return [this.printNode(node)]; + case ts.SyntaxKind.TypeReference: + case ts.SyntaxKind.IntersectionType: + return [node.getFullText().trim()]; + } + + if (ts.isEnumDeclaration(node)) { + return [node.getFullText().trim() + '\n']; } if (ts.isFunctionDeclaration(node)) { @@ -335,6 +353,7 @@ export class Printer { this.printModifiers(exportInfo, node), this.getMappedSourceNode(node.name), ...(node.type ? [': ', this.printNode(node.type)] : []), + ...(node.initializer ? [' = ', this.printNode(node.initializer)] : []), ';\n', ]; } @@ -362,6 +381,9 @@ export class Printer { this.printModifiers(exportInfo, node), node.name ? this.getMappedSourceNode(node.name) : [], this.printTypeParameters(node), + node.heritageClauses + ? ` ${node.heritageClauses.map((c) => c.getFullText().trim()).join(' ')}` + : [], ' {\n', node.members.flatMap((m) => { const memberText = m.getText(); diff --git a/packages/kbn-type-summarizer/src/lib/ts_nodes.ts b/packages/kbn-type-summarizer/src/lib/ts_nodes.ts index b5c03ee8c4c17..f3e2209651ba9 100644 --- a/packages/kbn-type-summarizer/src/lib/ts_nodes.ts +++ b/packages/kbn-type-summarizer/src/lib/ts_nodes.ts @@ -13,7 +13,8 @@ export type ValueNode = | ts.FunctionDeclaration | ts.TypeAliasDeclaration | ts.VariableDeclaration - | ts.InterfaceDeclaration; + | ts.InterfaceDeclaration + | ts.EnumDeclaration; export function isExportedValueNode(node: ts.Node): node is ValueNode { return ( @@ -21,7 +22,8 @@ export function isExportedValueNode(node: ts.Node): node is ValueNode { node.kind === ts.SyntaxKind.FunctionDeclaration || node.kind === ts.SyntaxKind.TypeAliasDeclaration || node.kind === ts.SyntaxKind.VariableDeclaration || - node.kind === ts.SyntaxKind.InterfaceDeclaration + node.kind === ts.SyntaxKind.InterfaceDeclaration || + node.kind === ts.SyntaxKind.EnumDeclaration ); } export function assertExportedValueNode(node: ts.Node): asserts node is ValueNode { diff --git a/packages/kbn-type-summarizer/src/tests/integration_helpers.ts b/packages/kbn-type-summarizer/src/tests/integration_helpers.ts index c64e58c4e33f9..82ef7c7fbdc4c 100644 --- a/packages/kbn-type-summarizer/src/tests/integration_helpers.ts +++ b/packages/kbn-type-summarizer/src/tests/integration_helpers.ts @@ -152,6 +152,15 @@ class MockCli { // convert the source files to .d.ts files this.buildDts(); + // copy .d.ts files from source to dist + for (const [rel, content] of Object.entries(this.mockFiles)) { + if (rel.endsWith('.d.ts')) { + const path = Path.resolve(this.dtsOutputDir, rel); + await Fsp.mkdir(Path.dirname(path), { recursive: true }); + await Fsp.writeFile(path, dedent(content)); + } + } + // summarize the .d.ts files into the output dir await summarizePackage(log, { dtsDir: normalizePath(this.dtsOutputDir), @@ -159,7 +168,7 @@ class MockCli { outputDir: normalizePath(this.outputDir), repoRelativePackageDir: 'src', tsconfigPath: normalizePath(this.tsconfigPath), - strictPrinting: false, + strictPrinting: true, }); // return the results diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.ts index eaf87cda8521b..0c3c6412269c2 100644 --- a/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.ts +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/class.test.ts @@ -75,3 +75,64 @@ it('prints basic class correctly', async () => { " `); }); + +it('prints heritage clauses', async () => { + const output = await run(` + class Foo { + foo() { + return 'foo' + } + } + + interface Named { + name: string + } + + interface Aged { + age: number + } + + export class Bar extends Foo implements Named, Aged { + name = 'bar' + age = 123 + + bar() { + return this.name + } + } + `); + + expect(output.code).toMatchInlineSnapshot(` + "class Foo { + foo(): string; + } + interface Named { + name: string; + } + interface Aged { + age: number; + } + export class Bar extends Foo implements Named, Aged { + name: string; + age: number; + bar(): string; + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "MAAM,G;EACJ,G;;UAKQ,K;;;UAIA,I;;;aAIG,G;EACX,I;EACA,G;EAEA,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/enum.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/enum.test.ts new file mode 100644 index 0000000000000..bff3e94bdbb82 --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/enum.test.ts @@ -0,0 +1,111 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { run } from '../integration_helpers'; + +it('prints the whole enum, including comments', async () => { + const result = await run(` + /** + * This is an enum + */ + export enum Foo { + /** + * some comment + */ + x, + /** + * some other comment + */ + y, + /** + * some final comment + */ + z = 1, + } + `); + + expect(result.code).toMatchInlineSnapshot(` + "/** + * This is an enum + */ + export declare enum Foo { + /** + * some comment + */ + x = 0, + /** + * some other comment + */ + y = 1, + /** + * some final comment + */ + z = 1 + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); + +it(`handles export-type'd enums`, async () => { + const result = await run( + ` + export type { Foo } from './foo' + `, + { + otherFiles: { + ['foo.ts']: ` + export enum Foo { + x = 1, + y = 2, + z = 3, + } + `, + }, + } + ); + + expect(result.code).toMatchInlineSnapshot(` + "export declare enum Foo { + x = 1, + y = 2, + z = 3 + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ + 'packages/kbn-type-summarizer/__tmp__/dist_dts/foo.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' + ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts index 60f7d9489b00b..3d949a8ede6b2 100644 --- a/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts @@ -123,3 +123,123 @@ it('output links to default import from node modules', async () => { " `); }); + +it('handles symbols with multiple sources in node_modules', async () => { + const output = await run( + ` + export type { Moment } from 'foo'; + `, + { + otherFiles: { + ['node_modules/foo/index.d.ts']: ` + import mo = require('./foo'); + export = mo; + `, + ['node_modules/foo/foo.d.ts']: ` + import mo = require('mo'); + export = mo; + + declare module "mo" { + export interface Moment { + foo(): string + } + } + `, + ['node_modules/mo/index.d.ts']: ` + declare namespace mo { + interface Moment extends Object { + add(amount?: number, unit?: number): Moment; + } + } + + export = mo; + export as namespace mo; + `, + }, + } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import type { Moment } from 'foo'; + + export type { Moment }; + + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); + +it('deduplicates multiple imports to the same type', async () => { + const output = await run( + ` + export { Foo1 } from './foo1'; + export { Foo2 } from './foo2'; + export { Foo3 } from './foo3'; + `, + { + otherFiles: { + ...nodeModules, + ['foo1.ts']: ` + import { Foo } from 'foo'; + export class Foo1 extends Foo {} + `, + ['foo2.ts']: ` + import { Foo } from 'foo'; + export class Foo2 extends Foo {} + `, + ['foo3.ts']: ` + import { Foo } from 'foo'; + export class Foo3 extends Foo {} + `, + }, + } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import { Foo } from 'foo'; + + export class Foo1 extends Foo { + } + export class Foo2 extends Foo { + } + export class Foo3 extends Foo { + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": ";;aACa,I;;aCAA,I;;aCAA,I", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "foo1.ts", + "foo2.ts", + "foo3.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ + 'packages/kbn-type-summarizer/__tmp__/dist_dts/foo1.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/foo2.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/foo3.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' + ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.ts index cbccbfb1d77dc..de0451f908d37 100644 --- a/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.ts +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/interface.test.ts @@ -60,3 +60,46 @@ it('prints the whole interface, including comments', async () => { " `); }); + +it(`handles export-type'd interfaces`, async () => { + const result = await run( + ` + export type { Foo } from './foo' + `, + { + otherFiles: { + ['foo.ts']: ` + export interface Foo { + name: string + } + `, + }, + } + ); + + expect(result.code).toMatchInlineSnapshot(` + "export interface Foo { + name: string; + } + //# sourceMappingURL=index.d.ts.map" + `); + expect(result.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "iBAAiB,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "foo.ts", + ], + "version": 3, + } + `); + expect(result.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ + 'packages/kbn-type-summarizer/__tmp__/dist_dts/foo.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' + ] + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/literals.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/literals.test.ts new file mode 100644 index 0000000000000..1fa965a34a4ab --- /dev/null +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/literals.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { run } from '../integration_helpers'; + +it('prints literal number types', async () => { + const output = await run(` + export const NUM = 3; + const NUM2 = 4; + export type PoN = Promise; + `); + + expect(output.code).toMatchInlineSnapshot(` + "export const NUM = 3; + declare const NUM2 = 4; + export type PoN = Promise + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "aAAa,G;cACP,I;YACM,G", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "index.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + debug Ignoring 5 global declarations for \\"Promise\\" + " + `); +}); diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.ts index cbe99c54ca042..e4c2c6e355467 100644 --- a/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.ts +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/type_alias.test.ts @@ -40,3 +40,42 @@ it('prints basic type alias', async () => { " `); }); + +it(`prints export type'd type alias`, async () => { + const output = await run( + ` + export type { Name } from './name' + `, + { + otherFiles: { + ['name.ts']: ` + export type Name = 'foo'; + `, + }, + } + ); + + expect(output.code).toMatchInlineSnapshot(` + "export type Name = 'foo' + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "YAAY,I", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [ + "name.ts", + ], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ + 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts', + 'packages/kbn-type-summarizer/__tmp__/dist_dts/name.d.ts' + ] + " + `); +}); From a1085f4b753c8ec46258f1111ecd179b02d4c355 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 21 Mar 2022 17:12:01 -0500 Subject: [PATCH 021/132] Revert "[Status service] log plugin status changes (#126320)" (#128096) This reverts commit 6e11beadcfbba53b98456e2bbfe0add464d374aa. --- .../server/status/log_plugins_status.test.ts | 332 ------------------ src/core/server/status/log_plugins_status.ts | 104 ------ .../status/status_service.test.mocks.ts | 19 - src/core/server/status/status_service.test.ts | 109 +----- src/core/server/status/status_service.ts | 7 - 5 files changed, 3 insertions(+), 568 deletions(-) delete mode 100644 src/core/server/status/log_plugins_status.test.ts delete mode 100644 src/core/server/status/log_plugins_status.ts delete mode 100644 src/core/server/status/status_service.test.mocks.ts diff --git a/src/core/server/status/log_plugins_status.test.ts b/src/core/server/status/log_plugins_status.test.ts deleted file mode 100644 index e1be88fe1b188..0000000000000 --- a/src/core/server/status/log_plugins_status.test.ts +++ /dev/null @@ -1,332 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { TestScheduler } from 'rxjs/testing'; -import { PluginName } from '../plugins'; -import { ServiceStatus, ServiceStatusLevel, ServiceStatusLevels } from './types'; -import { - getPluginsStatusChanges, - getPluginsStatusDiff, - getServiceLevelChangeMessage, -} from './log_plugins_status'; - -type ObsInputType = Record; - -const getTestScheduler = () => - new TestScheduler((actual, expected) => { - expect(actual).toEqual(expected); - }); - -const createServiceStatus = (level: ServiceStatusLevel): ServiceStatus => ({ - level, - summary: 'summary', -}); - -const createPluginsStatuses = ( - input: Record -): Record => { - return Object.entries(input).reduce((output, [name, level]) => { - output[name] = createServiceStatus(level); - return output; - }, {} as Record); -}; - -describe('getPluginsStatusChanges', () => { - it('does not emit on first plugins$ emission', () => { - getTestScheduler().run(({ expectObservable, hot }) => { - const statuses = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - }); - - const overall$ = hot('-a', { - a: statuses, - }); - const stop$ = hot(''); - const expected = '--'; - - expectObservable(getPluginsStatusChanges(overall$, stop$, 1)).toBe(expected); - }); - }); - - it('does not emit if statuses do not change', () => { - getTestScheduler().run(({ expectObservable, hot }) => { - const statuses = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - }); - - const overall$ = hot('-a-b', { - a: statuses, - b: statuses, - }); - const stop$ = hot(''); - const expected = '----'; - - expectObservable(getPluginsStatusChanges(overall$, stop$, 1)).toBe(expected); - }); - }); - - it('emits if any plugin status changes', () => { - getTestScheduler().run(({ expectObservable, hot }) => { - const statusesA = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - }); - const statusesB = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.available, - }); - - const overall$ = hot('-a-b', { - a: statusesA, - b: statusesB, - }); - const stop$ = hot(''); - const expected = '---a'; - - expectObservable(getPluginsStatusChanges(overall$, stop$, 1)).toBe(expected, { - a: [ - { - previousLevel: 'degraded', - nextLevel: 'available', - impactedServices: ['pluginB'], - }, - ], - }); - }); - }); - - it('emits everytime any plugin status changes', () => { - getTestScheduler().run(({ expectObservable, hot }) => { - const availableStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - }); - const degradedStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.degraded, - }); - - const overall$ = hot('-a-b-c-d', { - a: availableStatus, - b: degradedStatus, - c: degradedStatus, - d: availableStatus, - }); - const stop$ = hot(''); - const expected = '---a---b'; - - expectObservable(getPluginsStatusChanges(overall$, stop$, 1)).toBe(expected, { - a: [ - { - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA'], - }, - ], - b: [ - { - previousLevel: 'degraded', - nextLevel: 'available', - impactedServices: ['pluginA'], - }, - ], - }); - }); - }); - - it('throttle events', () => { - getTestScheduler().run(({ expectObservable, hot }) => { - const statusesA = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - }); - const statusesB = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.available, - }); - const statusesC = createPluginsStatuses({ - pluginA: ServiceStatusLevels.degraded, - pluginB: ServiceStatusLevels.available, - }); - - const overall$ = hot('-a-b--c', { - a: statusesA, - b: statusesB, - c: statusesC, - }); - const stop$ = hot(''); - const expected = '------a'; - - expectObservable(getPluginsStatusChanges(overall$, stop$, 5)).toBe(expected, { - a: [ - { - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA'], - }, - { - previousLevel: 'degraded', - nextLevel: 'available', - impactedServices: ['pluginB'], - }, - ], - }); - }); - }); - - it('stops emitting once `stop$` emits', () => { - getTestScheduler().run(({ expectObservable, hot }) => { - const statusesA = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - }); - const statusesB = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.available, - }); - const statusesC = createPluginsStatuses({ - pluginA: ServiceStatusLevels.degraded, - pluginB: ServiceStatusLevels.available, - }); - - const overall$ = hot('-a-b-c', { - a: statusesA, - b: statusesB, - c: statusesC, - }); - const stop$ = hot('----(s|)'); - const expected = '---a|'; - - expectObservable(getPluginsStatusChanges(overall$, stop$, 1)).toBe(expected, { - a: [ - { - previousLevel: 'degraded', - nextLevel: 'available', - impactedServices: ['pluginB'], - }, - ], - }); - }); - }); -}); - -describe('getPluginsStatusDiff', () => { - it('returns an empty list if level is the same for all plugins', () => { - const previousStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - pluginC: ServiceStatusLevels.unavailable, - }); - - const nextStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - pluginC: ServiceStatusLevels.unavailable, - }); - - const result = getPluginsStatusDiff(previousStatus, nextStatus); - - expect(result).toEqual([]); - }); - - it('returns an single entry if only one status changed', () => { - const previousStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - pluginC: ServiceStatusLevels.unavailable, - }); - - const nextStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.degraded, - pluginB: ServiceStatusLevels.degraded, - pluginC: ServiceStatusLevels.unavailable, - }); - - const result = getPluginsStatusDiff(previousStatus, nextStatus); - - expect(result).toEqual([ - { - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA'], - }, - ]); - }); - - it('groups plugins by previous and next level tuples', () => { - const previousStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.available, - pluginC: ServiceStatusLevels.unavailable, - }); - - const nextStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.degraded, - pluginB: ServiceStatusLevels.degraded, - pluginC: ServiceStatusLevels.unavailable, - }); - - const result = getPluginsStatusDiff(previousStatus, nextStatus); - - expect(result).toEqual([ - { - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA', 'pluginB'], - }, - ]); - }); - - it('returns one entry per previous and next level tuples', () => { - const previousStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.available, - pluginB: ServiceStatusLevels.degraded, - pluginC: ServiceStatusLevels.unavailable, - }); - - const nextStatus = createPluginsStatuses({ - pluginA: ServiceStatusLevels.degraded, - pluginB: ServiceStatusLevels.unavailable, - pluginC: ServiceStatusLevels.available, - }); - - const result = getPluginsStatusDiff(previousStatus, nextStatus); - - expect(result).toEqual([ - { - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA'], - }, - { - previousLevel: 'degraded', - nextLevel: 'unavailable', - impactedServices: ['pluginB'], - }, - { - previousLevel: 'unavailable', - nextLevel: 'available', - impactedServices: ['pluginC'], - }, - ]); - }); -}); - -describe('getServiceLevelChangeMessage', () => { - it('returns a human readable message about the change', () => { - expect( - getServiceLevelChangeMessage({ - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA', 'pluginB'], - }) - ).toMatchInlineSnapshot( - `"2 plugins changed status from 'available' to 'degraded': pluginA, pluginB"` - ); - }); -}); diff --git a/src/core/server/status/log_plugins_status.ts b/src/core/server/status/log_plugins_status.ts deleted file mode 100644 index 5b5d0d84efc4d..0000000000000 --- a/src/core/server/status/log_plugins_status.ts +++ /dev/null @@ -1,104 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { isDeepStrictEqual } from 'util'; -import { Observable, asyncScheduler } from 'rxjs'; -import { - distinctUntilChanged, - pairwise, - takeUntil, - map, - filter, - throttleTime, -} from 'rxjs/operators'; -import { PluginName } from '../plugins'; -import { ServiceStatus } from './types'; - -export type ServiceStatusWithName = ServiceStatus & { - name: PluginName; -}; - -export interface ServiceLevelChange { - previousLevel: string; - nextLevel: string; - impactedServices: string[]; -} - -export const getPluginsStatusChanges = ( - plugins$: Observable>, - stop$: Observable, - throttleDuration: number = 250 -): Observable => { - return plugins$.pipe( - takeUntil(stop$), - distinctUntilChanged((previous, next) => - isDeepStrictEqual(getStatusLevelMap(previous), getStatusLevelMap(next)) - ), - throttleTime(throttleDuration, asyncScheduler, { leading: true, trailing: true }), - pairwise(), - map(([oldStatus, newStatus]) => { - return getPluginsStatusDiff(oldStatus, newStatus); - }), - filter((statusChanges) => statusChanges.length > 0) - ); -}; - -const getStatusLevelMap = ( - plugins: Record -): Record => { - return Object.entries(plugins).reduce((levelMap, [key, value]) => { - levelMap[key] = value.level.toString(); - return levelMap; - }, {} as Record); -}; - -export const getPluginsStatusDiff = ( - previous: Record, - next: Record -): ServiceLevelChange[] => { - const statusChanges: Map = new Map(); - - Object.entries(next).forEach(([pluginName, nextStatus]) => { - const previousStatus = previous[pluginName]; - if (!previousStatus) { - return; - } - const previousLevel = statusLevel(previousStatus); - const nextLevel = statusLevel(nextStatus); - if (previousLevel === nextLevel) { - return; - } - const changeKey = statusChangeKey(previousLevel, nextLevel); - let statusChange = statusChanges.get(changeKey); - if (!statusChange) { - statusChange = { - previousLevel, - nextLevel, - impactedServices: [], - }; - statusChanges.set(changeKey, statusChange); - } - statusChange.impactedServices.push(pluginName); - }); - - return [...statusChanges.values()]; -}; - -export const getServiceLevelChangeMessage = ({ - impactedServices: services, - nextLevel: next, - previousLevel: previous, -}: ServiceLevelChange): string => { - return `${ - services.length - } plugins changed status from '${previous}' to '${next}': ${services.join(', ')}`; -}; - -const statusLevel = (status: ServiceStatus) => status.level.toString(); - -const statusChangeKey = (previous: string, next: string) => `${previous}:${next}`; diff --git a/src/core/server/status/status_service.test.mocks.ts b/src/core/server/status/status_service.test.mocks.ts deleted file mode 100644 index 8b860d8355fc6..0000000000000 --- a/src/core/server/status/status_service.test.mocks.ts +++ /dev/null @@ -1,19 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export const getOverallStatusChangesMock = jest.fn(); -jest.doMock('./log_overall_status', () => ({ - getOverallStatusChanges: getOverallStatusChangesMock, -})); - -export const getPluginsStatusChangesMock = jest.fn(); -export const getServiceLevelChangeMessageMock = jest.fn(); -jest.doMock('./log_plugins_status', () => ({ - getPluginsStatusChanges: getPluginsStatusChangesMock, - getServiceLevelChangeMessage: getServiceLevelChangeMessageMock, -})); diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 0b6fbb601c40f..dfd0ff9a7e103 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -6,13 +6,7 @@ * Side Public License, v 1. */ -import { - getOverallStatusChangesMock, - getPluginsStatusChangesMock, - getServiceLevelChangeMessageMock, -} from './status_service.test.mocks'; - -import { of, BehaviorSubject, Subject } from 'rxjs'; +import { of, BehaviorSubject } from 'rxjs'; import { ServiceStatus, ServiceStatusLevels, CoreStatus } from './types'; import { StatusService } from './status_service'; @@ -25,27 +19,14 @@ import { mockRouter, RouterMock } from '../http/router/router.mock'; import { metricsServiceMock } from '../metrics/metrics_service.mock'; import { configServiceMock } from '../config/mocks'; import { coreUsageDataServiceMock } from '../core_usage_data/core_usage_data_service.mock'; -import { loggingSystemMock } from '../logging/logging_system.mock'; -import type { ServiceLevelChange } from './log_plugins_status'; expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer); describe('StatusService', () => { let service: StatusService; - let logger: ReturnType; beforeEach(() => { - logger = loggingSystemMock.create(); - service = new StatusService(mockCoreContext.create({ logger })); - - getOverallStatusChangesMock.mockReturnValue({ subscribe: jest.fn() }); - getPluginsStatusChangesMock.mockReturnValue({ subscribe: jest.fn() }); - }); - - afterEach(() => { - getOverallStatusChangesMock.mockReset(); - getPluginsStatusChangesMock.mockReset(); - getServiceLevelChangeMessageMock.mockReset(); + service = new StatusService(mockCoreContext.create()); }); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -64,7 +45,7 @@ describe('StatusService', () => { }; type SetupDeps = Parameters[0]; - const setupDeps = (overrides: Partial = {}): SetupDeps => { + const setupDeps = (overrides: Partial): SetupDeps => { return { elasticsearch: { status$: of(available), @@ -555,88 +536,4 @@ describe('StatusService', () => { }); }); }); - - describe('start', () => { - it('calls getOverallStatusChanges and subscribe to the returned observable', async () => { - const mockSubscribe = jest.fn(); - getOverallStatusChangesMock.mockReturnValue({ - subscribe: mockSubscribe, - }); - - await service.setup(setupDeps()); - await service.start(); - - expect(getOverallStatusChangesMock).toHaveBeenCalledTimes(1); - expect(mockSubscribe).toHaveBeenCalledTimes(1); - }); - - it('logs a message everytime the getOverallStatusChangesMock observable emits', async () => { - const subject = new Subject(); - getOverallStatusChangesMock.mockReturnValue(subject); - - await service.setup(setupDeps()); - await service.start(); - - subject.next('some message'); - subject.next('another message'); - - const log = logger.get(); - - expect(log.info).toHaveBeenCalledTimes(2); - expect(log.info).toHaveBeenCalledWith('some message'); - expect(log.info).toHaveBeenCalledWith('another message'); - }); - - it('calls getPluginsStatusChanges and subscribe to the returned observable', async () => { - const mockSubscribe = jest.fn(); - getPluginsStatusChangesMock.mockReturnValue({ - subscribe: mockSubscribe, - }); - - await service.setup(setupDeps()); - await service.start(); - - expect(getPluginsStatusChangesMock).toHaveBeenCalledTimes(1); - expect(mockSubscribe).toHaveBeenCalledTimes(1); - }); - - it('logs messages everytime the getPluginsStatusChangesMock observable emits', async () => { - const subject = new Subject(); - getPluginsStatusChangesMock.mockReturnValue(subject); - - getServiceLevelChangeMessageMock.mockImplementation( - ({ - impactedServices: services, - nextLevel: next, - previousLevel: previous, - }: ServiceLevelChange) => { - return `${previous}-${next}-${services[0]}`; - } - ); - - await service.setup(setupDeps()); - await service.start(); - - subject.next([ - { - previousLevel: 'available', - nextLevel: 'degraded', - impactedServices: ['pluginA'], - }, - ]); - subject.next([ - { - previousLevel: 'degraded', - nextLevel: 'available', - impactedServices: ['pluginB'], - }, - ]); - - const log = logger.get(); - - expect(log.info).toHaveBeenCalledTimes(2); - expect(log.info).toHaveBeenCalledWith('available-degraded-pluginA'); - expect(log.info).toHaveBeenCalledWith('degraded-available-pluginB'); - }); - }); }); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index be64b2558acd2..63a1b02d5b2e7 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -27,7 +27,6 @@ import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types'; import { getSummaryStatus } from './get_summary_status'; import { PluginsStatusService } from './plugins_status'; import { getOverallStatusChanges } from './log_overall_status'; -import { getPluginsStatusChanges, getServiceLevelChangeMessage } from './log_plugins_status'; interface StatusLogMeta extends LogMeta { kibana: { status: ServiceStatus }; @@ -166,12 +165,6 @@ export class StatusService implements CoreService { getOverallStatusChanges(this.overall$, this.stop$).subscribe((message) => { this.logger.info(message); }); - - getPluginsStatusChanges(this.pluginsStatus.getAll$(), this.stop$).subscribe((statusChanges) => { - statusChanges.forEach((statusChange) => { - this.logger.info(getServiceLevelChangeMessage(statusChange)); - }); - }); } public stop() { From bdecf1568e0f53c6f46f5decda554d79a9b95d7d Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Mon, 21 Mar 2022 15:37:30 -0700 Subject: [PATCH 022/132] [Security Solution][Alerts] EQL rules fallback to @timestamp if timestamp override doesn't exist (#127989) * EQL rules fallback to @timestamp if timestamp override doesn't exist * Fix getEventCount test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../detection_engine/get_query_filter.test.ts | 193 +-------- .../detection_engine/get_query_filter.ts | 79 +--- .../signals/build_events_query.test.ts | 390 ++++++++++++------ .../signals/build_events_query.ts | 184 ++++++--- .../detection_engine/signals/executors/eql.ts | 2 +- .../threat_mapping/get_event_count.test.ts | 59 +-- .../security_and_spaces/tests/timestamps.ts | 22 + 7 files changed, 447 insertions(+), 482 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index a97a13b8aba38..09208f6a7fe3d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getQueryFilter, getAllFilters, buildEqlSearchRequest } from './get_query_filter'; +import { getQueryFilter, getAllFilters } from './get_query_filter'; import type { Filter } from '@kbn/es-query'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; @@ -1112,197 +1112,6 @@ describe('get_filter', () => { }); }); - describe('buildEqlSearchRequest', () => { - test('should build a basic request with time range', () => { - const request = buildEqlSearchRequest( - 'process where true', - ['testindex1', 'testindex2'], - 'now-5m', - 'now', - 100, - undefined, - [], - undefined - ); - expect(request).toEqual({ - allow_no_indices: true, - index: ['testindex1', 'testindex2'], - body: { - size: 100, - query: 'process where true', - filter: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'now', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - fields: [ - { - field: '*', - include_unmapped: true, - }, - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], - }, - }); - }); - - test('should build a request with timestamp and event category overrides', () => { - const request = buildEqlSearchRequest( - 'process where true', - ['testindex1', 'testindex2'], - 'now-5m', - 'now', - 100, - 'event.ingested', - [], - 'event.other_category' - ); - expect(request).toEqual({ - allow_no_indices: true, - index: ['testindex1', 'testindex2'], - body: { - event_category_field: 'event.other_category', - size: 100, - query: 'process where true', - filter: { - bool: { - filter: [ - { - range: { - 'event.ingested': { - gte: 'now-5m', - lte: 'now', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - fields: [ - { - field: '*', - include_unmapped: true, - }, - { - field: 'event.ingested', - format: 'strict_date_optional_time', - }, - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], - }, - }); - }); - - test('should build a request with exceptions', () => { - const request = buildEqlSearchRequest( - 'process where true', - ['testindex1', 'testindex2'], - 'now-5m', - 'now', - 100, - undefined, - [getExceptionListItemSchemaMock()], - undefined - ); - expect(request).toEqual({ - allow_no_indices: true, - index: ['testindex1', 'testindex2'], - body: { - size: 100, - query: 'process where true', - filter: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'now', - format: 'strict_date_optional_time', - }, - }, - }, - { - bool: { - must_not: { - bool: { - should: [ - { - bool: { - filter: [ - { - nested: { - path: 'some.parentField', - query: { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.parentField.nested.field': 'some value', - }, - }, - ], - }, - }, - score_mode: 'none', - }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.not.nested.field': 'some value', - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, - ], - }, - }, - fields: [ - { - field: '*', - include_unmapped: true, - }, - { - field: '@timestamp', - format: 'strict_date_optional_time', - }, - ], - }, - }); - }); - }); - describe('getAllFilters', () => { const exceptionsFilter = { meta: { alias: null, negate: false, disabled: false }, diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index e5fd1e8766ef7..e033dfb5b0177 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -12,13 +12,9 @@ import type { } from '@kbn/securitysolution-io-ts-list-types'; import { buildExceptionFilter } from '@kbn/securitysolution-list-utils'; import { Filter, EsQueryConfig, DataViewBase, buildEsQuery } from '@kbn/es-query'; -import { - EqlSearchRequest, - QueryDslQueryContainer, -} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ESBoolQuery } from '../typed_json'; -import { Query, Index, TimestampOverrideOrUndefined } from './schemas/common/schemas'; +import { Query, Index } from './schemas/common/schemas'; export const getQueryFilter = ( query: Query, @@ -61,76 +57,3 @@ export const getAllFilters = (filters: Filter[], exceptionFilter: Filter | undef return [...filters]; } }; - -export const buildEqlSearchRequest = ( - query: string, - index: string[], - from: string, - to: string, - size: number, - timestampOverride: TimestampOverrideOrUndefined, - exceptionLists: ExceptionListItemSchema[], - eventCategoryOverride: string | undefined -): EqlSearchRequest => { - const timestamp = timestampOverride ?? '@timestamp'; - - const defaultTimeFields = ['@timestamp']; - const timestamps = - timestampOverride != null ? [timestampOverride, ...defaultTimeFields] : defaultTimeFields; - const docFields = timestamps.map((tstamp) => ({ - field: tstamp, - format: 'strict_date_optional_time', - })); - - // Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value), - // allowing us to make 1024-item chunks of exception list items. - // Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a - // very conservative value. - const exceptionFilter = buildExceptionFilter({ - lists: exceptionLists, - excludeExceptions: true, - chunkSize: 1024, - }); - const requestFilter: QueryDslQueryContainer[] = [ - { - range: { - [timestamp]: { - gte: from, - lte: to, - format: 'strict_date_optional_time', - }, - }, - }, - ]; - if (exceptionFilter !== undefined) { - requestFilter.push({ - bool: { - must_not: { - bool: exceptionFilter.query?.bool, - }, - }, - }); - } - const fields = [ - { - field: '*', - include_unmapped: true, - }, - ...docFields, - ]; - return { - index, - allow_no_indices: true, - body: { - size, - query, - filter: { - bool: { - filter: requestFilter, - }, - }, - event_category_field: eventCategoryOverride, - fields, - }, - }; -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index e6e1212c46905..7a9afe04cfeb0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { buildEventsSearchQuery } from './build_events_query'; +import { buildEqlSearchRequest, buildEventsSearchQuery } from './build_events_query'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('create_signals', () => { test('it builds a now-5m up to today filter', () => { @@ -29,25 +30,12 @@ describe('create_signals', () => { filter: [ {}, { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'today', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - ], + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'today', + format: 'strict_date_optional_time', + }, }, }, { @@ -100,13 +88,22 @@ describe('create_signals', () => { {}, { bool: { - filter: [ + should: [ + { + range: { + 'event.ingested': { + gte: 'now-5m', + lte: 'today', + format: 'strict_date_optional_time', + }, + }, + }, { bool: { - should: [ + filter: [ { range: { - 'event.ingested': { + '@timestamp': { gte: 'now-5m', lte: 'today', format: 'strict_date_optional_time', @@ -115,33 +112,18 @@ describe('create_signals', () => { }, { bool: { - filter: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'today', - format: 'strict_date_optional_time', - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: 'event.ingested', - }, - }, - }, + must_not: { + exists: { + field: 'event.ingested', }, - ], + }, }, }, ], - minimum_should_match: 1, }, }, ], + minimum_should_match: 1, }, }, { @@ -204,25 +186,12 @@ describe('create_signals', () => { filter: [ {}, { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'today', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - ], + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'today', + format: 'strict_date_optional_time', + }, }, }, { @@ -275,25 +244,12 @@ describe('create_signals', () => { filter: [ {}, { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'today', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - ], + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'today', + format: 'strict_date_optional_time', + }, }, }, { @@ -345,25 +301,12 @@ describe('create_signals', () => { filter: [ {}, { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'today', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - ], + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'today', + format: 'strict_date_optional_time', + }, }, }, { @@ -422,25 +365,12 @@ describe('create_signals', () => { filter: [ {}, { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - range: { - '@timestamp': { - gte: 'now-5m', - lte: 'today', - format: 'strict_date_optional_time', - }, - }, - }, - ], - }, - }, - ], + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'today', + format: 'strict_date_optional_time', + }, }, }, { @@ -536,4 +466,226 @@ describe('create_signals', () => { }, }); }); + + describe('buildEqlSearchRequest', () => { + test('should build a basic request with time range', () => { + const request = buildEqlSearchRequest( + 'process where true', + ['testindex1', 'testindex2'], + 'now-5m', + 'now', + 100, + undefined, + [], + undefined + ); + expect(request).toEqual({ + allow_no_indices: true, + index: ['testindex1', 'testindex2'], + body: { + size: 100, + query: 'process where true', + filter: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'now', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, + ], + }, + }); + }); + + test('should build a request with timestamp and event category overrides', () => { + const request = buildEqlSearchRequest( + 'process where true', + ['testindex1', 'testindex2'], + 'now-5m', + 'now', + 100, + 'event.ingested', + [], + 'event.other_category' + ); + expect(request).toEqual({ + allow_no_indices: true, + index: ['testindex1', 'testindex2'], + body: { + event_category_field: 'event.other_category', + size: 100, + query: 'process where true', + filter: { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + range: { + 'event.ingested': { + lte: 'now', + gte: 'now-5m', + format: 'strict_date_optional_time', + }, + }, + }, + { + bool: { + filter: [ + { + range: { + '@timestamp': { + lte: 'now', + gte: 'now-5m', + format: 'strict_date_optional_time', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: 'event.ingested', + }, + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: 'event.ingested', + format: 'strict_date_optional_time', + }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, + ], + }, + }); + }); + + test('should build a request with exceptions', () => { + const request = buildEqlSearchRequest( + 'process where true', + ['testindex1', 'testindex2'], + 'now-5m', + 'now', + 100, + undefined, + [getExceptionListItemSchemaMock()], + undefined + ); + expect(request).toEqual({ + allow_no_indices: true, + index: ['testindex1', 'testindex2'], + body: { + size: 100, + query: 'process where true', + filter: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: 'now-5m', + lte: 'now', + format: 'strict_date_optional_time', + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + fields: [ + { + field: '*', + include_unmapped: true, + }, + { + field: '@timestamp', + format: 'strict_date_optional_time', + }, + ], + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index 5e559a176770d..1a664261215c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -5,9 +5,10 @@ * 2.0. */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { buildExceptionFilter } from '@kbn/securitysolution-list-utils'; import { isEmpty } from 'lodash'; import { TimestampOverrideOrUndefined } from '../../../../common/detection_engine/schemas/common/schemas'; - interface BuildEventsSearchQuery { aggregations?: Record; index: string[]; @@ -21,6 +22,70 @@ interface BuildEventsSearchQuery { trackTotalHits?: boolean; } +const buildTimeRangeFilter = ({ + to, + from, + timestampOverride, +}: { + to: string; + from: string; + timestampOverride?: string; +}): estypes.QueryDslQueryContainer => { + // If the timestampOverride is provided, documents must either populate timestampOverride with a timestamp in the range + // or must NOT populate the timestampOverride field at all and `@timestamp` must fall in the range. + // If timestampOverride is not provided, we simply use `@timestamp` + return timestampOverride != null + ? { + bool: { + minimum_should_match: 1, + should: [ + { + range: { + [timestampOverride]: { + lte: to, + gte: from, + format: 'strict_date_optional_time', + }, + }, + }, + { + bool: { + filter: [ + { + range: { + '@timestamp': { + lte: to, + gte: from, + format: 'strict_date_optional_time', + }, + }, + }, + { + bool: { + must_not: { + exists: { + field: timestampOverride, + }, + }, + }, + }, + ], + }, + }, + ], + }, + } + : { + range: { + '@timestamp': { + lte: to, + gte: from, + format: 'strict_date_optional_time', + }, + }, + }; +}; + export const buildEventsSearchQuery = ({ aggregations, index, @@ -41,59 +106,9 @@ export const buildEventsSearchQuery = ({ format: 'strict_date_optional_time', })); - const rangeFilter: estypes.QueryDslQueryContainer[] = - timestampOverride != null - ? [ - { - range: { - [timestampOverride]: { - lte: to, - gte: from, - format: 'strict_date_optional_time', - }, - }, - }, - { - bool: { - filter: [ - { - range: { - '@timestamp': { - lte: to, - gte: from, - format: 'strict_date_optional_time', - }, - }, - }, - { - bool: { - must_not: { - exists: { - field: timestampOverride, - }, - }, - }, - }, - ], - }, - }, - ] - : [ - { - range: { - '@timestamp': { - lte: to, - gte: from, - format: 'strict_date_optional_time', - }, - }, - }, - ]; + const rangeFilter = buildTimeRangeFilter({ to, from, timestampOverride }); - const filterWithTime: estypes.QueryDslQueryContainer[] = [ - filter, - { bool: { filter: [{ bool: { should: [...rangeFilter], minimum_should_match: 1 } }] } }, - ]; + const filterWithTime: estypes.QueryDslQueryContainer[] = [filter, rangeFilter]; const sort: estypes.Sort = []; if (timestampOverride) { @@ -151,3 +166,66 @@ export const buildEventsSearchQuery = ({ } return searchQuery; }; + +export const buildEqlSearchRequest = ( + query: string, + index: string[], + from: string, + to: string, + size: number, + timestampOverride: TimestampOverrideOrUndefined, + exceptionLists: ExceptionListItemSchema[], + eventCategoryOverride: string | undefined +): estypes.EqlSearchRequest => { + const defaultTimeFields = ['@timestamp']; + const timestamps = + timestampOverride != null ? [timestampOverride, ...defaultTimeFields] : defaultTimeFields; + const docFields = timestamps.map((tstamp) => ({ + field: tstamp, + format: 'strict_date_optional_time', + })); + + // Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value), + // allowing us to make 1024-item chunks of exception list items. + // Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a + // very conservative value. + const exceptionFilter = buildExceptionFilter({ + lists: exceptionLists, + excludeExceptions: true, + chunkSize: 1024, + }); + + const rangeFilter = buildTimeRangeFilter({ to, from, timestampOverride }); + const requestFilter: estypes.QueryDslQueryContainer[] = [rangeFilter]; + if (exceptionFilter !== undefined) { + requestFilter.push({ + bool: { + must_not: { + bool: exceptionFilter.query?.bool, + }, + }, + }); + } + const fields = [ + { + field: '*', + include_unmapped: true, + }, + ...docFields, + ]; + return { + index, + allow_no_indices: true, + body: { + size, + query, + filter: { + bool: { + filter: requestFilter, + }, + }, + event_category_field: eventCategoryOverride, + fields, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index 38905b427e6bf..5195f40da8010 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -13,7 +13,7 @@ import { AlertInstanceState, AlertServices, } from '../../../../../../alerting/server'; -import { buildEqlSearchRequest } from '../../../../../common/detection_engine/get_query_filter'; +import { buildEqlSearchRequest } from '../build_events_query'; import { hasLargeValueItem } from '../../../../../common/detection_engine/utils'; import { isOutdated } from '../../migrations/helpers'; import { getIndexVersion } from '../../routes/index/get_index_version'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.test.ts index bd10c4817e6dc..b2b397bf21683 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.test.ts @@ -33,25 +33,12 @@ describe('getEventCount', () => { filter: [ { bool: { must: [], filter: [], should: [], must_not: [] } }, { - bool: { - filter: [ - { - bool: { - should: [ - { - range: { - '@timestamp': { - lte: '2022-01-14T05:00:00.000Z', - gte: '2022-01-13T05:00:00.000Z', - format: 'strict_date_optional_time', - }, - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], + range: { + '@timestamp': { + lte: '2022-01-14T05:00:00.000Z', + gte: '2022-01-13T05:00:00.000Z', + format: 'strict_date_optional_time', + }, }, }, { match_all: {} }, @@ -84,40 +71,34 @@ describe('getEventCount', () => { { bool: { must: [], filter: [], should: [], must_not: [] } }, { bool: { - filter: [ + should: [ + { + range: { + 'event.ingested': { + lte: '2022-01-14T05:00:00.000Z', + gte: '2022-01-13T05:00:00.000Z', + format: 'strict_date_optional_time', + }, + }, + }, { bool: { - should: [ + filter: [ { range: { - 'event.ingested': { + '@timestamp': { lte: '2022-01-14T05:00:00.000Z', gte: '2022-01-13T05:00:00.000Z', format: 'strict_date_optional_time', }, }, }, - { - bool: { - filter: [ - { - range: { - '@timestamp': { - lte: '2022-01-14T05:00:00.000Z', - gte: '2022-01-13T05:00:00.000Z', - format: 'strict_date_optional_time', - }, - }, - }, - { bool: { must_not: { exists: { field: 'event.ingested' } } } }, - ], - }, - }, + { bool: { must_not: { exists: { field: 'event.ingested' } } } }, ], - minimum_should_match: 1, }, }, ], + minimum_should_match: 1, }, }, { match_all: {} }, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts index 9f0ed0028ab86..e0d539a3fe33b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/timestamps.ts @@ -279,6 +279,28 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOrderedByEventId.length).equal(2); }); + + it('should generate 2 signals when timestamp override does not exist', async () => { + const rule: EqlCreateSchema = { + ...getEqlRuleForSignalTesting(['myfa*']), + timestamp_override: 'event.fakeingestfield', + }; + const { id } = await createRule(supertest, log, rule); + + await waitForRuleSuccessOrStatus( + supertest, + log, + id, + RuleExecutionStatus['partial failure'] + ); + await sleep(5000); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsResponse = await getSignalsByIds(supertest, log, [id, id]); + const signals = signalsResponse.hits.hits.map((hit) => hit._source); + const signalsOrderedByEventId = orderBy(signals, 'signal.parent.id', 'asc'); + + expect(signalsOrderedByEventId.length).equal(2); + }); }); }); From 0b1425b9741199a3fff4563ee7780ea564698103 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Mon, 21 Mar 2022 18:55:30 -0400 Subject: [PATCH 023/132] [Alerting] Warn by default when rule schedule interval is set below the minimum configured. (#127498) * Changing structure of minimumScheduleInterval config * Updating rules client logic to follow enforce flag * Updating UI to use enforce value * Updating config key in functional tests * Fixes * Fixes * Updating help text * Wording suggestsion from PR review * Log warning instead of throwing an error if rule has default interval less than minimum * Updating default interval to be minimum if minimum is greater than hardcoded default * Fixing checks * Fixing tests * Fixing tests * Fixing config * Fixing checks Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/settings/alert-action-settings.asciidoc | 11 +- .../resources/base/bin/kibana-docker | 2 + x-pack/plugins/alerting/server/config.test.ts | 5 +- x-pack/plugins/alerting/server/config.ts | 12 +- x-pack/plugins/alerting/server/index.ts | 2 +- .../server/lib/get_rules_config.test.ts | 13 +- .../alerting/server/lib/get_rules_config.ts | 6 +- x-pack/plugins/alerting/server/plugin.test.ts | 8 +- x-pack/plugins/alerting/server/plugin.ts | 18 +-- .../server/rule_type_registry.test.ts | 49 ++++-- .../alerting/server/rule_type_registry.ts | 35 ++-- .../server/rules_client/rules_client.ts | 33 ++-- .../rules_client/tests/aggregate.test.ts | 2 +- .../server/rules_client/tests/create.test.ts | 70 +++++++- .../server/rules_client/tests/delete.test.ts | 2 +- .../server/rules_client/tests/disable.test.ts | 2 +- .../server/rules_client/tests/enable.test.ts | 2 +- .../server/rules_client/tests/find.test.ts | 2 +- .../server/rules_client/tests/get.test.ts | 2 +- .../tests/get_alert_state.test.ts | 2 +- .../tests/get_alert_summary.test.ts | 2 +- .../tests/get_execution_log.test.ts | 2 +- .../tests/list_alert_types.test.ts | 2 +- .../rules_client/tests/mute_all.test.ts | 2 +- .../rules_client/tests/mute_instance.test.ts | 2 +- .../server/rules_client/tests/resolve.test.ts | 2 +- .../rules_client/tests/unmute_all.test.ts | 2 +- .../tests/unmute_instance.test.ts | 2 +- .../server/rules_client/tests/update.test.ts | 151 +++++++++++++++++- .../rules_client/tests/update_api_key.test.ts | 2 +- .../rules_client_conflict_retries.test.ts | 2 +- .../server/rules_client_factory.test.ts | 20 +-- .../alerting/server/rules_client_factory.ts | 5 +- .../saved_objects/is_rule_exportable.test.ts | 4 +- x-pack/plugins/alerting/server/types.ts | 4 +- .../public/alerts/alert_form.test.tsx | 2 +- .../public/application/constants/index.ts | 2 +- .../application/lib/rule_api/rule_types.ts | 2 + .../rule_form/get_initial_interval.test.ts | 23 +++ .../rule_form/get_initial_interval.ts | 19 +++ .../sections/rule_form/rule_add.test.tsx | 38 ++++- .../sections/rule_form/rule_add.tsx | 15 +- .../sections/rule_form/rule_edit.test.tsx | 6 +- .../sections/rule_form/rule_errors.test.tsx | 18 ++- .../sections/rule_form/rule_errors.ts | 6 +- .../sections/rule_form/rule_form.test.tsx | 28 +++- .../sections/rule_form/rule_form.tsx | 72 ++++++--- .../rules_list/components/rules_list.test.tsx | 4 +- .../triggers_actions_ui/public/types.ts | 5 +- .../server/routes/config.test.ts | 4 +- .../server/routes/config.ts | 4 +- .../alerting_api_integration/common/config.ts | 2 +- .../functional_execution_context/config.ts | 2 +- x-pack/test/functional_with_es_ssl/config.ts | 2 +- x-pack/test/rule_registry/common/config.ts | 2 +- .../test/security_solution_cypress/config.ts | 2 +- 56 files changed, 586 insertions(+), 154 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.test.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.ts diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index c64aef3978e6e..8f365381f1b8e 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -195,12 +195,15 @@ For example, `20m`, `24h`, `7d`, `1w`. Default: `5m`. `xpack.alerting.cancelAlertsOnRuleTimeout`:: Specifies whether to skip writing alerts and scheduling actions if rule execution is cancelled due to timeout. Default: `true`. This setting can be overridden by individual rule types. -`xpack.alerting.minimumScheduleInterval`:: -Specifies the minimum interval allowed for the all rules. This minimum is enforced for all rules created or updated after the introduction of this setting. The time is formatted as: +`xpack.alerting.rules.minimumScheduleInterval.value`:: +Specifies the minimum schedule interval for rules. This minimum is applied to all rules created or updated after you set this value. The time is formatted as: + `[s,m,h,d]` + For example, `20m`, `24h`, `7d`. Default: `1m`. -`xpack.alerting.rules.execution.actions.max` -Specifies the maximum number of actions that a rule can trigger each time detection checks run. \ No newline at end of file +`xpack.alerting.rules.minimumScheduleInterval.enforce`:: +Specifies the behavior when a new or changed rule has a schedule interval less than the value defined in `xpack.alerting.rules.minimumScheduleInterval.value`. If `false`, rules with schedules less than the interval will be created but warnings will be logged. If `true`, rules with schedules less than the interval cannot be created. Default: `false`. + +`xpack.alerting.rules.execution.actions.max`:: +Specifies the maximum number of actions that a rule can trigger each time detection checks run. diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 41bfbf8bdd8cf..01d27a345378b 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -200,6 +200,8 @@ kibana_vars=( xpack.alerting.invalidateApiKeysTask.removalDelay xpack.alerting.defaultRuleTaskTimeout xpack.alerting.cancelAlertsOnRuleTimeout + xpack.alerting.rules.minimumScheduleInterval.value + xpack.alerting.rules.minimumScheduleInterval.enforce xpack.alerting.rules.execution.actions.max xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index c475e0267fd3f..cdb12b6755ada 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -22,13 +22,16 @@ describe('config validation', () => { "removalDelay": "1h", }, "maxEphemeralActionsPerAlert": 10, - "minimumScheduleInterval": "1m", "rules": Object { "execution": Object { "actions": Object { "max": 100000, }, }, + "minimumScheduleInterval": Object { + "enforce": false, + "value": "1m", + }, }, } `); diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index a638c4176d4af..d126fc4295050 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -14,6 +14,13 @@ const ruleTypeSchema = schema.object({ }); const rulesSchema = schema.object({ + minimumScheduleInterval: schema.object({ + value: schema.string({ + validate: validateDurationSchema, + defaultValue: '1m', + }), + enforce: schema.boolean({ defaultValue: false }), // if enforce is false, only warnings will be shown + }), execution: schema.object({ timeout: schema.maybe(schema.string({ validate: validateDurationSchema })), actions: schema.object({ @@ -37,11 +44,10 @@ export const configSchema = schema.object({ }), defaultRuleTaskTimeout: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), cancelAlertsOnRuleTimeout: schema.boolean({ defaultValue: true }), - minimumScheduleInterval: schema.string({ validate: validateDurationSchema, defaultValue: '1m' }), rules: rulesSchema, }); export type AlertingConfig = TypeOf; -export type PublicAlertingConfig = Pick; export type RulesConfig = TypeOf; -export type RuleTypeConfig = Omit; +export type RuleTypeConfig = Omit; +export type AlertingRulesConfig = Pick; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 343a4e1039918..49b65c678aa1f 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -35,7 +35,7 @@ export type { FindResult } from './rules_client'; export type { PublicAlert as Alert } from './alert'; export { parseDuration } from './lib'; export { getEsErrorMessage } from './lib/errors'; -export type { PublicAlertingConfig } from './config'; +export type { AlertingRulesConfig } from './config'; export { ReadOperations, AlertingAuthorizationFilterType, diff --git a/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts b/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts index 75c0d23a9f925..e8956bd065038 100644 --- a/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_rules_config.test.ts @@ -5,11 +5,15 @@ * 2.0. */ -import { getRulesConfig } from './get_rules_config'; +import { getExecutionConfigForRuleType } from './get_rules_config'; import { RulesConfig } from '../config'; const ruleTypeId = 'test-rule-type-id'; const config = { + minimumScheduleInterval: { + value: '2m', + enforce: false, + }, execution: { timeout: '1m', actions: { max: 1000 }, @@ -17,6 +21,7 @@ const config = { } as RulesConfig; const configWithRuleType = { + ...config, execution: { ...config.execution, ruleTypeOverrides: [ @@ -30,7 +35,7 @@ const configWithRuleType = { describe('get rules config', () => { test('returns the rule type specific config and keeps the default values that are not overwritten', () => { - expect(getRulesConfig({ config: configWithRuleType, ruleTypeId })).toEqual({ + expect(getExecutionConfigForRuleType({ config: configWithRuleType, ruleTypeId })).toEqual({ execution: { id: ruleTypeId, timeout: '1m', @@ -40,6 +45,8 @@ describe('get rules config', () => { }); test('returns the default config when there is no rule type specific config', () => { - expect(getRulesConfig({ config, ruleTypeId })).toEqual(config); + expect(getExecutionConfigForRuleType({ config, ruleTypeId })).toEqual({ + execution: config.execution, + }); }); }); diff --git a/x-pack/plugins/alerting/server/lib/get_rules_config.ts b/x-pack/plugins/alerting/server/lib/get_rules_config.ts index 1c6ca71b1f848..12f75254a5656 100644 --- a/x-pack/plugins/alerting/server/lib/get_rules_config.ts +++ b/x-pack/plugins/alerting/server/lib/get_rules_config.ts @@ -8,21 +8,21 @@ import { omit } from 'lodash'; import { RulesConfig, RuleTypeConfig } from '../config'; -export const getRulesConfig = ({ +export const getExecutionConfigForRuleType = ({ config, ruleTypeId, }: { config: RulesConfig; ruleTypeId: string; }): RuleTypeConfig => { - const ruleTypeConfig = config.execution.ruleTypeOverrides?.find( + const ruleTypeExecutionConfig = config.execution.ruleTypeOverrides?.find( (ruleType) => ruleType.id === ruleTypeId ); return { execution: { ...omit(config.execution, 'ruleTypeOverrides'), - ...ruleTypeConfig, + ...ruleTypeExecutionConfig, }, }; }; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 959642f153ee6..3f1737f6e1fdf 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -31,8 +31,8 @@ const generateAlertingConfig = (): AlertingConfig => ({ maxEphemeralActionsPerAlert: 10, defaultRuleTaskTimeout: '5m', cancelAlertsOnRuleTimeout: true, - minimumScheduleInterval: '1m', rules: { + minimumScheduleInterval: { value: '1m', enforce: false }, execution: { actions: { max: 1000, @@ -115,13 +115,16 @@ describe('Alerting Plugin', () => { const setupContract = await plugin.setup(setupMocks, mockPlugins); - expect(setupContract.getConfig()).toEqual({ minimumScheduleInterval: '1m' }); + expect(setupContract.getConfig()).toEqual({ + minimumScheduleInterval: { value: '1m', enforce: false }, + }); }); it(`applies the default config if there is no rule type specific config `, async () => { const context = coreMock.createPluginInitializerContext({ ...generateAlertingConfig(), rules: { + minimumScheduleInterval: { value: '1m', enforce: false }, execution: { actions: { max: 123, @@ -147,6 +150,7 @@ describe('Alerting Plugin', () => { const context = coreMock.createPluginInitializerContext({ ...generateAlertingConfig(), rules: { + minimumScheduleInterval: { value: '1m', enforce: false }, execution: { actions: { max: 123 }, ruleTypeOverrides: [{ id: sampleRuleType.id, timeout: '1d' }], diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 9e5aad8fc3e27..1d8e8d4867c17 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -7,6 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { BehaviorSubject } from 'rxjs'; +import { pick } from 'lodash'; import { UsageCollectionSetup, UsageCounter } from 'src/plugins/usage_collection/server'; import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import { @@ -57,12 +58,12 @@ import { scheduleApiKeyInvalidatorTask, } from './invalidate_pending_api_keys/task'; import { scheduleAlertingHealthCheck, initializeAlertingHealth } from './health'; -import { AlertingConfig, PublicAlertingConfig } from './config'; +import { AlertingConfig, AlertingRulesConfig } from './config'; import { getHealth } from './health/get_health'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; import { AlertingAuthorization } from './authorization'; import { getSecurityHealth, SecurityHealth } from './lib/get_security_health'; -import { getRulesConfig } from './lib/get_rules_config'; +import { getExecutionConfigForRuleType } from './lib/get_rules_config'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { @@ -99,7 +100,7 @@ export interface PluginSetupContract { > ): void; getSecurityHealth: () => Promise; - getConfig: () => PublicAlertingConfig; + getConfig: () => AlertingRulesConfig; } export interface PluginStartContract { @@ -198,11 +199,12 @@ export class AlertingPlugin { plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS)); const ruleTypeRegistry = new RuleTypeRegistry({ + logger: this.logger, taskManager: plugins.taskManager, taskRunnerFactory: this.taskRunnerFactory, licenseState: this.licenseState, licensing: plugins.licensing, - minimumScheduleInterval: this.config.minimumScheduleInterval, + minimumScheduleInterval: this.config.rules.minimumScheduleInterval, }); this.ruleTypeRegistry = ruleTypeRegistry; @@ -288,7 +290,7 @@ export class AlertingPlugin { if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) { throw new Error(`"${ruleType.minimumLicenseRequired}" is not a valid license type`); } - ruleType.config = getRulesConfig({ + ruleType.config = getExecutionConfigForRuleType({ config: alertingConfig.rules, ruleTypeId: ruleType.id, }); @@ -310,9 +312,7 @@ export class AlertingPlugin { ); }, getConfig: () => { - return { - minimumScheduleInterval: alertingConfig.minimumScheduleInterval, - }; + return pick(alertingConfig.rules, 'minimumScheduleInterval'); }, }; } @@ -370,7 +370,7 @@ export class AlertingPlugin { kibanaVersion: this.kibanaVersion, authorization: alertingAuthorizationClientFactory, eventLogger: this.eventLogger, - minimumScheduleInterval: this.config.minimumScheduleInterval, + minimumScheduleInterval: this.config.rules.minimumScheduleInterval, }); const getRulesClientWithRequest = (request: KibanaRequest) => { diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index 163a6bbed9d33..34dca1faa79ca 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -12,6 +12,9 @@ import { taskManagerMock } from '../../task_manager/server/mocks'; import { ILicenseState } from './lib/license_state'; import { licenseStateMock } from './lib/license_state.mock'; import { licensingMock } from '../../licensing/server/mocks'; +import { loggingSystemMock } from 'src/core/server/mocks'; + +const logger = loggingSystemMock.create().get(); let mockedLicenseState: jest.Mocked; let ruleTypeRegistryParams: ConstructorOptions; @@ -21,11 +24,12 @@ beforeEach(() => { jest.resetAllMocks(); mockedLicenseState = licenseStateMock.create(); ruleTypeRegistryParams = { + logger, taskManager, taskRunnerFactory: new TaskRunnerFactory(), licenseState: mockedLicenseState, licensing: licensingMock.createSetup(), - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }; }); @@ -192,7 +196,7 @@ describe('Create Lifecycle', () => { ); }); - test('throws if defaultScheduleInterval is less than configured minimumScheduleInterval', () => { + test('logs warning if defaultScheduleInterval is less than configured minimumScheduleInterval and enforce = false', () => { const ruleType: RuleType = { id: '123', name: 'Test', @@ -202,24 +206,49 @@ describe('Create Lifecycle', () => { name: 'Default', }, ], - defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', isExportable: true, executor: jest.fn(), producer: 'alerts', defaultScheduleInterval: '10s', - config: { - execution: { - actions: { max: 1000 }, - }, - }, }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); + registry.register(ruleType); - expect(() => registry.register(ruleType)).toThrowError( - new Error(`Rule type \"123\" cannot specify a default interval less than 1m.`) + expect(logger.warn).toHaveBeenCalledWith( + `Rule type "123" has a default interval of "10s", which is less than the configured minimum of "1m".` + ); + }); + + test('logs warning and updates default if defaultScheduleInterval is less than configured minimumScheduleInterval and enforce = true', () => { + const ruleType: RuleType = { + id: '123', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: jest.fn(), + producer: 'alerts', + defaultScheduleInterval: '10s', + }; + const registry = new RuleTypeRegistry({ + ...ruleTypeRegistryParams, + minimumScheduleInterval: { value: '1m', enforce: true }, + }); + registry.register(ruleType); + + expect(logger.warn).toHaveBeenCalledWith( + `Rule type "123" cannot specify a default interval less than the configured minimum of "1m". "1m" will be used.` ); + expect(registry.get('123').defaultScheduleInterval).toEqual('1m'); }); test('throws if RuleType action groups contains reserved group id', () => { diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index 9e7bca9e94876..8aabd383e38b3 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import typeDetect from 'type-detect'; import { intersection } from 'lodash'; +import { Logger } from 'kibana/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { RunContext, TaskManagerSetupContract } from '../../task_manager/server'; import { TaskRunnerFactory } from './task_runner'; @@ -30,13 +31,15 @@ import { } from '../common'; import { ILicenseState } from './lib/license_state'; import { getRuleTypeFeatureUsageName } from './lib/get_rule_type_feature_usage_name'; +import { AlertingRulesConfig } from '.'; export interface ConstructorOptions { + logger: Logger; taskManager: TaskManagerSetupContract; taskRunnerFactory: TaskRunnerFactory; licenseState: ILicenseState; licensing: LicensingPluginSetup; - minimumScheduleInterval: string; + minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval']; } export interface RegistryRuleType @@ -126,20 +129,23 @@ export type UntypedNormalizedRuleType = NormalizedRuleType< >; export class RuleTypeRegistry { + private readonly logger: Logger; private readonly taskManager: TaskManagerSetupContract; private readonly ruleTypes: Map = new Map(); private readonly taskRunnerFactory: TaskRunnerFactory; private readonly licenseState: ILicenseState; - private readonly minimumScheduleInterval: string; + private readonly minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval']; private readonly licensing: LicensingPluginSetup; constructor({ + logger, taskManager, taskRunnerFactory, licenseState, licensing, minimumScheduleInterval, }: ConstructorOptions) { + this.logger = logger; this.taskManager = taskManager; this.taskRunnerFactory = taskRunnerFactory; this.licenseState = licenseState; @@ -220,21 +226,18 @@ export class RuleTypeRegistry { } const defaultIntervalInMs = parseDuration(ruleType.defaultScheduleInterval); - const minimumIntervalInMs = parseDuration(this.minimumScheduleInterval); + const minimumIntervalInMs = parseDuration(this.minimumScheduleInterval.value); if (defaultIntervalInMs < minimumIntervalInMs) { - throw new Error( - i18n.translate( - 'xpack.alerting.ruleTypeRegistry.register.defaultTimeoutTooShortRuleTypeError', - { - defaultMessage: - 'Rule type "{id}" cannot specify a default interval less than {minimumInterval}.', - values: { - id: ruleType.id, - minimumInterval: this.minimumScheduleInterval, - }, - } - ) - ); + if (this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule type "${ruleType.id}" cannot specify a default interval less than the configured minimum of "${this.minimumScheduleInterval.value}". "${this.minimumScheduleInterval.value}" will be used.` + ); + ruleType.defaultScheduleInterval = this.minimumScheduleInterval.value; + } else { + this.logger.warn( + `Rule type "${ruleType.id}" has a default interval of "${ruleType.defaultScheduleInterval}", which is less than the configured minimum of "${this.minimumScheduleInterval.value}".` + ); + } } } diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 652afb1b92152..666617dcf3fd8 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -84,6 +84,7 @@ import { getModifiedSearch, modifyFilterKueryNode, } from './lib/mapped_params_utils'; +import { AlertingRulesConfig } from '../config'; import { formatExecutionLogResult, getExecutionLogAggregation, @@ -133,7 +134,7 @@ export interface ConstructorOptions { authorization: AlertingAuthorization; actionsAuthorization: ActionsAuthorization; ruleTypeRegistry: RuleTypeRegistry; - minimumScheduleInterval: string; + minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval']; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; spaceId?: string; namespace?: string; @@ -269,7 +270,7 @@ export class RulesClient { private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; private readonly authorization: AlertingAuthorization; private readonly ruleTypeRegistry: RuleTypeRegistry; - private readonly minimumScheduleInterval: string; + private readonly minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval']; private readonly minimumScheduleIntervalInMs: number; private readonly createAPIKey: (name: string) => Promise; private readonly getActionsClient: () => Promise; @@ -311,7 +312,7 @@ export class RulesClient { this.taskManager = taskManager; this.ruleTypeRegistry = ruleTypeRegistry; this.minimumScheduleInterval = minimumScheduleInterval; - this.minimumScheduleIntervalInMs = parseDuration(minimumScheduleInterval); + this.minimumScheduleIntervalInMs = parseDuration(minimumScheduleInterval.value); this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; this.authorization = authorization; this.createAPIKey = createAPIKey; @@ -370,9 +371,16 @@ export class RulesClient { // Validate that schedule interval is not less than configured minimum const intervalInMs = parseDuration(data.schedule.interval); if (intervalInMs < this.minimumScheduleIntervalInMs) { - throw Boom.badRequest( - `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval}` - ); + if (this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); + } else { + // just log warning but allow rule to be created + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + ); + } } // Extract saved object references for this rule @@ -1112,9 +1120,16 @@ export class RulesClient { // Validate that schedule interval is not less than configured minimum const intervalInMs = parseDuration(data.schedule.interval); if (intervalInMs < this.minimumScheduleIntervalInMs) { - throw Boom.badRequest( - `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval}` - ); + if (this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); + } else { + // just log warning but allow rule to be updated + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } } // Extract saved object references for this rule diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index cdcc143fa9e9f..aa910f4203f46 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -33,7 +33,7 @@ const rulesClientParams: jest.Mocked = { taskManager, ruleTypeRegistry, unsecuredSavedObjectsClient, - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, authorization: authorization as unknown as AlertingAuthorization, actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 2310d98d3b02d..df0e806e5e798 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -52,7 +52,7 @@ const rulesClientParams: jest.Mocked = { getEventLogClient: jest.fn(), kibanaVersion, auditLogger, - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }; beforeEach(() => { @@ -2546,7 +2546,73 @@ describe('create()', () => { expect(taskManager.schedule).not.toHaveBeenCalled(); }); - test('throws error when creating with an interval less than the minimum configured one', async () => { + test('logs warning when creating with an interval less than the minimum configured one when enforce = false', async () => { + const data = getMockData({ schedule: { interval: '1s' } }); + ruleTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: jest.fn(), + }, + })); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '1s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + await rulesClient.create({ data }); + expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( + `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + ); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); + expect(taskManager.schedule).toHaveBeenCalled(); + }); + + test('throws error when creating with an interval less than the minimum configured one when enforce = true', async () => { + rulesClient = new RulesClient({ + ...rulesClientParams, + minimumScheduleInterval: { value: '1m', enforce: true }, + }); ruleTypeRegistry.get.mockImplementation(() => ({ id: '123', name: 'Test', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts index 7c595ba500e64..db19346e34946 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts @@ -30,7 +30,7 @@ const rulesClientParams: jest.Mocked = { taskManager, ruleTypeRegistry, unsecuredSavedObjectsClient, - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, authorization: authorization as unknown as AlertingAuthorization, actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 0716cbf59b258..1ed8c5d77e567 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -42,7 +42,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index c932c845c3638..77ce4c7c49eb6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -36,7 +36,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 7dbd4953692a0..a803859f58af6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -37,7 +37,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts index dfdd44a38bca3..8ff06637b2efd 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts @@ -35,7 +35,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts index 18fa0278828ca..238dad71f6344 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts @@ -34,7 +34,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 68d05c80faab1..64f28096b788f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -40,7 +40,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index a55a3e57428bb..8ad904bacd5d3 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -41,7 +41,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/list_alert_types.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/list_alert_types.test.ts index 3b4c07a775eab..7a5d3863923fb 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/list_alert_types.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/list_alert_types.test.ts @@ -38,7 +38,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index 49bd8022cffe2..3d4b3a6ec3d03 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -34,7 +34,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts index 6122d794d064c..c0000c3ae6a43 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts @@ -34,7 +34,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts index dcd25f43ff611..3387d5a30bed1 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts @@ -35,7 +35,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index 8ed04caba62aa..1ff4c8be3df02 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -34,7 +34,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts index 5c0a67f98b8c2..3de2d654c9c5f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts @@ -34,7 +34,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index be2f859ac96b3..a087dfd436817 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -54,7 +54,7 @@ const rulesClientParams: jest.Mocked = { getEventLogClient: jest.fn(), kibanaVersion, auditLogger, - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }; beforeEach(() => { @@ -1809,7 +1809,154 @@ describe('update()', () => { expect(taskManager.schedule).not.toHaveBeenCalled(); }); - test('throws error when updating with an interval less than the minimum configured one', async () => { + test('logs warning when creating with an interval less than the minimum configured one when enforce = false', async () => { + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + }, + { + id: '2', + actionTypeId: 'test2', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + }, + ]); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + { + group: 'default', + actionRef: 'action_1', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + { + group: 'default', + actionRef: 'action_2', + actionTypeId: 'test2', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'action_1', + type: 'action', + id: '1', + }, + { + name: 'action_2', + type: 'action', + id: '2', + }, + ], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: '234', + createdAt: '2019-02-12T21:01:22.479Z', + }, + references: [], + }); + await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }, + }); + expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( + `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); + }); + + test('throws error when updating with an interval less than the minimum configured one when enforce = true', async () => { + rulesClient = new RulesClient({ + ...rulesClientParams, + minimumScheduleInterval: { value: '1m', enforce: true }, + }); await expect( rulesClient.update({ id: '1', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index b60f0185e0b57..9ce129f361a01 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -35,7 +35,7 @@ const rulesClientParams: jest.Mocked = { actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, spaceId: 'default', namespace: 'default', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, getUserName: jest.fn(), createAPIKey: jest.fn(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index f6b058f5bdb66..e476c84f73928 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -52,7 +52,7 @@ const rulesClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }; // this suite consists of two suites running tests against mutable RulesClient APIs: diff --git a/x-pack/plugins/alerting/server/rules_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index b59cff0f9986d..cf8e4604f01d9 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -35,7 +35,7 @@ const savedObjectsService = savedObjectsServiceMock.createInternalStartContract( const securityPluginSetup = securityMock.createSetup(); const securityPluginStart = securityMock.createStart(); -const alertsAuthorization = alertingAuthorizationMock.create(); +const alertingAuthorization = alertingAuthorizationMock.create(); const alertingAuthorizationClientFactory = alertingAuthorizationClientFactoryMock.createFactory(); const rulesClientFactoryParams: jest.Mocked = { @@ -44,7 +44,7 @@ const rulesClientFactoryParams: jest.Mocked = { ruleTypeRegistry: ruleTypeRegistryMock.create(), getSpaceId: jest.fn(), spaceIdToNamespace: jest.fn(), - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), actions: actionsMock.createStart(), eventLog: eventLogMock.createStart(), @@ -82,14 +82,14 @@ beforeEach(() => { rulesClientFactoryParams.spaceIdToNamespace.mockReturnValue('default'); }); -test('creates an alerts client with proper constructor arguments when security is enabled', async () => { +test('creates a rules client with proper constructor arguments when security is enabled', async () => { const factory = new RulesClientFactory(); factory.initialize({ securityPluginSetup, securityPluginStart, ...rulesClientFactoryParams }); const request = KibanaRequest.from(fakeRequest); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); alertingAuthorizationClientFactory.create.mockReturnValue( - alertsAuthorization as unknown as AlertingAuthorization + alertingAuthorization as unknown as AlertingAuthorization ); factory.create(request, savedObjectsService); @@ -107,7 +107,7 @@ test('creates an alerts client with proper constructor arguments when security i expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ unsecuredSavedObjectsClient: savedObjectsClient, - authorization: alertsAuthorization, + authorization: alertingAuthorization, actionsAuthorization, logger: rulesClientFactoryParams.logger, taskManager: rulesClientFactoryParams.taskManager, @@ -120,18 +120,18 @@ test('creates an alerts client with proper constructor arguments when security i createAPIKey: expect.any(Function), encryptedSavedObjectsClient: rulesClientFactoryParams.encryptedSavedObjectsClient, kibanaVersion: '7.10.0', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }); }); -test('creates an alerts client with proper constructor arguments', async () => { +test('creates a rules client with proper constructor arguments', async () => { const factory = new RulesClientFactory(); factory.initialize(rulesClientFactoryParams); const request = KibanaRequest.from(fakeRequest); savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); alertingAuthorizationClientFactory.create.mockReturnValue( - alertsAuthorization as unknown as AlertingAuthorization + alertingAuthorization as unknown as AlertingAuthorization ); factory.create(request, savedObjectsService); @@ -145,7 +145,7 @@ test('creates an alerts client with proper constructor arguments', async () => { expect(jest.requireMock('./rules_client').RulesClient).toHaveBeenCalledWith({ unsecuredSavedObjectsClient: savedObjectsClient, - authorization: alertsAuthorization, + authorization: alertingAuthorization, actionsAuthorization, logger: rulesClientFactoryParams.logger, taskManager: rulesClientFactoryParams.taskManager, @@ -158,7 +158,7 @@ test('creates an alerts client with proper constructor arguments', async () => { getActionsClient: expect.any(Function), getEventLogClient: expect.any(Function), kibanaVersion: '7.10.0', - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }); }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index 919902339e78f..19231030c4007 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -19,6 +19,7 @@ import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/serve import { TaskManagerStartContract } from '../../task_manager/server'; import { IEventLogClientService, IEventLogger } from '../../../plugins/event_log/server'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; +import { AlertingRulesConfig } from './config'; export interface RulesClientFactoryOpts { logger: Logger; taskManager: TaskManagerStartContract; @@ -33,7 +34,7 @@ export interface RulesClientFactoryOpts { kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; authorization: AlertingAuthorizationClientFactory; eventLogger?: IEventLogger; - minimumScheduleInterval: string; + minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval']; } export class RulesClientFactory { @@ -51,7 +52,7 @@ export class RulesClientFactory { private kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; private authorization!: AlertingAuthorizationClientFactory; private eventLogger?: IEventLogger; - private minimumScheduleInterval!: string; + private minimumScheduleInterval!: AlertingRulesConfig['minimumScheduleInterval']; public initialize(options: RulesClientFactoryOpts) { if (this.isInitialized) { diff --git a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts index d2310c26c49d2..9c99343b233dd 100644 --- a/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/is_rule_exportable.test.ts @@ -13,6 +13,7 @@ import { ILicenseState } from '../lib/license_state'; import { licenseStateMock } from '../lib/license_state.mock'; import { licensingMock } from '../../../licensing/server/mocks'; import { isRuleExportable } from './is_rule_exportable'; +import { loggingSystemMock } from 'src/core/server/mocks'; let ruleTypeRegistryParams: ConstructorOptions; let logger: MockedLogger; @@ -24,11 +25,12 @@ beforeEach(() => { mockedLicenseState = licenseStateMock.create(); logger = loggerMock.create(); ruleTypeRegistryParams = { + logger: loggingSystemMock.create().get(), taskManager, taskRunnerFactory: new TaskRunnerFactory(), licenseState: mockedLicenseState, licensing: licensingMock.createSetup(), - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }; }); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index b947ed93ecc50..8dcc403613577 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -42,7 +42,7 @@ import { AlertExecutionStatusWarningReasons, } from '../common'; import { LicenseType } from '../../licensing/server'; -import { RulesConfig } from './config'; +import { RuleTypeConfig } from './config'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; @@ -170,7 +170,7 @@ export interface RuleType< ruleTaskTimeout?: string; cancelAlertsOnRuleTimeout?: boolean; doesSetRecoveryContext?: boolean; - config?: RulesConfig; + config?: RuleTypeConfig; } export type UntypedRuleType = RuleType< AlertTypeParams, diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 5effe649bf21a..edabe65f90df3 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -135,7 +135,7 @@ describe('alert_form', () => { {}} errors={{ name: [], 'schedule.interval': [] }} operation="create" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index c69cbcfe8ac04..77282f834f9fd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -37,4 +37,4 @@ export enum SORT_ORDERS { export const DEFAULT_SEARCH_PAGE_SIZE: number = 10; -export const DEFAULT_ALERT_INTERVAL = '1m'; +export const DEFAULT_RULE_INTERVAL = '1m'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_types.ts index 2b96deaa0dfb1..cae0a10deb052 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_types.ts @@ -23,6 +23,7 @@ const rewriteBodyReq: RewriteRequestCase = ({ authorized_consumers: authorizedConsumers, rule_task_timeout: ruleTaskTimeout, does_set_recovery_context: doesSetRecoveryContext, + default_schedule_interval: defaultScheduleInterval, ...rest }: AsApiContract) => ({ enabledInLicense, @@ -34,6 +35,7 @@ const rewriteBodyReq: RewriteRequestCase = ({ authorizedConsumers, ruleTaskTimeout, doesSetRecoveryContext, + defaultScheduleInterval, ...rest, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.test.ts new file mode 100644 index 0000000000000..83c7fd93d13d2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { getInitialInterval } from './get_initial_interval'; +import { DEFAULT_RULE_INTERVAL } from '../../constants'; + +describe('getInitialInterval', () => { + test('should return DEFAULT_RULE_INTERVAL if minimumScheduleInterval is undefined', () => { + expect(getInitialInterval()).toEqual(DEFAULT_RULE_INTERVAL); + }); + + test('should return DEFAULT_RULE_INTERVAL if minimumScheduleInterval is smaller than or equal to default', () => { + expect(getInitialInterval('1m')).toEqual(DEFAULT_RULE_INTERVAL); + }); + + test('should return minimumScheduleInterval if minimumScheduleInterval is greater than default', () => { + expect(getInitialInterval('5m')).toEqual('5m'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.ts new file mode 100644 index 0000000000000..dcdc6c6431755 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/get_initial_interval.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DEFAULT_RULE_INTERVAL } from '../../constants'; +import { parseDuration } from '../../../../../alerting/common'; + +export function getInitialInterval(minimumScheduleInterval?: string) { + if (minimumScheduleInterval) { + // return minimum schedule interval if it is larger than the default + if (parseDuration(minimumScheduleInterval) > parseDuration(DEFAULT_RULE_INTERVAL)) { + return minimumScheduleInterval; + } + } + return DEFAULT_RULE_INTERVAL; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx index 2b34cc80ddb89..9c9d2da8f6545 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.test.tsx @@ -27,6 +27,7 @@ import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; import { ReactWrapper } from 'enzyme'; import { ALERTS_FEATURE_ID } from '../../../../../alerting/common'; import { useKibana } from '../../../common/lib/kibana'; +import { triggersActionsUiConfig } from '../../../common/lib/config_api'; jest.mock('../../../common/lib/kibana'); @@ -40,7 +41,7 @@ jest.mock('../../lib/rule_api', () => ({ })); jest.mock('../../../common/lib/config_api', () => ({ - triggersActionsUiConfig: jest.fn().mockResolvedValue({ minimumScheduleInterval: '1m' }), + triggersActionsUiConfig: jest.fn(), })); jest.mock('../../../common/lib/health_api', () => ({ @@ -175,6 +176,9 @@ describe('rule_add', () => { } it('renders rule add flyout', async () => { + (triggersActionsUiConfig as jest.Mock).mockResolvedValue({ + minimumScheduleInterval: { value: '1m', enforce: false }, + }); const onClose = jest.fn(); await setup({}, onClose); @@ -194,6 +198,9 @@ describe('rule_add', () => { }); it('renders rule add flyout with initial values', async () => { + (triggersActionsUiConfig as jest.Mock).mockResolvedValue({ + minimumScheduleInterval: { value: '1m', enforce: false }, + }); const onClose = jest.fn(); await setup( { @@ -207,13 +214,33 @@ describe('rule_add', () => { ); expect(wrapper.find('input#ruleName').props().value).toBe('Simple status rule'); - expect(wrapper.find('[data-test-subj="tagsComboBox"]').first().text()).toBe('uptimelogs'); + expect(wrapper.find('[data-test-subj="intervalInput"]').first().props().value).toEqual(1); + expect(wrapper.find('[data-test-subj="intervalInputUnit"]').first().props().value).toBe('h'); + }); + + it('renders rule add flyout with DEFAULT_RULE_INTERVAL if no initialValues specified and no minimumScheduleInterval', async () => { + (triggersActionsUiConfig as jest.Mock).mockResolvedValue({}); + await setup(); - expect(wrapper.find('.euiSelect').first().props().value).toBe('h'); + expect(wrapper.find('[data-test-subj="intervalInput"]').first().props().value).toEqual(1); + expect(wrapper.find('[data-test-subj="intervalInputUnit"]').first().props().value).toBe('m'); + }); + + it('renders rule add flyout with minimumScheduleInterval if minimumScheduleInterval is greater than DEFAULT_RULE_INTERVAL', async () => { + (triggersActionsUiConfig as jest.Mock).mockResolvedValue({ + minimumScheduleInterval: { value: '5m', enforce: false }, + }); + await setup(); + + expect(wrapper.find('[data-test-subj="intervalInput"]').first().props().value).toEqual(5); + expect(wrapper.find('[data-test-subj="intervalInputUnit"]').first().props().value).toBe('m'); }); it('emit an onClose event when the rule is saved', async () => { + (triggersActionsUiConfig as jest.Mock).mockResolvedValue({ + minimumScheduleInterval: { value: '1m', enforce: false }, + }); const onClose = jest.fn(); const rule = mockRule(); @@ -242,7 +269,10 @@ describe('rule_add', () => { expect(onClose).toHaveBeenCalledWith(RuleFlyoutCloseReason.SAVED); }); - it('should enforce any default inteval', async () => { + it('should enforce any default interval', async () => { + (triggersActionsUiConfig as jest.Mock).mockResolvedValue({ + minimumScheduleInterval: { value: '1m', enforce: false }, + }); await setup({ ruleTypeId: 'my-rule-type' }, jest.fn(), '3h'); // Wait for handlers to fire diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index 14f04d8872b94..2d6a7ec4bf63d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -33,8 +33,9 @@ import { HealthContextProvider } from '../../context/health_context'; import { useKibana } from '../../../common/lib/kibana'; import { hasRuleChanged, haveRuleParamsChanged } from './has_rule_changed'; import { getRuleWithInvalidatedFields } from '../../lib/value_validators'; -import { DEFAULT_ALERT_INTERVAL } from '../../constants'; +import { DEFAULT_RULE_INTERVAL } from '../../constants'; import { triggersActionsUiConfig } from '../../../common/lib/config_api'; +import { getInitialInterval } from './get_initial_interval'; const RuleAdd = ({ consumer, @@ -58,7 +59,7 @@ const RuleAdd = ({ consumer, ruleTypeId, schedule: { - interval: DEFAULT_ALERT_INTERVAL, + interval: DEFAULT_RULE_INTERVAL, }, actions: [], tags: [], @@ -146,6 +147,14 @@ const RuleAdd = ({ })(); }, [rule, actionTypeRegistry]); + useEffect(() => { + if (config.minimumScheduleInterval && !initialValues?.schedule?.interval) { + setRuleProperty('schedule', { + interval: getInitialInterval(config.minimumScheduleInterval.value), + }); + } + }, [config.minimumScheduleInterval, initialValues]); + useEffect(() => { if (rule.ruleTypeId && ruleTypeIndex) { const type = ruleTypeIndex.get(rule.ruleTypeId); @@ -156,7 +165,7 @@ const RuleAdd = ({ }, [rule.ruleTypeId, ruleTypeIndex, rule.schedule.interval, changedFromDefaultInterval]); useEffect(() => { - if (rule.schedule.interval !== DEFAULT_ALERT_INTERVAL && !changedFromDefaultInterval) { + if (rule.schedule.interval !== DEFAULT_RULE_INTERVAL && !changedFromDefaultInterval) { setChangedFromDefaultInterval(true); } }, [rule.schedule.interval, changedFromDefaultInterval]); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx index 9de3f5a87790e..9183ac6163b60 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.test.tsx @@ -36,7 +36,9 @@ jest.mock('../../lib/rule_api', () => ({ })); jest.mock('../../../common/lib/config_api', () => ({ - triggersActionsUiConfig: jest.fn().mockResolvedValue({ minimumScheduleInterval: '1m' }), + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), })); jest.mock('./rule_errors', () => ({ @@ -229,6 +231,6 @@ describe('rule_edit', () => { await setup(); const lastCall = getRuleErrors.mock.calls[getRuleErrors.mock.calls.length - 1]; expect(lastCall[2]).toBeDefined(); - expect(lastCall[2]).toEqual({ minimumScheduleInterval: '1m' }); + expect(lastCall[2]).toEqual({ minimumScheduleInterval: { value: '1m', enforce: false } }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx index 02af29ce422ba..00d29061003bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx @@ -17,7 +17,7 @@ import { import { Rule, RuleTypeModel } from '../../../types'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; -const config = { minimumScheduleInterval: '1m' }; +const config = { minimumScheduleInterval: { value: '1m', enforce: false } }; describe('rule_errors', () => { describe('validateBaseProperties()', () => { it('should validate the name', () => { @@ -44,10 +44,24 @@ describe('rule_errors', () => { }); }); - it('should validate the minimumScheduleInterval', () => { + it('should validate the minimumScheduleInterval if enforce = false', () => { const rule = mockRule(); rule.schedule.interval = '2s'; const result = validateBaseProperties(rule, config); + expect(result.errors).toStrictEqual({ + name: [], + 'schedule.interval': [], + ruleTypeId: [], + actionConnectors: [], + }); + }); + + it('should validate the minimumScheduleInterval if enforce = true', () => { + const rule = mockRule(); + rule.schedule.interval = '2s'; + const result = validateBaseProperties(rule, { + minimumScheduleInterval: { value: '1m', enforce: true }, + }); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': ['Interval must be at least 1 minute.'], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts index d8bcb49ad86de..7637eb010867b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts @@ -43,15 +43,15 @@ export function validateBaseProperties( defaultMessage: 'Check interval is required.', }) ); - } else if (config.minimumScheduleInterval) { + } else if (config.minimumScheduleInterval && config.minimumScheduleInterval.enforce) { const duration = parseDuration(ruleObject.schedule.interval); - const minimumDuration = parseDuration(config.minimumScheduleInterval); + const minimumDuration = parseDuration(config.minimumScheduleInterval.value); if (duration < minimumDuration) { errors['schedule.interval'].push( i18n.translate('xpack.triggersActionsUI.sections.ruleForm.error.belowMinimumText', { defaultMessage: 'Interval must be at least {minimum}.', values: { - minimum: formatDuration(config.minimumScheduleInterval, true), + minimum: formatDuration(config.minimumScheduleInterval.value, true), }, }) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx index a8c1b72c9e129..e14a9fcc21c4d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.test.tsx @@ -94,7 +94,7 @@ describe('rule_form', () => { describe('rule_form create rule', () => { let wrapper: ReactWrapper; - async function setup() { + async function setup(enforceMinimum = false, schedule = '1m') { const mocks = coreMock.createSetup(); const { useLoadRuleTypes } = jest.requireMock('../../hooks/use_load_rule_types'); const ruleTypes: RuleType[] = [ @@ -174,7 +174,7 @@ describe('rule_form', () => { params: {}, consumer: ALERTS_FEATURE_ID, schedule: { - interval: '1m', + interval: schedule, }, actions: [], tags: [], @@ -186,7 +186,7 @@ describe('rule_form', () => { wrapper = mountWithIntl( {}} errors={{ name: [], 'schedule.interval': [], ruleTypeId: [] }} operation="create" @@ -214,13 +214,27 @@ describe('rule_form', () => { expect(ruleTypeSelectOptions.exists()).toBeTruthy(); }); - it('renders minimum schedule interval', async () => { - await setup(); + it('renders minimum schedule interval helper text when enforce = true', async () => { + await setup(true); expect(wrapper.find('[data-test-subj="intervalFormRow"]').first().prop('helpText')).toEqual( `Interval must be at least 1 minute.` ); }); + it('renders minimum schedule interval helper suggestion when enforce = false and schedule is less than configuration', async () => { + await setup(false, '10s'); + expect(wrapper.find('[data-test-subj="intervalFormRow"]').first().prop('helpText')).toEqual( + `Intervals less than 1 minute are not recommended due to performance considerations.` + ); + }); + + it('does not render minimum schedule interval helper when enforce = false and schedule is greater than configuration', async () => { + await setup(); + expect(wrapper.find('[data-test-subj="intervalFormRow"]').first().prop('helpText')).toEqual( + `` + ); + }); + it('does not render registered rule type which non editable', async () => { await setup(); const ruleTypeSelectOptions = wrapper.find( @@ -368,7 +382,7 @@ describe('rule_form', () => { wrapper = mountWithIntl( {}} errors={{ name: [], 'schedule.interval': [], ruleTypeId: [] }} operation="create" @@ -431,7 +445,7 @@ describe('rule_form', () => { wrapper = mountWithIntl( {}} errors={{ name: [], 'schedule.interval': [], ruleTypeId: [] }} operation="create" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 9df4679afad26..d73a0a9dd9e36 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -41,6 +41,7 @@ import { formatDuration, getDurationNumberInItsUnit, getDurationUnitValue, + parseDuration, } from '../../../../../alerting/common/parse_duration'; import { RuleReducerAction, InitialRule } from './rule_reducer'; import { @@ -73,8 +74,8 @@ import { checkRuleTypeEnabled } from '../../lib/check_rule_type_enabled'; import { ruleTypeCompare, ruleTypeGroupCompare } from '../../lib/rule_type_compare'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { SectionLoading } from '../../components/section_loading'; -import { DEFAULT_ALERT_INTERVAL } from '../../constants'; import { useLoadRuleTypes } from '../../hooks/use_load_rule_types'; +import { getInitialInterval } from './get_initial_interval'; const ENTER_KEY = 13; @@ -97,9 +98,6 @@ interface RuleFormProps> { filteredSolutions?: string[] | undefined; } -const defaultScheduleInterval = getDurationNumberInItsUnit(DEFAULT_ALERT_INTERVAL); -const defaultScheduleIntervalUnit = getDurationUnitValue(DEFAULT_ALERT_INTERVAL); - export const RuleForm = ({ rule, config, @@ -126,6 +124,10 @@ export const RuleForm = ({ const [ruleTypeModel, setRuleTypeModel] = useState(null); + const defaultRuleInterval = getInitialInterval(config.minimumScheduleInterval?.value); + const defaultScheduleInterval = getDurationNumberInItsUnit(defaultRuleInterval); + const defaultScheduleIntervalUnit = getDurationUnitValue(defaultRuleInterval); + const [ruleInterval, setRuleInterval] = useState( rule.schedule.interval ? getDurationNumberInItsUnit(rule.schedule.interval) @@ -238,15 +240,10 @@ export const RuleForm = ({ if (rule.schedule.interval) { const interval = getDurationNumberInItsUnit(rule.schedule.interval); const intervalUnit = getDurationUnitValue(rule.schedule.interval); - - if (interval !== defaultScheduleInterval) { - setRuleInterval(interval); - } - if (intervalUnit !== defaultScheduleIntervalUnit) { - setRuleIntervalUnit(intervalUnit); - } + setRuleInterval(interval); + setRuleIntervalUnit(intervalUnit); } - }, [rule.schedule.interval]); + }, [rule.schedule.interval, defaultScheduleInterval, defaultScheduleIntervalUnit]); const setRuleProperty = useCallback( (key: Key, value: Rule[Key] | null) => { @@ -588,12 +585,50 @@ export const RuleForm = ({ type="questionInCircle" content={i18n.translate('xpack.triggersActionsUI.sections.ruleForm.checkWithTooltip', { defaultMessage: - 'Define how often to evaluate the condition. Checks are queued; they run as close to the defined value as capacity allows. The xpack.alerting.minimumScheduleInterval setting defines the minimum value.', + 'Define how often to evaluate the condition. Checks are queued; they run as close to the defined value as capacity allows. The xpack.alerting.rules.minimumScheduleInterval.value setting defines the minimum value. The xpack.alerting.rules.minimumScheduleInterval.enforce setting defines whether this minimum is required or suggested.', })} /> ); + const getHelpTextForInterval = () => { + if (!config || !config.minimumScheduleInterval) { + return ''; + } + + // No help text if there is an error + if (errors['schedule.interval'].length > 0) { + return ''; + } + + if (config.minimumScheduleInterval.enforce) { + // Always show help text if minimum is enforced + return i18n.translate('xpack.triggersActionsUI.sections.ruleForm.checkEveryHelpText', { + defaultMessage: 'Interval must be at least {minimum}.', + values: { + minimum: formatDuration(config.minimumScheduleInterval.value, true), + }, + }); + } else if ( + rule.schedule.interval && + parseDuration(rule.schedule.interval) < parseDuration(config.minimumScheduleInterval.value) + ) { + // Only show help text if current interval is less than suggested + return i18n.translate( + 'xpack.triggersActionsUI.sections.ruleForm.checkEveryHelpSuggestionText', + { + defaultMessage: + 'Intervals less than {minimum} are not recommended due to performance considerations.', + values: { + minimum: formatDuration(config.minimumScheduleInterval.value, true), + }, + } + ); + } else { + return ''; + } + }; + return ( @@ -669,16 +704,7 @@ export const RuleForm = ({ fullWidth data-test-subj="intervalFormRow" display="rowCompressed" - helpText={ - errors['schedule.interval'].length > 0 - ? '' - : i18n.translate('xpack.triggersActionsUI.sections.ruleForm.checkEveryHelpText', { - defaultMessage: 'Interval must be at least {minimum}.', - values: { - minimum: formatDuration(config.minimumScheduleInterval ?? '1m', true), - }, - }) - } + helpText={getHelpTextForInterval()} label={labelForRuleChecked} isInvalid={errors['schedule.interval'].length > 0} error={errors['schedule.interval']} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index b834adc905f50..36c102c6f54bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -41,7 +41,9 @@ jest.mock('../../../../common/lib/health_api', () => ({ triggersActionsUiHealth: jest.fn(() => ({ isRulesAvailable: true })), })); jest.mock('../../../../common/lib/config_api', () => ({ - triggersActionsUiConfig: jest.fn().mockResolvedValue({ minimumScheduleInterval: '1m' }), + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), })); jest.mock('react-router-dom', () => ({ useHistory: () => ({ diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 2446b76f956ed..0835ef2b7453e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -349,5 +349,8 @@ export enum Percentiles { } export interface TriggersActionsUiConfig { - minimumScheduleInterval?: string; + minimumScheduleInterval?: { + value: string; + enforce: boolean; + }; } diff --git a/x-pack/plugins/triggers_actions_ui/server/routes/config.test.ts b/x-pack/plugins/triggers_actions_ui/server/routes/config.test.ts index a7e1772ddabc7..b7e0f15623e81 100644 --- a/x-pack/plugins/triggers_actions_ui/server/routes/config.test.ts +++ b/x-pack/plugins/triggers_actions_ui/server/routes/config.test.ts @@ -13,7 +13,7 @@ describe('createConfigRoute', () => { const router = httpServiceMock.createRouter(); const logger = loggingSystemMock.create().get(); createConfigRoute(logger, router, `/api/triggers_actions_ui`, { - minimumScheduleInterval: '1m', + minimumScheduleInterval: { value: '1m', enforce: false }, }); const [config, handler] = router.get.mock.calls[0]; @@ -24,7 +24,7 @@ describe('createConfigRoute', () => { expect(mockResponse.ok).toBeCalled(); expect(mockResponse.ok.mock.calls[0][0]).toEqual({ - body: { minimumScheduleInterval: '1m' }, + body: { minimumScheduleInterval: { value: '1m', enforce: false } }, }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/server/routes/config.ts b/x-pack/plugins/triggers_actions_ui/server/routes/config.ts index d10651c4c252a..e7c880b35d29a 100644 --- a/x-pack/plugins/triggers_actions_ui/server/routes/config.ts +++ b/x-pack/plugins/triggers_actions_ui/server/routes/config.ts @@ -13,13 +13,13 @@ import { KibanaResponseFactory, } from 'kibana/server'; import { Logger } from '../../../../../src/core/server'; -import { PublicAlertingConfig } from '../../../alerting/server'; +import { AlertingRulesConfig } from '../../../alerting/server'; export function createConfigRoute( logger: Logger, router: IRouter, baseRoute: string, - config?: PublicAlertingConfig + config?: AlertingRulesConfig ) { const path = `${baseRoute}/_config`; logger.debug(`registering triggers_actions_ui config route GET ${path}`); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index d8a5ef9986f29..8c103ef8ce52c 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -162,7 +162,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerting.invalidateApiKeysTask.interval="15s"', '--xpack.alerting.healthCheck.interval="1s"', - '--xpack.alerting.minimumScheduleInterval="1s"', + '--xpack.alerting.rules.minimumScheduleInterval.value="1s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, `--xpack.actions.microsoftGraphApiUrl=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}/api/_actions-FTS-external-service-simulators/exchange/users/test@/sendMail`, diff --git a/x-pack/test/functional_execution_context/config.ts b/x-pack/test/functional_execution_context/config.ts index e11f345ceaf77..336a15026b3a3 100644 --- a/x-pack/test/functional_execution_context/config.ts +++ b/x-pack/test/functional_execution_context/config.ts @@ -58,7 +58,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--logging.loggers[2].name=http.server.response', '--logging.loggers[2].level=all', `--logging.loggers[2].appenders=${JSON.stringify(['file'])}`, - `--xpack.alerting.minimumScheduleInterval="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, ], }, }; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index e180259fa55f3..e537603a0113b 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -66,7 +66,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, - `--xpack.alerting.minimumScheduleInterval="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.preconfiguredAlertHistoryEsIndex=false`, `--xpack.actions.preconfigured=${JSON.stringify({ diff --git a/x-pack/test/rule_registry/common/config.ts b/x-pack/test/rule_registry/common/config.ts index 51058edcb303f..91cc290f60361 100644 --- a/x-pack/test/rule_registry/common/config.ts +++ b/x-pack/test/rule_registry/common/config.ts @@ -78,7 +78,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, - `--xpack.alerting.minimumScheduleInterval="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, '--xpack.eventLog.logEntries=true', ...disabledPlugins .filter((k) => k !== 'security') diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index f9665c2a6729a..47be0c9b2c8ce 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -46,7 +46,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--xpack.ruleRegistry.unsafe.indexUpgrade.enabled=true', // Without below line, default interval for rules is 1m // See https://github.com/elastic/kibana/pull/125396 for details - '--xpack.alerting.minimumScheduleInterval=1s', + '--xpack.alerting.rules.minimumScheduleInterval.value=1s', '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'riskyHostsEnabled', From 2a93e80574f9c1052c541bc4083a440a623899e8 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 21 Mar 2022 16:52:06 -0700 Subject: [PATCH 024/132] Use validated fields for index setting (#128094) --- .../fleet/server/services/epm/elasticsearch/template/install.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 894c2820fa2e1..f30971c0e7d5e 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -465,7 +465,7 @@ export async function installTemplate({ const defaultSettings = buildDefaultSettings({ templateName, packageName, - fields, + fields: validFields, type: dataStream.type, ilmPolicy: dataStream.ilm_policy, }); From efb40e26c650f06d431e62261abea9435fd0a04d Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Tue, 22 Mar 2022 02:11:41 +0000 Subject: [PATCH 025/132] Prebuilt rule alert telemetry (#126163) * Set up drule alerts telemetry task. * Fetch alerts from siem signals index. * Add filter list and enrich telemetry events. * Fix issue with time interval. * Remove potentially sensitive fields. * Use default alerts index instead of signals index. Exclude notes fields from ES payloads Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/telemetry/constants.ts | 4 + .../endpoint_alerts.ts} | 63 +--- .../telemetry/filterlists/exception_lists.ts | 18 + .../index.test.ts} | 2 +- .../server/lib/telemetry/filterlists/index.ts | 43 +++ .../filterlists/prebuilt_rules_alerts.ts | 307 ++++++++++++++++++ .../server/lib/telemetry/filterlists/types.ts | 10 + .../server/lib/telemetry/helpers.ts | 4 +- .../server/lib/telemetry/receiver.ts | 105 +++++- .../server/lib/telemetry/sender.ts | 4 +- .../server/lib/telemetry/tasks/index.ts | 3 + .../tasks/prebuilt_rule_alerts.test.ts | 36 ++ .../telemetry/tasks/prebuilt_rule_alerts.ts | 80 +++++ .../security_solution/server/plugin.ts | 1 + 14 files changed, 608 insertions(+), 72 deletions(-) rename x-pack/plugins/security_solution/server/lib/telemetry/{filters.ts => filterlists/endpoint_alerts.ts} (59%) create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/filterlists/exception_lists.ts rename x-pack/plugins/security_solution/server/lib/telemetry/{filters.test.ts => filterlists/index.test.ts} (97%) create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.ts create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/filterlists/types.ts create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts index ec363568cafd2..3dc0fc4558fc5 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts @@ -13,10 +13,14 @@ export const MAX_ENDPOINT_TELEMETRY_BATCH = 300; export const MAX_DETECTION_RULE_TELEMETRY_BATCH = 1_000; +export const MAX_DETECTION_ALERTS_BATCH = 50; + export const TELEMETRY_CHANNEL_LISTS = 'security-lists-v2'; export const TELEMETRY_CHANNEL_ENDPOINT_META = 'endpoint-metadata'; +export const TELEMETRY_CHANNEL_DETECTION_ALERTS = 'alerts-detections'; + export const LIST_DETECTION_RULE_EXCEPTION = 'detection_rule_exception'; export const LIST_ENDPOINT_EXCEPTION = 'endpoint_exception'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts similarity index 59% rename from x-pack/plugins/security_solution/server/lib/telemetry/filters.ts rename to x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts index bd41bc454e876..3b55d4a789fc0 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts @@ -5,14 +5,10 @@ * 2.0. */ -import type { TelemetryEvent } from './types'; - -export interface AllowlistFields { - [key: string]: boolean | AllowlistFields; -} +import type { AllowlistFields } from './types'; // Allow list process fields within events. This includes "process" and "Target.process".' -const allowlistProcessFields: AllowlistFields = { +const baseAllowlistFields: AllowlistFields = { args: true, entity_id: true, name: true, @@ -76,8 +72,8 @@ const allowlistBaseEventFields: AllowlistFields = { }, }, process: { - parent: allowlistProcessFields, - ...allowlistProcessFields, + parent: baseAllowlistFields, + ...baseAllowlistFields, }, network: { direction: true, @@ -93,8 +89,8 @@ const allowlistBaseEventFields: AllowlistFields = { }, Target: { process: { - parent: allowlistProcessFields, - ...allowlistProcessFields, + parent: baseAllowlistFields, + ...baseAllowlistFields, }, }, user: { @@ -105,7 +101,7 @@ const allowlistBaseEventFields: AllowlistFields = { // Allow list for the data we include in the events. True means that it is deep-cloned // blindly. Object contents means that we only copy the fields that appear explicitly in // the sub-object. -export const allowlistEventFields: AllowlistFields = { +export const endpointAllowlistFields: AllowlistFields = { _id: true, '@timestamp': true, signal_id: true, @@ -134,48 +130,3 @@ export const allowlistEventFields: AllowlistFields = { }, ...allowlistBaseEventFields, }; - -export const exceptionListEventFields: AllowlistFields = { - created_at: true, - effectScope: true, - entries: true, - id: true, - name: true, - os_types: true, - rule_version: true, - scope: true, -}; - -/** - * Filters out information not required for downstream analysis - * - * @param allowlist - * @param event - * @returns TelemetryEvent with explicitly required fields - */ -export function copyAllowlistedFields( - allowlist: AllowlistFields, - event: TelemetryEvent -): TelemetryEvent { - return Object.entries(allowlist).reduce((newEvent, [allowKey, allowValue]) => { - const eventValue = event[allowKey]; - if (eventValue !== null && eventValue !== undefined) { - if (allowValue === true) { - return { ...newEvent, [allowKey]: eventValue }; - } else if (typeof allowValue === 'object' && Array.isArray(eventValue)) { - const subValues = eventValue.filter((v) => typeof v === 'object'); - return { - ...newEvent, - [allowKey]: subValues.map((v) => copyAllowlistedFields(allowValue, v as TelemetryEvent)), - }; - } else if (typeof allowValue === 'object' && typeof eventValue === 'object') { - const values = copyAllowlistedFields(allowValue, eventValue as TelemetryEvent); - return { - ...newEvent, - ...(Object.keys(values).length > 0 ? { [allowKey]: values } : {}), - }; - } - } - return newEvent; - }, {}); -} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/exception_lists.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/exception_lists.ts new file mode 100644 index 0000000000000..cf3e57510ae89 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/exception_lists.ts @@ -0,0 +1,18 @@ +/* + * 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 type { AllowlistFields } from './types'; + +export const exceptionListAllowlistFields: AllowlistFields = { + created_at: true, + effectScope: true, + entries: true, + id: true, + name: true, + os_types: true, + rule_version: true, + scope: true, +}; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.test.ts similarity index 97% rename from x-pack/plugins/security_solution/server/lib/telemetry/filters.test.ts rename to x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.test.ts index 926816149d25c..d02c623bdb70e 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { copyAllowlistedFields } from './filters'; +import { copyAllowlistedFields } from './index'; describe('Security Telemetry filters', () => { describe('allowlistEventFields', () => { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.ts new file mode 100644 index 0000000000000..7c64a29da8f11 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/index.ts @@ -0,0 +1,43 @@ +/* + * 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 type { AllowlistFields } from './types'; +import type { TelemetryEvent } from '../types'; + +/** + * Filters out Key/Values not required for downstream analysis + * @returns TelemetryEvent with explicitly required fields + */ +export function copyAllowlistedFields( + allowlist: AllowlistFields, + event: TelemetryEvent +): TelemetryEvent { + return Object.entries(allowlist).reduce((newEvent, [allowKey, allowValue]) => { + const eventValue = event[allowKey]; + if (eventValue !== null && eventValue !== undefined) { + if (allowValue === true) { + return { ...newEvent, [allowKey]: eventValue }; + } else if (typeof allowValue === 'object' && Array.isArray(eventValue)) { + const subValues = eventValue.filter((v) => typeof v === 'object'); + return { + ...newEvent, + [allowKey]: subValues.map((v) => copyAllowlistedFields(allowValue, v as TelemetryEvent)), + }; + } else if (typeof allowValue === 'object' && typeof eventValue === 'object') { + const values = copyAllowlistedFields(allowValue, eventValue as TelemetryEvent); + return { + ...newEvent, + ...(Object.keys(values).length > 0 ? { [allowKey]: values } : {}), + }; + } + } + return newEvent; + }, {}); +} + +export { endpointAllowlistFields } from './endpoint_alerts'; +export { exceptionListAllowlistFields } from './exception_lists'; +export { prebuiltRuleAllowlistFields } from './prebuilt_rules_alerts'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts new file mode 100644 index 0000000000000..a40c16a64966f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/prebuilt_rules_alerts.ts @@ -0,0 +1,307 @@ +/* + * 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 type { AllowlistFields } from './types'; + +export const prebuiltRuleAllowlistFields: AllowlistFields = { + _id: true, + '@timestamp': true, + agent: { + id: true, + }, + destination: { + port: true, + }, + dll: { + code_signature: { + status: true, + subject_name: true, + }, + }, + dns: { + question: { + name: true, + }, + }, + group: { + name: true, + }, + host: { + id: true, + os: { + family: true, + name: true, + }, + }, + http: { + request: { + body: { + content: true, + }, + method: true, + }, + response: { + status_code: true, + }, + }, + message: true, + network: { + bytes: true, + direction: true, + protocol: true, + transport: true, + type: true, + }, + process: { + args: true, + args_count: true, + code_signature: { + subject_name: true, + trusted: true, + }, + command_line: true, + entity_id: true, + executable: true, + Ext: { + token: { + integrity_level_name: true, + }, + }, + name: true, + parent: { + args: true, + commmand_line: true, + entity_id: true, + executable: true, + Ext: { + real: { + pid: true, + }, + }, + name: true, + pid: true, + original_file_name: true, + }, + pid: true, + working_directory: true, + }, + registry: { + data: { + string: true, + }, + path: true, + value: true, + }, + rule: { + name: true, + }, + source: { + port: true, + }, + tls: { + server: { + hash: true, + }, + }, + type: true, + url: { + extension: true, + full: true, + path: true, + }, + user_agent: { + original: true, + }, + user: { + domain: true, + id: true, + }, + // Base alert fields + kibana: { + alert: { + ancestors: true, + depth: true, + original_time: true, + reason: true, + risk_score: true, + rule: { + enabled: true, + from: true, + interval: true, + max_signals: true, + name: true, + rule_id: true, + tags: true, + type: true, + uuid: true, + version: true, + severity: true, + workflow_status: true, + }, + }, + }, + // aws rule fields + aws: { + cloudtrail: { + console_login: { + additional_eventdata: { + mfa_used: true, + }, + }, + error_code: true, + user_identity: { + session_context: { + session_issuer: { + type: true, + }, + }, + type: true, + }, + }, + }, + // azure fields + azure: { + activitylogs: { + operation_name: true, + }, + auditlogs: { + operation_name: true, + properties: { + category: true, + target_resources: true, + }, + }, + signinlogs: { + properties: { + app_display_name: true, + risk_level_aggregated: true, + risk_level_during_signin: true, + risk_state: true, + token_issuer_type: true, + }, + }, + }, + endgame: { + event_subtype_full: true, + metadata: { + type: true, + }, + }, + event: { + action: true, + agent_id_status: true, + category: true, + code: true, + dataset: true, + kind: true, + module: true, + outcome: true, + provider: true, + type: true, + }, + file: { + Ext: { + windows: { + zone_identifier: true, + }, + }, + extension: true, + hash: true, + name: true, + path: true, + pe: { + imphash: true, + original_file_name: true, + }, + }, + // Google/GCP + google_workspace: { + admin: { + new_value: true, + setting: { + name: true, + }, + }, + }, + // office 360 + o365: { + audit: { + LogonError: true, + ModifiedProperties: { + /* eslint-disable @typescript-eslint/naming-convention */ + Role_DisplayName: { + NewValue: true, + }, + }, + Name: true, + NewValue: true, + Operation: true, + Parameters: { + AccessRights: true, + AllowFederatedUsers: true, + AllowGuestUser: true, + Enabled: true, + ForwardAsAttachmentTo: true, + ForwardTo: true, + RedirectTo: true, + }, + }, + }, + powershell: { + file: { + script_block_text: true, + }, + }, + // winlog + winlog: { + event_data: { + AccessList: true, + AccessMask: true, + AllowedToDelegateTo: true, + AttributeLDAPDisplayName: true, + AttributeValue: true, + CallerProcessName: true, + CallTrace: true, + ClientProcessId: true, + GrantedAccess: true, + IntegrityLevel: true, + NewTargetUserName: true, + ObjectDN: true, + OldTargetUserName: true, + ParentProcessId: true, + PrivilegeList: true, + Properties: true, + RelativeTargetName: true, + ShareName: true, + SubjectLogonId: true, + SubjectUserName: true, + TargetImage: true, + TargetLogonId: true, + TargetProcessGUID: true, + TargetSid: true, + }, + logon: { + type: true, + }, + }, + // ml signal fields + signal: { + ancestors: true, + depth: true, + original_time: true, + parent: true, + parents: true, + reason: true, + rule: { + anomaly_threshold: true, + from: true, + machine_learning_job_id: true, + name: true, + output_index: true, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/types.ts new file mode 100644 index 0000000000000..75d2f61814605 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/types.ts @@ -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. + */ + +export interface AllowlistFields { + [key: string]: boolean | AllowlistFields; +} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts index d2dce77866d06..9f63775a1bff7 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { PackagePolicy } from '../../../../fleet/common/types/models/package_policy'; -import { copyAllowlistedFields, exceptionListEventFields } from './filters'; +import { copyAllowlistedFields, exceptionListAllowlistFields } from './filterlists/index'; import { PolicyData } from '../../../common/endpoint/types'; import type { ExceptionListItem, @@ -183,7 +183,7 @@ export const templateExceptionList = ( // cast exception list type to a TelemetryEvent for allowlist filtering const filteredListItem = copyAllowlistedFields( - exceptionListEventFields, + exceptionListAllowlistFields, item as unknown as TelemetryEvent ); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts index 91054577656b1..36dfc191ce335 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts @@ -50,6 +50,7 @@ export interface ITelemetryReceiver { start( core?: CoreStart, kibanaIndex?: string, + alertsIndex?: string, endpointContextService?: EndpointAppContextService, exceptionListClient?: ExceptionListClient ): Promise; @@ -131,6 +132,8 @@ export interface ITelemetryReceiver { status: string; type: string; }; + + fetchPrebuiltRuleAlerts(): Promise; } export class TelemetryReceiver implements ITelemetryReceiver { @@ -141,8 +144,9 @@ export class TelemetryReceiver implements ITelemetryReceiver { private exceptionListClient?: ExceptionListClient; private soClient?: SavedObjectsClientContract; private kibanaIndex?: string; + private alertsIndex?: string; private clusterInfo?: ESClusterInfo; - private readonly max_records = 10_000; + private readonly maxRecords = 10_000 as const; constructor(logger: Logger) { this.logger = logger.get('telemetry_events'); @@ -151,10 +155,12 @@ export class TelemetryReceiver implements ITelemetryReceiver { public async start( core?: CoreStart, kibanaIndex?: string, + alertsIndex?: string, endpointContextService?: EndpointAppContextService, exceptionListClient?: ExceptionListClient ) { this.kibanaIndex = kibanaIndex; + this.alertsIndex = alertsIndex; this.agentClient = endpointContextService?.getAgentService()?.asInternalUser; this.agentPolicyService = endpointContextService?.getAgentPolicyService(); this.esClient = core?.elasticsearch.client.asInternalUser; @@ -174,7 +180,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { } return this.agentClient?.listAgents({ - perPage: this.max_records, + perPage: this.maxRecords, showInactive: true, sortField: 'enrolled_at', sortOrder: 'desc', @@ -205,7 +211,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { aggs: { policy_responses: { terms: { - size: this.max_records, + size: this.maxRecords, field: 'agent.id', }, aggs: { @@ -253,7 +259,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { endpoint_agents: { terms: { field: 'agent.id', - size: this.max_records, + size: this.maxRecords, }, aggs: { latest_metrics: { @@ -300,7 +306,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { endpoint_metadata: { terms: { field: 'agent.id', - size: this.max_records, + size: this.maxRecords, }, aggs: { latest_metadata: { @@ -388,7 +394,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { data: results?.data.map(trustedApplicationToTelemetryEntry), total: results?.total ?? 0, page: results?.page ?? 1, - per_page: results?.per_page ?? this.max_records, + per_page: results?.per_page ?? this.maxRecords, }; } @@ -403,7 +409,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { const results = await this.exceptionListClient.findExceptionListItem({ listId, page: 1, - perPage: this.max_records, + perPage: this.maxRecords, filter: undefined, namespaceType: 'agnostic', sortField: 'name', @@ -414,7 +420,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { data: results?.data.map(exceptionListItemToTelemetryEntry) ?? [], total: results?.total ?? 0, page: results?.page ?? 1, - per_page: results?.per_page ?? this.max_records, + per_page: results?.per_page ?? this.maxRecords, }; } @@ -431,7 +437,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { expand_wildcards: ['open' as const, 'hidden' as const], index: `${this.kibanaIndex}*`, ignore_unavailable: true, - size: this.max_records, + size: this.maxRecords, body: { query: { bool: { @@ -481,7 +487,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { const results = await this.exceptionListClient?.findExceptionListsItem({ listId: [listId], filter: [], - perPage: this.max_records, + perPage: this.maxRecords, page: 1, sortField: 'exception-list.created_at', sortOrder: 'desc', @@ -492,8 +498,85 @@ export class TelemetryReceiver implements ITelemetryReceiver { data: results?.data.map((r) => ruleExceptionListItemToTelemetryEvent(r, ruleVersion)) ?? [], total: results?.total ?? 0, page: results?.page ?? 1, - per_page: results?.per_page ?? this.max_records, + per_page: results?.per_page ?? this.maxRecords, + }; + } + + /** + * Fetch an overview of detection rule alerts over the last 3 hours. + * Filters out custom rules and endpoint rules. + * @returns total of alerts by rules + */ + public async fetchPrebuiltRuleAlerts() { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve detection rule alerts'); + } + + const query: SearchRequest = { + expand_wildcards: ['open' as const, 'hidden' as const], + index: `${this.alertsIndex}*`, + ignore_unavailable: true, + size: 1_000, + body: { + _source: { + exclude: [ + 'message', + 'kibana.alert.rule.note', + 'kibana.alert.rule.parameters.note', + 'powershell.file.script_block_text', + ], + }, + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'kibana.alert.rule.parameters.immutable': 'true', + }, + }, + ], + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match_phrase: { + 'event.module': 'endpoint', + }, + }, + ], + }, + }, + }, + }, + { + range: { + '@timestamp': { + gte: 'now-1h', + lte: 'now', + }, + }, + }, + ], + }, + }, + }, }; + + const response = await this.esClient.search(query, { meta: true }); + this.logger.debug(`received prebuilt alerts: (${response.body.hits.hits.length})`); + + const telemetryEvents: TelemetryEvent[] = response.body.hits.hits.flatMap((h) => + h._source != null ? ([h._source] as TelemetryEvent[]) : [] + ); + + return telemetryEvents; } public async fetchClusterInfo(): Promise { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 4079d3eb9cc8e..db35cf0c7ad8a 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -18,7 +18,7 @@ import { TaskManagerStartContract, } from '../../../../task_manager/server'; import { ITelemetryReceiver } from './receiver'; -import { allowlistEventFields, copyAllowlistedFields } from './filters'; +import { copyAllowlistedFields, endpointAllowlistFields } from './filterlists/index'; import { createTelemetryTaskConfigs } from './tasks'; import { createUsageCounterLabel } from './helpers'; import type { TelemetryEvent } from './types'; @@ -217,7 +217,7 @@ export class TelemetryEventsSender implements ITelemetryEventsSender { public processEvents(events: TelemetryEvent[]): TelemetryEvent[] { return events.map(function (obj: TelemetryEvent): TelemetryEvent { - return copyAllowlistedFields(allowlistEventFields, obj); + return copyAllowlistedFields(endpointAllowlistFields, obj); }); } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts index 0b25ef87452f6..9cfb6883e9533 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts @@ -10,10 +10,12 @@ import { createTelemetryDiagnosticsTaskConfig } from './diagnostic'; import { createTelemetryEndpointTaskConfig } from './endpoint'; import { createTelemetrySecurityListTaskConfig } from './security_lists'; import { createTelemetryDetectionRuleListsTaskConfig } from './detection_rule'; +import { createTelemetryPrebuiltRuleAlertsTaskConfig } from './prebuilt_rule_alerts'; import { MAX_SECURITY_LIST_TELEMETRY_BATCH, MAX_ENDPOINT_TELEMETRY_BATCH, MAX_DETECTION_RULE_TELEMETRY_BATCH, + MAX_DETECTION_ALERTS_BATCH, } from '../constants'; export function createTelemetryTaskConfigs(): SecurityTelemetryTaskConfig[] { @@ -22,5 +24,6 @@ export function createTelemetryTaskConfigs(): SecurityTelemetryTaskConfig[] { createTelemetryEndpointTaskConfig(MAX_SECURITY_LIST_TELEMETRY_BATCH), createTelemetrySecurityListTaskConfig(MAX_ENDPOINT_TELEMETRY_BATCH), createTelemetryDetectionRuleListsTaskConfig(MAX_DETECTION_RULE_TELEMETRY_BATCH), + createTelemetryPrebuiltRuleAlertsTaskConfig(MAX_DETECTION_ALERTS_BATCH), ]; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts new file mode 100644 index 0000000000000..1efd4299de523 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { loggingSystemMock } from 'src/core/server/mocks'; +import { createTelemetryPrebuiltRuleAlertsTaskConfig } from './prebuilt_rule_alerts'; +import { createMockTelemetryEventsSender, createMockTelemetryReceiver } from '../__mocks__'; + +describe('security telemetry - detection rule alerts task test', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + }); + + test('detection rule alerts task should fetch recent alert data from elasticsearch', async () => { + const testTaskExecutionPeriod = { + last: undefined, + current: new Date().toISOString(), + }; + const mockTelemetryEventsSender = createMockTelemetryEventsSender(); + const mockTelemetryReceiver = createMockTelemetryReceiver(); + const telemetryDetectionRuleAlertsTaskConfig = createTelemetryPrebuiltRuleAlertsTaskConfig(1); + + await telemetryDetectionRuleAlertsTaskConfig.runTask( + 'test-id', + logger, + mockTelemetryReceiver, + mockTelemetryEventsSender, + testTaskExecutionPeriod + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts new file mode 100644 index 0000000000000..ad284df0e7c06 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/prebuilt_rule_alerts.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from 'src/core/server'; +import { ITelemetryEventsSender } from '../sender'; +import { ITelemetryReceiver } from '../receiver'; +import type { ESClusterInfo, ESLicense } from '../types'; +import { TaskExecutionPeriod } from '../task'; +import { TELEMETRY_CHANNEL_DETECTION_ALERTS } from '../constants'; +import { batchTelemetryRecords } from '../helpers'; +import { TelemetryEvent } from '../types'; +import { copyAllowlistedFields, prebuiltRuleAllowlistFields } from '../filterlists/index'; + +export function createTelemetryPrebuiltRuleAlertsTaskConfig(maxTelemetryBatch: number) { + return { + type: 'security:telemetry-prebuilt-rule-alerts', + title: 'Security Solution - Prebuilt Rule and Elastic ML Alerts Telemetry', + interval: '1h', + timeout: '5m', + version: '1.0.0', + runTask: async ( + taskId: string, + logger: Logger, + receiver: ITelemetryReceiver, + sender: ITelemetryEventsSender, + taskExecutionPeriod: TaskExecutionPeriod + ) => { + try { + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); + + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); + + const telemetryEvents = await receiver.fetchPrebuiltRuleAlerts(); + if (telemetryEvents.length === 0) { + logger.debug('no prebuilt rule alerts retrieved'); + return 0; + } + + const processedAlerts = telemetryEvents.map( + (event: TelemetryEvent): TelemetryEvent => + copyAllowlistedFields(prebuiltRuleAllowlistFields, event) + ); + + const enrichedAlerts = processedAlerts.map( + (event: TelemetryEvent): TelemetryEvent => ({ + ...event, + licence_id: licenseInfo?.uid, + cluster_uuid: clusterInfo?.cluster_uuid, + cluster_name: clusterInfo?.cluster_name, + }) + ); + + logger.debug(`sending ${enrichedAlerts.length} elastic prebuilt alerts`); + const batches = batchTelemetryRecords(enrichedAlerts, maxTelemetryBatch); + for (const batch of batches) { + await sender.sendOnDemand(TELEMETRY_CHANNEL_DETECTION_ALERTS, batch); + } + + return enrichedAlerts.length; + } catch (err) { + logger.debug('could not complete prebuilt alerts telemetry task'); + return 0; + } + }, + }; +} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 511679ef71a79..3f14af0d8affc 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -452,6 +452,7 @@ export class Plugin implements ISecuritySolutionPlugin { core, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.kibanaIndex!, + DEFAULT_ALERTS_INDEX, this.endpointAppContextService, exceptionListClient ); From 4ea48c7f646133b13472184b01a3eafc85716598 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 22 Mar 2022 08:25:11 +0200 Subject: [PATCH 026/132] [Visualize] Enable Save&Return button for canvas when dashboard permissions are off (#128136) * [Visualize] Enable Save&Return button for canvas when dashboard permissions are off * Fix CI and add another unit test --- .../utils/get_top_nav_config.test.tsx | 143 ++++++++++++++++++ .../utils/get_top_nav_config.tsx | 2 +- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx index 7ddece73d54b7..62ed8343b33c7 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx @@ -81,6 +81,149 @@ describe('getTopNavConfig', () => { }, share, }; + test('returns correct links if the save visualize capabilities are set to false', () => { + const vis = { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance; + const novizSaveServices = { + ...services, + visualizeCapabilities: { + save: false, + }, + }; + const topNavLinks = getTopNavConfig( + { + hasUnsavedChanges: false, + setHasUnsavedChanges: jest.fn(), + hasUnappliedChanges: false, + onOpenInspector: jest.fn(), + originatingApp: 'dashboards', + setOriginatingApp: jest.fn(), + visInstance: vis, + stateContainer, + visualizationIdFromUrl: undefined, + stateTransfer: createEmbeddableStateTransferMock(), + } as unknown as TopNavConfigParams, + novizSaveServices as unknown as VisualizeServices + ); + + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "description": "Open Inspector for visualization", + "disableButton": [Function], + "id": "inspector", + "label": "inspect", + "run": undefined, + "testId": "openInspectorButton", + "tooltip": [Function], + }, + Object { + "description": "Share Visualization", + "disableButton": false, + "id": "share", + "label": "share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Return to the last app without saving changes", + "emphasize": false, + "id": "cancel", + "label": "Cancel", + "run": [Function], + "testId": "visualizeCancelAndReturnButton", + "tooltip": [Function], + }, + Object { + "description": "Finish editing visualization and return to the last app", + "disableButton": false, + "emphasize": true, + "iconType": "checkInCircleFilled", + "id": "saveAndReturn", + "label": "Save and return", + "run": [Function], + "testId": "visualizesaveAndReturnButton", + "tooltip": [Function], + }, + ] + `); + }); + test('returns correct links if the originating app is undefined', () => { + const vis = { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance; + const topNavLinks = getTopNavConfig( + { + hasUnsavedChanges: false, + setHasUnsavedChanges: jest.fn(), + hasUnappliedChanges: false, + onOpenInspector: jest.fn(), + originatingApp: undefined, + setOriginatingApp: jest.fn(), + visInstance: vis, + stateContainer, + visualizationIdFromUrl: undefined, + stateTransfer: createEmbeddableStateTransferMock(), + } as unknown as TopNavConfigParams, + services as unknown as VisualizeServices + ); + + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "description": "Open Inspector for visualization", + "disableButton": [Function], + "id": "inspector", + "label": "inspect", + "run": undefined, + "testId": "openInspectorButton", + "tooltip": [Function], + }, + Object { + "description": "Share Visualization", + "disableButton": false, + "id": "share", + "label": "share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Save Visualization", + "disableButton": false, + "emphasize": true, + "iconType": "save", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "visualizeSaveButton", + "tooltip": [Function], + }, + ] + `); + }); test('returns correct links for by reference visualization', () => { const vis = { savedVis: { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx index 362749cb206df..376dde50ec97e 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx @@ -627,7 +627,7 @@ export const getTopNavConfig = ( } ), testId: 'visualizesaveAndReturnButton', - disableButton: hasUnappliedChanges || !dashboardCapabilities.showWriteControls, + disableButton: hasUnappliedChanges, tooltip() { if (hasUnappliedChanges) { return i18n.translate( From 7b545b06eb4f8f6362d9401f3a35059a2046f571 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 22 Mar 2022 07:51:24 +0100 Subject: [PATCH 027/132] [CCR] Remove `axios` dependency in tests (#128148) * Start refactoring test * Finish refactoring rest of tests --- .../auto_follow_pattern_add.test.js | 7 +- .../auto_follow_pattern_edit.test.js | 19 +-- .../auto_follow_pattern_list.test.js | 9 +- .../follower_index_add.test.js | 19 +-- .../follower_index_edit.test.js | 62 ++++---- .../follower_indices_list.test.js | 4 +- .../helpers/http_requests.js | 141 ++++++------------ .../helpers/setup_environment.js | 20 +-- .../__jest__/client_integration/home.test.js | 7 +- 9 files changed, 104 insertions(+), 184 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js index e49751cecc1d0..b47dc3e321f8b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -12,15 +12,10 @@ import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './help const { setup } = pageHelpers.autoFollowPatternAdd; describe('Create Auto-follow pattern', () => { - let server; let httpRequestsMockHelpers; beforeAll(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + ({ httpRequestsMockHelpers } = setupEnvironment()); }); beforeEach(() => { diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js index 7d1a97ae1f97e..c72893473860d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js @@ -8,21 +8,16 @@ import { AutoFollowPatternForm } from '../../app/components/auto_follow_pattern_form'; import './mocks'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; -import { AUTO_FOLLOW_PATTERN_EDIT } from './helpers/constants'; +import { AUTO_FOLLOW_PATTERN_EDIT, AUTO_FOLLOW_PATTERN_EDIT_NAME } from './helpers/constants'; const { setup } = pageHelpers.autoFollowPatternEdit; const { setup: setupAutoFollowPatternAdd } = pageHelpers.autoFollowPatternAdd; describe('Edit Auto-follow pattern', () => { - let server; let httpRequestsMockHelpers; beforeAll(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + ({ httpRequestsMockHelpers } = setupEnvironment()); }); describe('on component mount', () => { @@ -36,7 +31,10 @@ describe('Edit Auto-follow pattern', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse(remoteClusters); - httpRequestsMockHelpers.setGetAutoFollowPatternResponse(AUTO_FOLLOW_PATTERN_EDIT); + httpRequestsMockHelpers.setGetAutoFollowPatternResponse( + AUTO_FOLLOW_PATTERN_EDIT_NAME, + AUTO_FOLLOW_PATTERN_EDIT + ); ({ component, find } = setup()); await nextTick(); @@ -83,7 +81,10 @@ describe('Edit Auto-follow pattern', () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse([ { name: 'cluster-2', seeds: ['localhost:123'], isConnected: false }, ]); - httpRequestsMockHelpers.setGetAutoFollowPatternResponse(AUTO_FOLLOW_PATTERN_EDIT); + httpRequestsMockHelpers.setGetAutoFollowPatternResponse( + AUTO_FOLLOW_PATTERN_EDIT_NAME, + AUTO_FOLLOW_PATTERN_EDIT + ); ({ component, find, exists, actions, form } = setup()); await nextTick(); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js index 7f7a61a6f0177..8eab5feeb9cfd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js @@ -12,15 +12,10 @@ import { setupEnvironment, pageHelpers, nextTick, delay, getRandomString } from const { setup } = pageHelpers.autoFollowPatternList; describe('', () => { - let server; let httpRequestsMockHelpers; beforeAll(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + ({ httpRequestsMockHelpers } = setupEnvironment()); }); beforeEach(() => { @@ -213,7 +208,7 @@ describe('', () => { expect(rows.length).toBe(2); // We wil delete the *first* auto-follow pattern in the table - httpRequestsMockHelpers.setDeleteAutoFollowPatternResponse({ + httpRequestsMockHelpers.setDeleteAutoFollowPatternResponse(autoFollowPattern1.name, { itemsDeleted: [autoFollowPattern1.name], }); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js index 10e41a8b18d5b..61d5eae72bcfd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js @@ -14,15 +14,13 @@ const { setup } = pageHelpers.followerIndexAdd; const { setup: setupAutoFollowPatternAdd } = pageHelpers.autoFollowPatternAdd; describe('Create Follower index', () => { - let server; + let httpSetup; let httpRequestsMockHelpers; beforeAll(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); beforeEach(() => { @@ -165,15 +163,12 @@ describe('Create Follower index', () => { test('should make a request to check if the index name is available in ES', async () => { httpRequestsMockHelpers.setGetClusterIndicesResponse([]); - // Keep track of the request count made until this point - const totalRequests = server.requests.length; - form.setInputValue('followerIndexInput', 'index-name'); await delay(550); // we need to wait as there is a debounce of 500ms on the http validation - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - '/api/index_management/indices' + expect(httpSetup.get).toHaveBeenLastCalledWith( + `/api/index_management/indices`, + expect.anything() ); }); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js index cf490f173bff0..31350601f6232 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js @@ -9,22 +9,20 @@ import { act } from 'react-dom/test-utils'; import { API_BASE_PATH } from '../../../common/constants'; import { FollowerIndexForm } from '../../app/components/follower_index_form/follower_index_form'; import './mocks'; -import { FOLLOWER_INDEX_EDIT } from './helpers/constants'; +import { FOLLOWER_INDEX_EDIT, FOLLOWER_INDEX_EDIT_NAME } from './helpers/constants'; import { setupEnvironment, pageHelpers, nextTick } from './helpers'; const { setup } = pageHelpers.followerIndexEdit; const { setup: setupFollowerIndexAdd } = pageHelpers.followerIndexAdd; describe('Edit follower index', () => { - let server; + let httpSetup; let httpRequestsMockHelpers; beforeAll(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('on component mount', () => { @@ -35,7 +33,10 @@ describe('Edit follower index', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse(remoteClusters); - httpRequestsMockHelpers.setGetFollowerIndexResponse(FOLLOWER_INDEX_EDIT); + httpRequestsMockHelpers.setGetFollowerIndexResponse( + FOLLOWER_INDEX_EDIT_NAME, + FOLLOWER_INDEX_EDIT + ); ({ component, find } = setup()); await nextTick(); @@ -97,7 +98,10 @@ describe('Edit follower index', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse(remoteClusters); - httpRequestsMockHelpers.setGetFollowerIndexResponse(FOLLOWER_INDEX_EDIT); + httpRequestsMockHelpers.setGetFollowerIndexResponse( + FOLLOWER_INDEX_EDIT_NAME, + FOLLOWER_INDEX_EDIT + ); await act(async () => { testBed = await setup(); @@ -117,26 +121,23 @@ describe('Edit follower index', () => { await nextTick(); // Make sure the Request went through - const latestRequest = server.requests[server.requests.length - 1]; - const requestBody = JSON.parse(JSON.parse(latestRequest.requestBody).body); - - // Verify the API endpoint called: method, path and payload - expect(latestRequest.method).toBe('PUT'); - expect(latestRequest.url).toBe( - `${API_BASE_PATH}/follower_indices/${FOLLOWER_INDEX_EDIT.name}` + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/follower_indices/${FOLLOWER_INDEX_EDIT_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + maxReadRequestOperationCount: 7845, + maxOutstandingReadRequests: 16, + maxReadRequestSize: '64mb', + maxWriteRequestOperationCount: 2456, + maxWriteRequestSize: '1048b', + maxOutstandingWriteRequests: 69, + maxWriteBufferCount: 123456, + maxWriteBufferSize: '256mb', + maxRetryDelay: '10s', + readPollTimeout: '2m', + }), + }) ); - expect(requestBody).toEqual({ - maxReadRequestOperationCount: 7845, - maxOutstandingReadRequests: 16, - maxReadRequestSize: '64mb', - maxWriteRequestOperationCount: 2456, - maxWriteRequestSize: '1048b', - maxOutstandingWriteRequests: 69, - maxWriteBufferCount: 123456, - maxWriteBufferSize: '256mb', - maxRetryDelay: '10s', - readPollTimeout: '2m', - }); }); }); @@ -151,7 +152,10 @@ describe('Edit follower index', () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse([ { name: 'new-york', seeds: ['localhost:123'], isConnected: false }, ]); - httpRequestsMockHelpers.setGetFollowerIndexResponse(FOLLOWER_INDEX_EDIT); + httpRequestsMockHelpers.setGetFollowerIndexResponse( + FOLLOWER_INDEX_EDIT_NAME, + FOLLOWER_INDEX_EDIT + ); ({ component, find, exists, actions, form } = setup()); await nextTick(); diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index b3e47b2ad7dca..3e48c82f21fbe 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -21,17 +21,15 @@ import { setupEnvironment, pageHelpers, getRandomString } from './helpers'; const { setup } = pageHelpers.followerIndexList; describe('', () => { - let server; let httpRequestsMockHelpers; beforeAll(() => { jest.useFakeTimers(); - ({ server, httpRequestsMockHelpers } = setupEnvironment()); + ({ httpRequestsMockHelpers } = setupEnvironment()); }); afterAll(() => { jest.useRealTimers(); - server.restore(); }); beforeEach(() => { diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js index 5707f9fa5fdcd..43bdcd8b3a414 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js @@ -5,111 +5,63 @@ * 2.0. */ -import { fakeServer } from 'sinon'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { API_BASE_PATH } from '../../../../common/constants'; // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server) => { - const mockResponse = (defaultResponse, response) => [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify({ ...defaultResponse, ...response }), - ]; - - const setLoadFollowerIndicesResponse = (response) => { - const defaultResponse = { indices: [] }; - - server.respondWith( - 'GET', - '/api/cross_cluster_replication/follower_indices', - mockResponse(defaultResponse, response) - ); - }; - - const setLoadAutoFollowPatternsResponse = (response) => { - const defaultResponse = { patterns: [] }; +const registerHttpRequestMockHelpers = (httpSetup) => { + const mockResponses = new Map( + ['GET', 'PUT', 'DELETE', 'POST'].map((method) => [method, new Map()]) + ); - server.respondWith( - 'GET', - '/api/cross_cluster_replication/auto_follow_patterns', - mockResponse(defaultResponse, response) - ); + const mockMethodImplementation = (method, path) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); }; - const setDeleteAutoFollowPatternResponse = (response) => { - const defaultResponse = { errors: [], itemsDeleted: [] }; + httpSetup.get.mockImplementation((path) => mockMethodImplementation('GET', path)); + httpSetup.delete.mockImplementation((path) => mockMethodImplementation('DELETE', path)); + httpSetup.post.mockImplementation((path) => mockMethodImplementation('POST', path)); + httpSetup.put.mockImplementation((path) => mockMethodImplementation('PUT', path)); - server.respondWith( - 'DELETE', - /\/api\/cross_cluster_replication\/auto_follow_patterns/, - mockResponse(defaultResponse, response) - ); - }; - - const setAutoFollowStatsResponse = (response) => { - const defaultResponse = { - numberOfFailedFollowIndices: 0, - numberOfFailedRemoteClusterStateRequests: 0, - numberOfSuccessfulFollowIndices: 0, - recentAutoFollowErrors: [], - autoFollowedClusters: [ - { - clusterName: 'new-york', - timeSinceLastCheckMillis: 13746, - lastSeenMetadataVersion: 22, - }, - ], + const mockResponse = (method, path, response, error) => { + const defuse = (promise) => { + promise.catch(() => {}); + return promise; }; - server.respondWith( - 'GET', - '/api/cross_cluster_replication/stats/auto_follow', - mockResponse(defaultResponse, response) - ); + return mockResponses + .get(method) + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); }; - const setLoadRemoteClustersResponse = (response = [], error) => { - if (error) { - server.respondWith('GET', '/api/remote_clusters', [ - error.status || 400, - { 'Content-Type': 'application/json' }, - JSON.stringify(error.body), - ]); - } else { - server.respondWith('GET', '/api/remote_clusters', [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - } - }; + const setLoadFollowerIndicesResponse = (response = { indices: [] }, error) => + mockResponse('GET', `${API_BASE_PATH}/follower_indices`, response, error); - const setGetAutoFollowPatternResponse = (response) => { - const defaultResponse = {}; + const setLoadAutoFollowPatternsResponse = (response = { patterns: [] }, error) => + mockResponse('GET', `${API_BASE_PATH}/auto_follow_patterns`, response, error); - server.respondWith( - 'GET', - /\/api\/cross_cluster_replication\/auto_follow_patterns\/.+/, - mockResponse(defaultResponse, response) + const setDeleteAutoFollowPatternResponse = (autoFollowId, response, error) => + mockResponse( + 'DELETE', + `${API_BASE_PATH}/auto_follow_patterns/${autoFollowId}`, + response, + error ); - }; - const setGetClusterIndicesResponse = (response = []) => { - server.respondWith('GET', '/api/index_management/indices', [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; + const setAutoFollowStatsResponse = (response, error) => + mockResponse('GET', `${API_BASE_PATH}/stats/auto_follow`, response, error); - const setGetFollowerIndexResponse = (response) => { - const defaultResponse = {}; + const setLoadRemoteClustersResponse = (response = [], error) => + mockResponse('GET', '/api/remote_clusters', response, error); - server.respondWith( - 'GET', - /\/api\/cross_cluster_replication\/follower_indices\/.+/, - mockResponse(defaultResponse, response) - ); - }; + const setGetAutoFollowPatternResponse = (patternId, response = {}, error) => + mockResponse('GET', `${API_BASE_PATH}/auto_follow_patterns/${patternId}`, response, error); + + const setGetClusterIndicesResponse = (response = [], error) => + mockResponse('GET', '/api/index_management/indices', response, error); + + const setGetFollowerIndexResponse = (patternId, response = {}, error) => + mockResponse('GET', `${API_BASE_PATH}/follower_indices/${patternId}`, response, error); return { setLoadFollowerIndicesResponse, @@ -124,15 +76,10 @@ const registerHttpRequestMockHelpers = (server) => { }; export const init = () => { - const server = fakeServer.create(); - server.respondImmediately = true; - - // We make requests to APIs which don't impact the UX, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, '']); + const httpSetup = httpServiceMock.createSetupContract(); return { - server, - httpRequestsMockHelpers: registerHttpRequestMockHelpers(server), + httpSetup, + httpRequestsMockHelpers: registerHttpRequestMockHelpers(httpSetup), }; }; diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/setup_environment.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/setup_environment.js index 41efd474e43dc..1d030dff5f2f7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/setup_environment.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/helpers/setup_environment.js @@ -5,26 +5,16 @@ * 2.0. */ -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; - import { docLinksServiceMock } from '../../../../../../../src/core/public/mocks'; -import { setHttpClient } from '../../../app/services/api'; import { init as initDocumentation } from '../../../app/services/documentation_links'; import { init as initHttpRequests } from './http_requests'; +import { setHttpClient } from '../../../app/services/api'; export const setupEnvironment = () => { - // axios has a similar interface to HttpSetup, but we - // flatten out the response. - const client = axios.create({ adapter: axiosXhrAdapter }); - client.interceptors.response.use(({ data }) => data); - setHttpClient(client); - initDocumentation(docLinksServiceMock.createStartContract()); + const httpRequests = initHttpRequests(); - const { server, httpRequestsMockHelpers } = initHttpRequests(); + setHttpClient(httpRequests.httpSetup); + initDocumentation(docLinksServiceMock.createStartContract()); - return { - server, - httpRequestsMockHelpers, - }; + return httpRequests; }; diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/home.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/home.test.js index d4cbb1225a81a..b4b8009aedc3c 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/home.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/home.test.js @@ -11,18 +11,13 @@ import { setupEnvironment, pageHelpers, nextTick } from './helpers'; const { setup } = pageHelpers.home; describe('', () => { - let server; let httpRequestsMockHelpers; let find; let exists; let component; beforeAll(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + ({ httpRequestsMockHelpers } = setupEnvironment()); }); beforeEach(() => { From a3020b453047680e39b2267dbff78beff553ba9b Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 22 Mar 2022 01:06:49 -0700 Subject: [PATCH 028/132] [Reporting] Correction to schema for jobType size metrics (#128205) * [Reporting] Correction to schema for jobType size metrics * fixes * fixes2 * fix snapshots --- .../reporting_usage_collector.test.ts.snap | 168 ++++--- .../server/usage/get_export_stats.test.ts | 24 +- .../server/usage/get_export_stats.ts | 4 +- .../server/usage/get_reporting_usage.ts | 13 +- .../reporting/server/usage/schema.test.ts | 168 +++---- .../plugins/reporting/server/usage/schema.ts | 2 +- .../plugins/reporting/server/usage/types.ts | 4 +- .../schema/xpack_plugins.json | 420 +++++++++--------- .../reporting_and_security/usage/metrics.ts | 2 +- 9 files changed, 402 insertions(+), 403 deletions(-) diff --git a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap index 7cd86b60e3354..af04dd4659433 100644 --- a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap +++ b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap @@ -56,7 +56,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -134,7 +134,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -204,7 +204,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -268,7 +268,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -350,7 +350,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -428,7 +428,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -495,7 +495,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -559,7 +559,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -685,7 +685,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -788,7 +788,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -1364,7 +1364,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -1467,7 +1467,7 @@ Object { }, }, }, - "sizes": Object { + "output_size": Object { "1.0": Object { "type": "long", }, @@ -1974,7 +1974,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2009,7 +2009,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2040,7 +2040,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2069,7 +2069,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2106,7 +2106,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2141,7 +2141,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2171,7 +2171,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2200,7 +2200,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2246,7 +2246,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2291,7 +2291,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2349,7 +2349,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2394,7 +2394,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2445,7 +2445,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2480,7 +2480,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2511,7 +2511,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2540,7 +2540,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2577,7 +2577,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2612,7 +2612,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2642,7 +2642,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2671,7 +2671,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2717,7 +2717,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2762,7 +2762,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2814,7 +2814,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2859,7 +2859,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2904,7 +2904,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2939,7 +2939,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2970,7 +2970,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -2999,7 +2999,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3036,7 +3036,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3071,7 +3071,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3101,7 +3101,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3130,7 +3130,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3176,7 +3176,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3221,7 +3221,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3238,15 +3238,7 @@ Object { }, "statuses": undefined, }, - "output_size": Object { - "1.0": 1156282, - "25.0": 1156282, - "5.0": 1156282, - "50.0": 1158078.5, - "75.0": 1159875, - "95.0": 1159875, - "99.0": 1159875, - }, + "output_size": undefined, "printable_pdf": Object { "app": Object { "canvas workpad": 0, @@ -3281,7 +3273,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3356,14 +3348,14 @@ Object { }, }, }, - "sizes": Object { - "1.0": 1156282, - "25.0": 1156282, - "5.0": 1156282, - "50.0": 1158078.5, - "75.0": 1159875, - "95.0": 1159875, - "99.0": 1159875, + "output_size": Object { + "1.0": null, + "25.0": null, + "5.0": null, + "50.0": null, + "75.0": null, + "95.0": null, + "99.0": null, }, "total": 3, }, @@ -3401,7 +3393,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3436,7 +3428,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3467,7 +3459,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3496,7 +3488,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3533,7 +3525,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3568,7 +3560,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3598,7 +3590,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3627,7 +3619,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3673,7 +3665,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3718,7 +3710,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3781,7 +3773,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3826,7 +3818,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3882,7 +3874,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3917,7 +3909,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3948,7 +3940,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -3977,7 +3969,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4014,7 +4006,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4049,7 +4041,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4079,7 +4071,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4108,7 +4100,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4154,7 +4146,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4199,7 +4191,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4251,7 +4243,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, @@ -4296,7 +4288,7 @@ Object { "99.0": null, }, }, - "sizes": Object { + "output_size": Object { "1.0": null, "25.0": null, "5.0": null, diff --git a/x-pack/plugins/reporting/server/usage/get_export_stats.test.ts b/x-pack/plugins/reporting/server/usage/get_export_stats.test.ts index 26b83f007a5e8..b553e12e06558 100644 --- a/x-pack/plugins/reporting/server/usage/get_export_stats.test.ts +++ b/x-pack/plugins/reporting/server/usage/get_export_stats.test.ts @@ -79,14 +79,14 @@ test('Model of jobTypes', () => { PNG: { available: true, total: 3, - sizes: sizesAggResponse, + output_size: sizesAggResponse, app: { dashboard: 0, visualization: 3, 'canvas workpad': 0 }, metrics: { png_cpu: {}, png_memory: {} } as MetricsStats, }, printable_pdf: { available: true, total: 3, - sizes: sizesAggResponse, + output_size: sizesAggResponse, app: { dashboard: 0, visualization: 0, 'canvas workpad': 3 }, layout: { preserve_layout: 3, print: 0, canvas: 0 }, metrics: { pdf_cpu: {}, pdf_memory: {}, pdf_pages: {} } as MetricsStats, @@ -95,7 +95,7 @@ test('Model of jobTypes', () => { available: true, total: 3, app: { search: 3 }, - sizes: sizesAggResponse, + output_size: sizesAggResponse, metrics: { csv_rows: {} } as MetricsStats, }, }, @@ -118,7 +118,7 @@ test('Model of jobTypes', () => { "png_cpu": Object {}, "png_memory": Object {}, }, - "sizes": Object { + "output_size": Object { "1.0": 5093470, "25.0": 5093470, "5.0": 5093470, @@ -144,7 +144,7 @@ test('Model of jobTypes', () => { "metrics": Object { "csv_rows": Object {}, }, - "sizes": Object { + "output_size": Object { "1.0": 5093470, "25.0": 5093470, "5.0": 5093470, @@ -176,7 +176,7 @@ test('Model of jobTypes', () => { "pdf_memory": Object {}, "pdf_pages": Object {}, }, - "sizes": Object { + "output_size": Object { "1.0": 5093470, "25.0": 5093470, "5.0": 5093470, @@ -197,7 +197,7 @@ test('PNG counts, provided count of deprecated jobs explicitly', () => { available: true, total: 15, deprecated: 5, - sizes: sizesAggResponse, + output_size: sizesAggResponse, app: { dashboard: 0, visualization: 0, 'canvas workpad': 0 }, metrics: { png_cpu: {}, png_memory: {} } as MetricsStats, }, @@ -220,7 +220,7 @@ test('PNG counts, provided count of deprecated jobs explicitly', () => { "png_cpu": Object {}, "png_memory": Object {}, }, - "sizes": Object { + "output_size": Object { "1.0": 5093470, "25.0": 5093470, "5.0": 5093470, @@ -240,7 +240,7 @@ test('Incorporate metric stats', () => { PNGV2: { available: true, total: 3, - sizes: sizesAggResponse, + output_size: sizesAggResponse, app: { dashboard: 0, visualization: 0, 'canvas workpad': 3 }, metrics: { png_cpu: { '50.0': 0.01, '75.0': 0.01, '95.0': 0.01, '99.0': 0.01 }, @@ -250,7 +250,7 @@ test('Incorporate metric stats', () => { printable_pdf_v2: { available: true, total: 3, - sizes: sizesAggResponse, + output_size: sizesAggResponse, metrics: { pdf_cpu: { '50.0': 0.01, '75.0': 0.01, '95.0': 0.01, '99.0': 0.01 }, pdf_memory: { '50.0': 3485, '75.0': 3496, '95.0': 3678, '99.0': 3782 }, @@ -288,7 +288,7 @@ test('Incorporate metric stats', () => { "99.0": 3782, }, }, - "sizes": Object { + "output_size": Object { "1.0": 5093470, "25.0": 5093470, "5.0": 5093470, @@ -335,7 +335,7 @@ test('Incorporate metric stats', () => { "99.0": 4, }, }, - "sizes": Object { + "output_size": Object { "1.0": 5093470, "25.0": 5093470, "5.0": 5093470, diff --git a/x-pack/plugins/reporting/server/usage/get_export_stats.ts b/x-pack/plugins/reporting/server/usage/get_export_stats.ts index 223cff8962381..94c2d5ec66de0 100644 --- a/x-pack/plugins/reporting/server/usage/get_export_stats.ts +++ b/x-pack/plugins/reporting/server/usage/get_export_stats.ts @@ -23,7 +23,7 @@ const defaultTotalsForFeature: Omit & { layout: Lay total: 0, deprecated: 0, app: { 'canvas workpad': 0, search: 0, visualization: 0, dashboard: 0 }, - sizes: ['1.0', '5.0', '25.0', '50.0', '75.0', '95.0', '99.0'].reduce( + output_size: ['1.0', '5.0', '25.0', '50.0', '75.0', '95.0', '99.0'].reduce( (sps, p) => ({ ...sps, [p]: null }), {} as SizePercentiles ), @@ -79,7 +79,7 @@ function getAvailableTotalForFeature( available: isAvailable(featureAvailability, exportType), total: jobType?.total || 0, deprecated, - sizes: { ...defaultTotalsForFeature.sizes, ...jobType?.sizes }, + output_size: { ...defaultTotalsForFeature.output_size, ...jobType?.output_size }, metrics: { ...metricsForFeature[exportType], ...jobType?.metrics }, app: { ...defaultTotalsForFeature.app, ...jobType?.app }, layout: jobTypeIsPdf(exportType) diff --git a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts index 369419abd2f1e..a0a01ec602d82 100644 --- a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts @@ -32,7 +32,7 @@ enum keys { OBJECT_TYPE = 'objectTypes', STATUS_BY_APP = 'statusByApp', STATUS = 'statusTypes', - OUTPUT_SIZE = 'sizes', + OUTPUT_SIZE = 'output_size', IS_DEPRECATED = 'meta.isDeprecated', CSV_ROWS = 'csv_rows', PDF_CPU = 'pdf_cpu', @@ -96,7 +96,14 @@ function getAggStats( ): Partial { const { buckets: jobBuckets } = aggs[keys.JOB_TYPE] as AggregationBuckets; const jobTypes = jobBuckets.reduce((accum: JobTypes, bucket) => { - const { key, doc_count: count, isDeprecated, sizes, layoutTypes, objectTypes } = bucket; + const { + key, + doc_count: count, + isDeprecated, + output_size: outputSizes, + layoutTypes, + objectTypes, + } = bucket; const deprecatedCount = isDeprecated?.doc_count; // format the search results into the telemetry schema @@ -105,7 +112,7 @@ function getAggStats( deprecated: deprecatedCount, app: getKeyCount(get(objectTypes, 'buckets', [])), metrics: (metrics && metrics[key]) || undefined, - sizes: get(sizes, 'values', {} as SizePercentiles), + output_size: get(outputSizes, 'values', {} as SizePercentiles), layout: getKeyCount(get(layoutTypes, 'buckets', [])), }; return { ...accum, [key]: jobType }; diff --git a/x-pack/plugins/reporting/server/usage/schema.test.ts b/x-pack/plugins/reporting/server/usage/schema.test.ts index c82bb5c0df9f0..9b0b866e93a23 100644 --- a/x-pack/plugins/reporting/server/usage/schema.test.ts +++ b/x-pack/plugins/reporting/server/usage/schema.test.ts @@ -40,13 +40,13 @@ describe('Reporting telemetry schema', () => { "PNG.metrics.png_memory.75.0.type": "long", "PNG.metrics.png_memory.95.0.type": "long", "PNG.metrics.png_memory.99.0.type": "long", - "PNG.sizes.1.0.type": "long", - "PNG.sizes.25.0.type": "long", - "PNG.sizes.5.0.type": "long", - "PNG.sizes.50.0.type": "long", - "PNG.sizes.75.0.type": "long", - "PNG.sizes.95.0.type": "long", - "PNG.sizes.99.0.type": "long", + "PNG.output_size.1.0.type": "long", + "PNG.output_size.25.0.type": "long", + "PNG.output_size.5.0.type": "long", + "PNG.output_size.50.0.type": "long", + "PNG.output_size.75.0.type": "long", + "PNG.output_size.95.0.type": "long", + "PNG.output_size.99.0.type": "long", "PNG.total.type": "long", "PNGV2.app.canvas workpad.type": "long", "PNGV2.app.dashboard.type": "long", @@ -62,13 +62,13 @@ describe('Reporting telemetry schema', () => { "PNGV2.metrics.png_memory.75.0.type": "long", "PNGV2.metrics.png_memory.95.0.type": "long", "PNGV2.metrics.png_memory.99.0.type": "long", - "PNGV2.sizes.1.0.type": "long", - "PNGV2.sizes.25.0.type": "long", - "PNGV2.sizes.5.0.type": "long", - "PNGV2.sizes.50.0.type": "long", - "PNGV2.sizes.75.0.type": "long", - "PNGV2.sizes.95.0.type": "long", - "PNGV2.sizes.99.0.type": "long", + "PNGV2.output_size.1.0.type": "long", + "PNGV2.output_size.25.0.type": "long", + "PNGV2.output_size.5.0.type": "long", + "PNGV2.output_size.50.0.type": "long", + "PNGV2.output_size.75.0.type": "long", + "PNGV2.output_size.95.0.type": "long", + "PNGV2.output_size.99.0.type": "long", "PNGV2.total.type": "long", "_all.type": "long", "available.type": "boolean", @@ -82,13 +82,13 @@ describe('Reporting telemetry schema', () => { "csv_searchsource.metrics.csv_rows.75.0.type": "long", "csv_searchsource.metrics.csv_rows.95.0.type": "long", "csv_searchsource.metrics.csv_rows.99.0.type": "long", - "csv_searchsource.sizes.1.0.type": "long", - "csv_searchsource.sizes.25.0.type": "long", - "csv_searchsource.sizes.5.0.type": "long", - "csv_searchsource.sizes.50.0.type": "long", - "csv_searchsource.sizes.75.0.type": "long", - "csv_searchsource.sizes.95.0.type": "long", - "csv_searchsource.sizes.99.0.type": "long", + "csv_searchsource.output_size.1.0.type": "long", + "csv_searchsource.output_size.25.0.type": "long", + "csv_searchsource.output_size.5.0.type": "long", + "csv_searchsource.output_size.50.0.type": "long", + "csv_searchsource.output_size.75.0.type": "long", + "csv_searchsource.output_size.95.0.type": "long", + "csv_searchsource.output_size.99.0.type": "long", "csv_searchsource.total.type": "long", "csv_searchsource_immediate.app.canvas workpad.type": "long", "csv_searchsource_immediate.app.dashboard.type": "long", @@ -100,13 +100,13 @@ describe('Reporting telemetry schema', () => { "csv_searchsource_immediate.metrics.csv_rows.75.0.type": "long", "csv_searchsource_immediate.metrics.csv_rows.95.0.type": "long", "csv_searchsource_immediate.metrics.csv_rows.99.0.type": "long", - "csv_searchsource_immediate.sizes.1.0.type": "long", - "csv_searchsource_immediate.sizes.25.0.type": "long", - "csv_searchsource_immediate.sizes.5.0.type": "long", - "csv_searchsource_immediate.sizes.50.0.type": "long", - "csv_searchsource_immediate.sizes.75.0.type": "long", - "csv_searchsource_immediate.sizes.95.0.type": "long", - "csv_searchsource_immediate.sizes.99.0.type": "long", + "csv_searchsource_immediate.output_size.1.0.type": "long", + "csv_searchsource_immediate.output_size.25.0.type": "long", + "csv_searchsource_immediate.output_size.5.0.type": "long", + "csv_searchsource_immediate.output_size.50.0.type": "long", + "csv_searchsource_immediate.output_size.75.0.type": "long", + "csv_searchsource_immediate.output_size.95.0.type": "long", + "csv_searchsource_immediate.output_size.99.0.type": "long", "csv_searchsource_immediate.total.type": "long", "enabled.type": "boolean", "last7Days.PNG.app.canvas workpad.type": "long", @@ -123,13 +123,13 @@ describe('Reporting telemetry schema', () => { "last7Days.PNG.metrics.png_memory.75.0.type": "long", "last7Days.PNG.metrics.png_memory.95.0.type": "long", "last7Days.PNG.metrics.png_memory.99.0.type": "long", - "last7Days.PNG.sizes.1.0.type": "long", - "last7Days.PNG.sizes.25.0.type": "long", - "last7Days.PNG.sizes.5.0.type": "long", - "last7Days.PNG.sizes.50.0.type": "long", - "last7Days.PNG.sizes.75.0.type": "long", - "last7Days.PNG.sizes.95.0.type": "long", - "last7Days.PNG.sizes.99.0.type": "long", + "last7Days.PNG.output_size.1.0.type": "long", + "last7Days.PNG.output_size.25.0.type": "long", + "last7Days.PNG.output_size.5.0.type": "long", + "last7Days.PNG.output_size.50.0.type": "long", + "last7Days.PNG.output_size.75.0.type": "long", + "last7Days.PNG.output_size.95.0.type": "long", + "last7Days.PNG.output_size.99.0.type": "long", "last7Days.PNG.total.type": "long", "last7Days.PNGV2.app.canvas workpad.type": "long", "last7Days.PNGV2.app.dashboard.type": "long", @@ -145,13 +145,13 @@ describe('Reporting telemetry schema', () => { "last7Days.PNGV2.metrics.png_memory.75.0.type": "long", "last7Days.PNGV2.metrics.png_memory.95.0.type": "long", "last7Days.PNGV2.metrics.png_memory.99.0.type": "long", - "last7Days.PNGV2.sizes.1.0.type": "long", - "last7Days.PNGV2.sizes.25.0.type": "long", - "last7Days.PNGV2.sizes.5.0.type": "long", - "last7Days.PNGV2.sizes.50.0.type": "long", - "last7Days.PNGV2.sizes.75.0.type": "long", - "last7Days.PNGV2.sizes.95.0.type": "long", - "last7Days.PNGV2.sizes.99.0.type": "long", + "last7Days.PNGV2.output_size.1.0.type": "long", + "last7Days.PNGV2.output_size.25.0.type": "long", + "last7Days.PNGV2.output_size.5.0.type": "long", + "last7Days.PNGV2.output_size.50.0.type": "long", + "last7Days.PNGV2.output_size.75.0.type": "long", + "last7Days.PNGV2.output_size.95.0.type": "long", + "last7Days.PNGV2.output_size.99.0.type": "long", "last7Days.PNGV2.total.type": "long", "last7Days._all.type": "long", "last7Days.csv_searchsource.app.canvas workpad.type": "long", @@ -164,13 +164,13 @@ describe('Reporting telemetry schema', () => { "last7Days.csv_searchsource.metrics.csv_rows.75.0.type": "long", "last7Days.csv_searchsource.metrics.csv_rows.95.0.type": "long", "last7Days.csv_searchsource.metrics.csv_rows.99.0.type": "long", - "last7Days.csv_searchsource.sizes.1.0.type": "long", - "last7Days.csv_searchsource.sizes.25.0.type": "long", - "last7Days.csv_searchsource.sizes.5.0.type": "long", - "last7Days.csv_searchsource.sizes.50.0.type": "long", - "last7Days.csv_searchsource.sizes.75.0.type": "long", - "last7Days.csv_searchsource.sizes.95.0.type": "long", - "last7Days.csv_searchsource.sizes.99.0.type": "long", + "last7Days.csv_searchsource.output_size.1.0.type": "long", + "last7Days.csv_searchsource.output_size.25.0.type": "long", + "last7Days.csv_searchsource.output_size.5.0.type": "long", + "last7Days.csv_searchsource.output_size.50.0.type": "long", + "last7Days.csv_searchsource.output_size.75.0.type": "long", + "last7Days.csv_searchsource.output_size.95.0.type": "long", + "last7Days.csv_searchsource.output_size.99.0.type": "long", "last7Days.csv_searchsource.total.type": "long", "last7Days.csv_searchsource_immediate.app.canvas workpad.type": "long", "last7Days.csv_searchsource_immediate.app.dashboard.type": "long", @@ -182,13 +182,13 @@ describe('Reporting telemetry schema', () => { "last7Days.csv_searchsource_immediate.metrics.csv_rows.75.0.type": "long", "last7Days.csv_searchsource_immediate.metrics.csv_rows.95.0.type": "long", "last7Days.csv_searchsource_immediate.metrics.csv_rows.99.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.1.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.25.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.5.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.50.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.75.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.95.0.type": "long", - "last7Days.csv_searchsource_immediate.sizes.99.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.1.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.25.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.5.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.50.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.75.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.95.0.type": "long", + "last7Days.csv_searchsource_immediate.output_size.99.0.type": "long", "last7Days.csv_searchsource_immediate.total.type": "long", "last7Days.output_size.1.0.type": "long", "last7Days.output_size.25.0.type": "long", @@ -218,13 +218,13 @@ describe('Reporting telemetry schema', () => { "last7Days.printable_pdf.metrics.pdf_pages.75.0.type": "long", "last7Days.printable_pdf.metrics.pdf_pages.95.0.type": "long", "last7Days.printable_pdf.metrics.pdf_pages.99.0.type": "long", - "last7Days.printable_pdf.sizes.1.0.type": "long", - "last7Days.printable_pdf.sizes.25.0.type": "long", - "last7Days.printable_pdf.sizes.5.0.type": "long", - "last7Days.printable_pdf.sizes.50.0.type": "long", - "last7Days.printable_pdf.sizes.75.0.type": "long", - "last7Days.printable_pdf.sizes.95.0.type": "long", - "last7Days.printable_pdf.sizes.99.0.type": "long", + "last7Days.printable_pdf.output_size.1.0.type": "long", + "last7Days.printable_pdf.output_size.25.0.type": "long", + "last7Days.printable_pdf.output_size.5.0.type": "long", + "last7Days.printable_pdf.output_size.50.0.type": "long", + "last7Days.printable_pdf.output_size.75.0.type": "long", + "last7Days.printable_pdf.output_size.95.0.type": "long", + "last7Days.printable_pdf.output_size.99.0.type": "long", "last7Days.printable_pdf.total.type": "long", "last7Days.printable_pdf_v2.app.canvas workpad.type": "long", "last7Days.printable_pdf_v2.app.dashboard.type": "long", @@ -247,13 +247,13 @@ describe('Reporting telemetry schema', () => { "last7Days.printable_pdf_v2.metrics.pdf_pages.75.0.type": "long", "last7Days.printable_pdf_v2.metrics.pdf_pages.95.0.type": "long", "last7Days.printable_pdf_v2.metrics.pdf_pages.99.0.type": "long", - "last7Days.printable_pdf_v2.sizes.1.0.type": "long", - "last7Days.printable_pdf_v2.sizes.25.0.type": "long", - "last7Days.printable_pdf_v2.sizes.5.0.type": "long", - "last7Days.printable_pdf_v2.sizes.50.0.type": "long", - "last7Days.printable_pdf_v2.sizes.75.0.type": "long", - "last7Days.printable_pdf_v2.sizes.95.0.type": "long", - "last7Days.printable_pdf_v2.sizes.99.0.type": "long", + "last7Days.printable_pdf_v2.output_size.1.0.type": "long", + "last7Days.printable_pdf_v2.output_size.25.0.type": "long", + "last7Days.printable_pdf_v2.output_size.5.0.type": "long", + "last7Days.printable_pdf_v2.output_size.50.0.type": "long", + "last7Days.printable_pdf_v2.output_size.75.0.type": "long", + "last7Days.printable_pdf_v2.output_size.95.0.type": "long", + "last7Days.printable_pdf_v2.output_size.99.0.type": "long", "last7Days.printable_pdf_v2.total.type": "long", "last7Days.status.completed.type": "long", "last7Days.status.completed_with_warnings.type": "long", @@ -408,13 +408,13 @@ describe('Reporting telemetry schema', () => { "printable_pdf.metrics.pdf_pages.75.0.type": "long", "printable_pdf.metrics.pdf_pages.95.0.type": "long", "printable_pdf.metrics.pdf_pages.99.0.type": "long", - "printable_pdf.sizes.1.0.type": "long", - "printable_pdf.sizes.25.0.type": "long", - "printable_pdf.sizes.5.0.type": "long", - "printable_pdf.sizes.50.0.type": "long", - "printable_pdf.sizes.75.0.type": "long", - "printable_pdf.sizes.95.0.type": "long", - "printable_pdf.sizes.99.0.type": "long", + "printable_pdf.output_size.1.0.type": "long", + "printable_pdf.output_size.25.0.type": "long", + "printable_pdf.output_size.5.0.type": "long", + "printable_pdf.output_size.50.0.type": "long", + "printable_pdf.output_size.75.0.type": "long", + "printable_pdf.output_size.95.0.type": "long", + "printable_pdf.output_size.99.0.type": "long", "printable_pdf.total.type": "long", "printable_pdf_v2.app.canvas workpad.type": "long", "printable_pdf_v2.app.dashboard.type": "long", @@ -437,13 +437,13 @@ describe('Reporting telemetry schema', () => { "printable_pdf_v2.metrics.pdf_pages.75.0.type": "long", "printable_pdf_v2.metrics.pdf_pages.95.0.type": "long", "printable_pdf_v2.metrics.pdf_pages.99.0.type": "long", - "printable_pdf_v2.sizes.1.0.type": "long", - "printable_pdf_v2.sizes.25.0.type": "long", - "printable_pdf_v2.sizes.5.0.type": "long", - "printable_pdf_v2.sizes.50.0.type": "long", - "printable_pdf_v2.sizes.75.0.type": "long", - "printable_pdf_v2.sizes.95.0.type": "long", - "printable_pdf_v2.sizes.99.0.type": "long", + "printable_pdf_v2.output_size.1.0.type": "long", + "printable_pdf_v2.output_size.25.0.type": "long", + "printable_pdf_v2.output_size.5.0.type": "long", + "printable_pdf_v2.output_size.50.0.type": "long", + "printable_pdf_v2.output_size.75.0.type": "long", + "printable_pdf_v2.output_size.95.0.type": "long", + "printable_pdf_v2.output_size.99.0.type": "long", "printable_pdf_v2.total.type": "long", "status.completed.type": "long", "status.completed_with_warnings.type": "long", diff --git a/x-pack/plugins/reporting/server/usage/schema.ts b/x-pack/plugins/reporting/server/usage/schema.ts index 5dcc0d33b6bef..b26ffba23e6a6 100644 --- a/x-pack/plugins/reporting/server/usage/schema.ts +++ b/x-pack/plugins/reporting/server/usage/schema.ts @@ -78,7 +78,7 @@ const availableTotalSchema: MakeSchemaFrom = { available: { type: 'boolean' }, total: { type: 'long' }, deprecated: { type: 'long' }, - sizes: sizesSchema, + output_size: sizesSchema, app: appCountsSchema, }; diff --git a/x-pack/plugins/reporting/server/usage/types.ts b/x-pack/plugins/reporting/server/usage/types.ts index 2e73553b59d2d..e11de7f4765d7 100644 --- a/x-pack/plugins/reporting/server/usage/types.ts +++ b/x-pack/plugins/reporting/server/usage/types.ts @@ -20,7 +20,7 @@ interface DocCount { } interface SizeBuckets { - sizes?: { values: SizePercentiles }; + output_size?: { values: SizePercentiles }; } interface ObjectTypeBuckets { @@ -91,7 +91,7 @@ export interface AvailableTotal { available: boolean; total: number; deprecated?: number; - sizes: SizePercentiles; + output_size: SizePercentiles; app: { search?: number; dashboard?: number; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 147ccd81d75b2..f8230be2f5908 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -5818,23 +5818,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -5859,6 +5843,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "csv_rows": { @@ -5892,23 +5892,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -5933,6 +5917,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "csv_rows": { @@ -5966,23 +5966,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -6007,6 +5991,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "png_cpu": { @@ -6056,23 +6056,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -6097,6 +6081,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "png_cpu": { @@ -6146,23 +6146,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -6187,6 +6171,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "layout": { "properties": { "canvas": { @@ -6265,23 +6265,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -6306,6 +6290,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "layout": { "properties": { "canvas": { @@ -6924,6 +6924,12 @@ } } }, + "available": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, "last7Days": { "properties": { "csv_searchsource": { @@ -6937,23 +6943,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -6978,6 +6968,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "csv_rows": { @@ -7011,23 +7017,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -7052,6 +7042,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "csv_rows": { @@ -7085,23 +7091,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -7126,6 +7116,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "png_cpu": { @@ -7175,23 +7181,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -7216,6 +7206,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "metrics": { "properties": { "png_cpu": { @@ -7265,23 +7271,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -7306,6 +7296,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "layout": { "properties": { "canvas": { @@ -7384,23 +7390,7 @@ "deprecated": { "type": "long" }, - "app": { - "properties": { - "search": { - "type": "long" - }, - "canvas workpad": { - "type": "long" - }, - "dashboard": { - "type": "long" - }, - "visualization": { - "type": "long" - } - } - }, - "sizes": { + "output_size": { "properties": { "1.0": { "type": "long" @@ -7425,6 +7415,22 @@ } } }, + "app": { + "properties": { + "search": { + "type": "long" + }, + "canvas workpad": { + "type": "long" + }, + "dashboard": { + "type": "long" + }, + "visualization": { + "type": "long" + } + } + }, "layout": { "properties": { "canvas": { @@ -8044,12 +8050,6 @@ } } } - }, - "available": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" } } }, diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts index 1ba9f5b55570e..d8645025ef6b8 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/metrics.ts @@ -77,7 +77,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('includes report metrics (not for job types under last_7_days)', async () => { - expect(reporting.printable_pdf.sizes).keys([ + expect(reporting.printable_pdf.output_size).keys([ '1_0', '25_0', '50_0', From 194140e6965ebdcb67151a72747ffe0f7c0e9421 Mon Sep 17 00:00:00 2001 From: mgiota Date: Tue, 22 Mar 2022 10:16:04 +0100 Subject: [PATCH 029/132] [Actionable Observability] enable rules page by default (#127959) * enable rules page by default * fix failing tests * fix uptime failing tests * fix apm failing tests * fix logs failing tests * more fixes for failing apm tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../observability/public/application/application.test.tsx | 2 +- .../public/components/app/section/apm/index.test.tsx | 2 +- .../observability/public/pages/overview/overview.stories.tsx | 2 +- x-pack/plugins/observability/public/utils/test_helper.tsx | 2 +- x-pack/plugins/observability/server/index.ts | 2 +- .../test/functional/apps/apm/feature_controls/apm_security.ts | 2 ++ .../apps/infra/feature_controls/infrastructure_security.ts | 4 ++-- .../functional/apps/infra/feature_controls/logs_security.ts | 4 ++-- .../apps/uptime/feature_controls/uptime_security.ts | 3 ++- 9 files changed, 13 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index ad164b86063bb..20e67661bad06 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -64,7 +64,7 @@ describe('renderApp', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, - rules: { enabled: false }, + rules: { enabled: true }, }, }; diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index d22bdec538594..e590d53074f8a 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -49,7 +49,7 @@ describe('APMSection', () => { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, - rules: { enabled: false }, + rules: { enabled: true }, }, }, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 82e181868d2ea..6b6e1d6d1493a 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -89,7 +89,7 @@ const withCore = makeDecorator({ alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, - rules: { enabled: false }, + rules: { enabled: true }, }, }, observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), diff --git a/x-pack/plugins/observability/public/utils/test_helper.tsx b/x-pack/plugins/observability/public/utils/test_helper.tsx index 7bddcdff66d77..4dd67ff290886 100644 --- a/x-pack/plugins/observability/public/utils/test_helper.tsx +++ b/x-pack/plugins/observability/public/utils/test_helper.tsx @@ -30,7 +30,7 @@ const config = { alertingExperience: { enabled: true }, cases: { enabled: true }, overviewNext: { enabled: false }, - rules: { enabled: false }, + rules: { enabled: true }, }, }; diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 8c81d7d8f9f17..a26bafbf89acc 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -33,7 +33,7 @@ export const config: PluginConfigDescriptor = { }), unsafe: schema.object({ alertingExperience: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), - rules: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), + rules: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), cases: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), overviewNext: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), }), diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts index 3a0e4046291e4..3cfe612037e0c 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts @@ -64,6 +64,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(navLinks.map((link) => link.text)).to.eql([ 'Overview', 'Alerts', + 'Rules', 'APM', 'User Experience', 'Stack Management', @@ -119,6 +120,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(navLinks).to.eql([ 'Overview', 'Alerts', + 'Rules', 'APM', 'User Experience', 'Stack Management', diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts index f713c903ebe1e..64387753dc39a 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts @@ -63,7 +63,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows metrics navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Alerts', 'Metrics', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Alerts', 'Rules', 'Metrics', 'Stack Management']); }); describe('infrastructure landing page without data', () => { @@ -161,7 +161,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows metrics navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Alerts', 'Metrics', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Alerts', 'Rules', 'Metrics', 'Stack Management']); }); describe('infrastructure landing page without data', () => { diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts index 8908a34298373..aaa80407f9df4 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts @@ -60,7 +60,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows logs navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Alerts', 'Logs', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Alerts', 'Rules', 'Logs', 'Stack Management']); }); describe('logs landing page without data', () => { @@ -123,7 +123,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows logs navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Alerts', 'Logs', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Alerts', 'Rules', 'Logs', 'Stack Management']); }); describe('logs landing page without data', () => { diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts index 4d4acbe6242ba..ea4e4e939d946 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts @@ -70,6 +70,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(navLinks.map((link) => link.text)).to.eql([ 'Overview', 'Alerts', + 'Rules', 'Uptime', 'Stack Management', ]); @@ -123,7 +124,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows uptime navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Alerts', 'Uptime', 'Stack Management']); + expect(navLinks).to.eql(['Overview', 'Alerts', 'Rules', 'Uptime', 'Stack Management']); }); it('can navigate to Uptime app', async () => { From d62d75d5274d87b14752a7360205dfc567045e56 Mon Sep 17 00:00:00 2001 From: "Lucas F. da Costa" Date: Tue, 22 Mar 2022 09:32:25 +0000 Subject: [PATCH 030/132] [Uptime] hydrate service monitors with port data [#121125] (#127180) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monitor_management/config_key.ts | 1 + .../monitor_management/monitor_types.ts | 2 + .../fleet_package/browser/formatters.ts | 2 + .../fleet_package/browser/normalizers.ts | 2 + .../contexts/browser_context.tsx | 2 + .../synthetics_service/formatters/browser.ts | 2 + .../hydrate_saved_object.test.ts | 75 +++++++++++++++++++ .../hydrate_saved_object.ts | 69 ++++++++++------- .../monitor_validation.test.ts | 2 + 9 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.test.ts diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor_management/config_key.ts b/x-pack/plugins/uptime/common/runtime_types/monitor_management/config_key.ts index 595dd1a3e7721..c3a6697d68b62 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor_management/config_key.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor_management/config_key.ts @@ -61,6 +61,7 @@ export enum ConfigKey { UPLOAD_SPEED = 'throttling.upload_speed', LATENCY = 'throttling.latency', URLS = 'urls', + PORT = 'url.port', USERNAME = 'username', WAIT = 'wait', ZIP_URL_TLS_CERTIFICATE_AUTHORITIES = 'source.zip_url.ssl.certificate_authorities', diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts index 12791edc676a3..c63f5eb838d60 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts @@ -166,6 +166,8 @@ export const BrowserSimpleFieldsCodec = t.intersection([ [ConfigKey.SOURCE_ZIP_PASSWORD]: t.string, [ConfigKey.SOURCE_ZIP_PROXY_URL]: t.string, [ConfigKey.PARAMS]: t.string, + [ConfigKey.URLS]: t.union([t.string, t.undefined]), + [ConfigKey.PORT]: t.union([t.number, t.undefined]), }), ZipUrlTLSFieldsCodec, CommonFieldsCodec, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts index 20ff3d5081ba6..374319d13d263 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts @@ -38,6 +38,8 @@ const throttlingFormatter: Formatter = (fields) => { export const browserFormatters: BrowserFormatMap = { [ConfigKey.METADATA]: (fields) => objectToJsonFormatter(fields[ConfigKey.METADATA]), + [ConfigKey.URLS]: null, + [ConfigKey.PORT]: null, [ConfigKey.SOURCE_ZIP_URL]: null, [ConfigKey.SOURCE_ZIP_USERNAME]: null, [ConfigKey.SOURCE_ZIP_PASSWORD]: null, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts index 71d178494cff1..b33ae9e27bc7f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts @@ -67,6 +67,8 @@ export function getThrottlingParamNormalizer(key: ThrottlingConfigKey): Normaliz export const browserNormalizers: BrowserNormalizerMap = { [ConfigKey.METADATA]: getBrowserJsonToJavascriptNormalizer(ConfigKey.METADATA), + [ConfigKey.URLS]: getBrowserNormalizer(ConfigKey.URLS), + [ConfigKey.PORT]: getBrowserNormalizer(ConfigKey.PORT), [ConfigKey.SOURCE_ZIP_URL]: getBrowserNormalizer(ConfigKey.SOURCE_ZIP_URL), [ConfigKey.SOURCE_ZIP_USERNAME]: getBrowserNormalizer(ConfigKey.SOURCE_ZIP_USERNAME), [ConfigKey.SOURCE_ZIP_PASSWORD]: getBrowserNormalizer(ConfigKey.SOURCE_ZIP_PASSWORD), diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx index c16a9639b3127..015023318556a 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/browser_context.tsx @@ -43,6 +43,8 @@ export const initialValues: BrowserSimpleFields = { [ConfigKey.ZIP_URL_TLS_KEY_PASSPHRASE]: undefined, [ConfigKey.ZIP_URL_TLS_VERIFICATION_MODE]: undefined, [ConfigKey.ZIP_URL_TLS_VERSION]: undefined, + [ConfigKey.URLS]: undefined, + [ConfigKey.PORT]: undefined, }; const defaultContext: BrowserSimpleFieldsContext = { diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts index 7eb89f17b44ad..616d764c50abb 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/formatters/browser.ts @@ -12,6 +12,8 @@ export type BrowserFormatMap = Record; export const browserFormatters: BrowserFormatMap = { [ConfigKey.METADATA]: (fields) => objectFormatter(fields[ConfigKey.METADATA]), + [ConfigKey.URLS]: null, + [ConfigKey.PORT]: null, [ConfigKey.ZIP_URL_TLS_VERSION]: (fields) => arrayFormatter(fields[ConfigKey.ZIP_URL_TLS_VERSION]), [ConfigKey.SOURCE_ZIP_URL]: null, diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.test.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.test.ts new file mode 100644 index 0000000000000..5c493a38ac862 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { hydrateSavedObjects } from './hydrate_saved_object'; +import { SyntheticsMonitorSavedObject } from '../../../common/types'; +import { UptimeServerSetup } from '../adapters'; +import moment from 'moment'; + +describe('hydrateSavedObjects', () => { + const mockEsClient = { + search: jest.fn(), + }; + + const mockMonitorTemplate = { + id: 'my-mock-monitor', + attributes: { + type: 'browser', + name: 'Test Browser Monitor 01', + }, + }; + + const serverMock: UptimeServerSetup = { + uptimeEsClient: mockEsClient, + authSavedObjectsClient: { + bulkUpdate: jest.fn(), + }, + } as unknown as UptimeServerSetup; + + const toKibanaResponse = (hits: Array<{ _source: Record }>) => ({ + body: { hits: { hits } }, + }); + + it.each([['browser'], ['http'], ['tcp']])( + 'hydrates missing data for %s monitors', + async (type) => { + const time = moment(); + const monitor = { + ...mockMonitorTemplate, + attributes: { ...mockMonitorTemplate.attributes, type }, + updated_at: moment(time).subtract(1, 'hour').toISOString(), + } as SyntheticsMonitorSavedObject; + + const monitors: SyntheticsMonitorSavedObject[] = [monitor]; + + mockEsClient.search.mockResolvedValue( + toKibanaResponse([ + { + _source: { + config_id: monitor.id, + '@timestamp': moment(time).toISOString(), + url: { port: 443, full: 'https://example.com' }, + }, + }, + ]) + ); + + await hydrateSavedObjects({ monitors, server: serverMock }); + + expect(serverMock.authSavedObjectsClient?.bulkUpdate).toHaveBeenCalledWith([ + { + ...monitor, + attributes: { + ...monitor.attributes, + 'url.port': 443, + urls: 'https://example.com', + }, + }, + ]); + } + ); +}); diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts index 08e2934a4ac08..3d132e74d24d5 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts @@ -9,7 +9,7 @@ import moment from 'moment'; import { UptimeESClient } from '../lib'; import { UptimeServerSetup } from '../adapters'; import { SyntheticsMonitorSavedObject } from '../../../common/types'; -import { MonitorFields, Ping } from '../../../common/runtime_types'; +import { SyntheticsMonitor, MonitorFields, Ping } from '../../../common/runtime_types'; export const hydrateSavedObjects = async ({ monitors, @@ -19,38 +19,55 @@ export const hydrateSavedObjects = async ({ server: UptimeServerSetup; }) => { try { - const missingUrlInfoIds: string[] = []; + const missingInfoIds: string[] = monitors + .filter((monitor) => { + const isBrowserMonitor = monitor.attributes.type === 'browser'; + const isHTTPMonitor = monitor.attributes.type === 'http'; + const isTCPMonitor = monitor.attributes.type === 'tcp'; - monitors - .filter((monitor) => monitor.attributes.type === 'browser') - .forEach(({ attributes, id }) => { - const monitor = attributes as MonitorFields; - if (!monitor || !monitor.urls) { - missingUrlInfoIds.push(id); - } - }); + const monitorAttributes = monitor.attributes as MonitorFields; + const isMissingUrls = !monitorAttributes || !monitorAttributes.urls; + const isMissingPort = !monitorAttributes || !monitorAttributes['url.port']; - if (missingUrlInfoIds.length > 0 && server.uptimeEsClient) { + const isEnrichableBrowserMonitor = isBrowserMonitor && (isMissingUrls || isMissingPort); + const isEnrichableHttpMonitor = isHTTPMonitor && isMissingPort; + const isEnrichableTcpMonitor = isTCPMonitor && isMissingPort; + + return isEnrichableBrowserMonitor || isEnrichableHttpMonitor || isEnrichableTcpMonitor; + }) + .map(({ id }) => id); + + if (missingInfoIds.length > 0 && server.uptimeEsClient) { const esDocs: Ping[] = await fetchSampleMonitorDocuments( server.uptimeEsClient, - missingUrlInfoIds + missingInfoIds ); + const updatedObjects = monitors - .filter((monitor) => missingUrlInfoIds.includes(monitor.id)) + .filter((monitor) => missingInfoIds.includes(monitor.id)) .map((monitor) => { - let url = ''; + let resultAttributes: Partial = monitor.attributes; + esDocs.forEach((doc) => { // to make sure the document is ingested after the latest update of the monitor - const diff = moment(monitor.updated_at).diff(moment(doc.timestamp), 'minutes'); - if (doc.config_id === monitor.id && doc.url?.full && diff > 1) { - url = doc.url?.full; + const documentIsAfterLatestUpdate = moment(monitor.updated_at).isBefore( + moment(doc.timestamp) + ); + if (!documentIsAfterLatestUpdate) return monitor; + if (doc.config_id !== monitor.id) return monitor; + + if (doc.url?.full) { + resultAttributes = { ...resultAttributes, urls: doc.url?.full }; + } + + if (doc.url?.port) { + resultAttributes = { ...resultAttributes, ['url.port']: doc.url?.port }; } }); - if (url) { - return { ...monitor, attributes: { ...monitor.attributes, urls: url } }; - } - return monitor; + + return { ...monitor, attributes: resultAttributes }; }); + await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); } } catch (e) { @@ -77,19 +94,15 @@ const fetchSampleMonitorDocuments = async (esClient: UptimeESClient, configIds: config_id: configIds, }, }, - { - term: { - 'monitor.type': 'browser', - }, - }, { exists: { field: 'summary', }, }, { - exists: { - field: 'url.full', + bool: { + minimum_should_match: 1, + should: [{ exists: { field: 'url.full' } }, { exists: { field: 'url.port' } }], }, }, ], diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics_service/monitor_validation.test.ts b/x-pack/plugins/uptime/server/rest_api/synthetics_service/monitor_validation.test.ts index 611f32873c714..cadd03dd65aea 100644 --- a/x-pack/plugins/uptime/server/rest_api/synthetics_service/monitor_validation.test.ts +++ b/x-pack/plugins/uptime/server/rest_api/synthetics_service/monitor_validation.test.ts @@ -167,6 +167,8 @@ describe('validateMonitor', () => { [ConfigKey.SOURCE_ZIP_PASSWORD]: 'password', [ConfigKey.SOURCE_ZIP_PROXY_URL]: 'http://proxy-url.com', [ConfigKey.PARAMS]: '', + [ConfigKey.URLS]: undefined, + [ConfigKey.PORT]: undefined, }; testBrowserAdvancedFields = { From accb6bdf0137652a6d9fb1a0b8619ff64c50740e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 22 Mar 2022 11:01:18 +0100 Subject: [PATCH 031/132] [Timelion] Use default number formatter as fallback if nothing else is specified (#128155) --- src/plugins/vis_types/timelion/kibana.json | 2 +- .../public/components/timelion_vis_component.tsx | 11 ++++++----- .../timelion/public/helpers/plugin_services.ts | 4 ++++ src/plugins/vis_types/timelion/public/plugin.ts | 15 +++++++++++++-- src/plugins/vis_types/timelion/tsconfig.json | 1 + 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/plugins/vis_types/timelion/kibana.json b/src/plugins/vis_types/timelion/kibana.json index d1af2e9cd792b..8a2f8a16660fe 100644 --- a/src/plugins/vis_types/timelion/kibana.json +++ b/src/plugins/vis_types/timelion/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["visualizations", "data", "expressions", "charts", "dataViews"], + "requiredPlugins": ["visualizations", "data", "expressions", "charts", "dataViews", "fieldFormats"], "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"], "owner": { "name": "Vis Editors", diff --git a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx index c3a02a8abe8e0..401c26cdb8b37 100644 --- a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx @@ -34,12 +34,11 @@ import { } from '../helpers/panel_utils'; import { colors } from '../helpers/chart_constants'; -import { getCharts } from '../helpers/plugin_services'; +import { getCharts, getFieldFormats } from '../helpers/plugin_services'; -import type { Sheet } from '../helpers/timelion_request_handler'; +import type { Series, Sheet } from '../helpers/timelion_request_handler'; import type { IInterpreterRenderHandlers } from '../../../../expressions'; import type { TimelionVisDependencies } from '../plugin'; -import type { Series } from '../helpers/timelion_request_handler'; import './timelion_vis.scss'; @@ -75,7 +74,9 @@ const DefaultYAxis = () => ( const renderYAxis = (series: Series[]) => { const yAxisOptions = extractAllYAxis(series); - + const defaultFormatter = (x: unknown) => { + return getFieldFormats().getInstance('number').convert(x); + }; const yAxis = yAxisOptions.map((option, index) => ( { id={option.id!} title={option.title} position={option.position} - tickFormat={option.tickFormat} + tickFormat={option.tickFormat || defaultFormatter} gridLine={{ visible: !index, }} diff --git a/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts b/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts index bcd618e6e832b..811004bfe1933 100644 --- a/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts +++ b/src/plugins/vis_types/timelion/public/helpers/plugin_services.ts @@ -9,6 +9,7 @@ import type { ISearchStart } from 'src/plugins/data/public'; import type { DataViewsContract } from 'src/plugins/data_views/public'; import type { ChartsPluginStart } from 'src/plugins/charts/public'; +import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { createGetterSetter } from '../../../../kibana_utils/public'; export const [getIndexPatterns, setIndexPatterns] = @@ -17,3 +18,6 @@ export const [getIndexPatterns, setIndexPatterns] = export const [getDataSearch, setDataSearch] = createGetterSetter('Search'); export const [getCharts, setCharts] = createGetterSetter('Charts'); + +export const [getFieldFormats, setFieldFormats] = + createGetterSetter('FieldFormats'); diff --git a/src/plugins/vis_types/timelion/public/plugin.ts b/src/plugins/vis_types/timelion/public/plugin.ts index 20a6857771cc4..1e94c7c28bb42 100644 --- a/src/plugins/vis_types/timelion/public/plugin.ts +++ b/src/plugins/vis_types/timelion/public/plugin.ts @@ -25,9 +25,15 @@ import type { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import type { VisualizationsSetup } from 'src/plugins/visualizations/public'; import type { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; +import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { getTimelionVisualizationConfig } from './timelion_vis_fn'; import { getTimelionVisDefinition } from './timelion_vis_type'; -import { setIndexPatterns, setDataSearch, setCharts } from './helpers/plugin_services'; +import { + setIndexPatterns, + setDataSearch, + setCharts, + setFieldFormats, +} from './helpers/plugin_services'; import { getArgValueSuggestions } from './helpers/arg_value_suggestions'; import { getTimelionVisRenderer } from './timelion_vis_renderer'; @@ -55,6 +61,7 @@ export interface TimelionVisStartDependencies { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; charts: ChartsPluginStart; + fieldFormats: FieldFormatsStart; } /** @public */ @@ -90,10 +97,14 @@ export class TimelionVisPlugin visualizations.createBaseVisualization(getTimelionVisDefinition(dependencies)); } - public start(core: CoreStart, { data, charts, dataViews }: TimelionVisStartDependencies) { + public start( + core: CoreStart, + { data, charts, dataViews, fieldFormats }: TimelionVisStartDependencies + ) { setIndexPatterns(dataViews); setDataSearch(data.search); setCharts(charts); + setFieldFormats(fieldFormats); return { getArgValueSuggestions, diff --git a/src/plugins/vis_types/timelion/tsconfig.json b/src/plugins/vis_types/timelion/tsconfig.json index 3ce35e4ff1f5e..5e20e43224cdb 100644 --- a/src/plugins/vis_types/timelion/tsconfig.json +++ b/src/plugins/vis_types/timelion/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../../core/tsconfig.json" }, { "path": "../../visualizations/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, + { "path": "../../field_formats/tsconfig.json" }, { "path": "../../data_views/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, { "path": "../../kibana_utils/tsconfig.json" }, From 354cd01ecbfbac7b38a98ded6e15babdbae79a16 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 22 Mar 2022 11:22:55 +0100 Subject: [PATCH 032/132] [Lens] include empty rows setting for date histogram (#127453) --- .../search/aggs/buckets/date_histogram.ts | 19 +- .../aggs/buckets/date_histogram_fn.test.ts | 2 + .../search/aggs/buckets/date_histogram_fn.ts | 7 + .../data/common/search/tabify/buckets.test.ts | 201 +++++++++++------- .../data/common/search/tabify/buckets.ts | 23 +- .../data/common/search/tabify/tabify.ts | 2 +- .../main/utils/fetch_chart.test.ts | 1 + .../main/utils/get_chart_agg_config.test.ts | 1 + .../snapshots/session/combined_test0.json | 1 + .../snapshots/session/combined_test1.json | 1 + .../snapshots/session/combined_test2.json | 1 + .../snapshots/session/combined_test3.json | 1 + .../snapshots/session/final_output_test.json | 1 + .../snapshots/session/metric_all_data.json | 1 + .../snapshots/session/metric_empty_data.json | 1 + .../session/metric_invalid_data.json | 1 - .../session/metric_multi_metric_data.json | 1 + .../session/metric_percentage_mode.json | 1 + .../session/metric_single_metric_data.json | 1 + .../snapshots/session/partial_test_1.json | 1 + .../snapshots/session/partial_test_2.json | 1 + .../snapshots/session/step_output_test0.json | 1 + .../snapshots/session/step_output_test1.json | 1 + .../snapshots/session/step_output_test2.json | 1 + .../snapshots/session/step_output_test3.json | 1 + .../snapshots/session/tagcloud_all_data.json | 1 + .../session/tagcloud_empty_data.json | 1 + .../snapshots/session/tagcloud_fontsize.json | 1 + .../session/tagcloud_invalid_data.json | 1 - .../session/tagcloud_metric_data.json | 1 + .../snapshots/session/tagcloud_options.json | 1 + .../indexpattern.test.ts | 2 +- .../definitions/date_histogram.test.tsx | 2 +- .../operations/definitions/date_histogram.tsx | 25 ++- .../make_lens_embeddable_factory.ts | 4 +- .../server/migrations/common_migrations.ts | 14 ++ .../saved_object_migrations.test.ts | 71 +++++++ .../migrations/saved_object_migrations.ts | 13 +- x-pack/test/functional/apps/lens/formula.ts | 1 + .../test/functional/apps/lens/smokescreen.ts | 2 + .../test/functional/apps/lens/time_shift.ts | 5 +- .../kbn_archiver/lens/lens_basic.json | 5 +- .../test/functional/page_objects/lens_page.ts | 5 + 43 files changed, 333 insertions(+), 95 deletions(-) create mode 100644 test/interpreter_functional/snapshots/session/combined_test0.json create mode 100644 test/interpreter_functional/snapshots/session/combined_test1.json create mode 100644 test/interpreter_functional/snapshots/session/combined_test2.json create mode 100644 test/interpreter_functional/snapshots/session/combined_test3.json create mode 100644 test/interpreter_functional/snapshots/session/final_output_test.json create mode 100644 test/interpreter_functional/snapshots/session/metric_all_data.json create mode 100644 test/interpreter_functional/snapshots/session/metric_empty_data.json delete mode 100644 test/interpreter_functional/snapshots/session/metric_invalid_data.json create mode 100644 test/interpreter_functional/snapshots/session/metric_multi_metric_data.json create mode 100644 test/interpreter_functional/snapshots/session/metric_percentage_mode.json create mode 100644 test/interpreter_functional/snapshots/session/metric_single_metric_data.json create mode 100644 test/interpreter_functional/snapshots/session/partial_test_1.json create mode 100644 test/interpreter_functional/snapshots/session/partial_test_2.json create mode 100644 test/interpreter_functional/snapshots/session/step_output_test0.json create mode 100644 test/interpreter_functional/snapshots/session/step_output_test1.json create mode 100644 test/interpreter_functional/snapshots/session/step_output_test2.json create mode 100644 test/interpreter_functional/snapshots/session/step_output_test3.json create mode 100644 test/interpreter_functional/snapshots/session/tagcloud_all_data.json create mode 100644 test/interpreter_functional/snapshots/session/tagcloud_empty_data.json create mode 100644 test/interpreter_functional/snapshots/session/tagcloud_fontsize.json delete mode 100644 test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json create mode 100644 test/interpreter_functional/snapshots/session/tagcloud_metric_data.json create mode 100644 test/interpreter_functional/snapshots/session/tagcloud_options.json diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts index f2bd6ea734e04..64ec46ad637e0 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { get, noop, find, every } from 'lodash'; +import { get, noop, find, every, omitBy, isNil } from 'lodash'; import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; @@ -71,6 +71,7 @@ export interface AggParamsDateHistogram extends BaseAggParams { format?: string; min_doc_count?: number; extended_bounds?: ExtendedBounds; + extendToTimeRange?: boolean; } export const getDateHistogramBucketAgg = ({ @@ -171,6 +172,11 @@ export const getDateHistogramBucketAgg = ({ default: true, write: noop, }, + { + name: 'extendToTimeRange', + default: false, + write: noop, + }, { name: 'scaleMetricValues', default: false, @@ -303,6 +309,17 @@ export const getDateHistogramBucketAgg = ({ return; } + + if (agg.params.extendToTimeRange && agg.buckets.hasBounds()) { + const bucketBounds = agg.buckets.getBounds()!; + output.params.extended_bounds = omitBy( + { + min: bucketBounds.min?.valueOf(), + max: bucketBounds.max?.valueOf(), + }, + isNil + ); + } }, toExpressionAst: extendedBoundsToAst, }, diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.test.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.test.ts index 87fec1123cd24..5724d2b9c7218 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.test.ts @@ -24,6 +24,7 @@ describe('agg_expression_functions', () => { "params": Object { "customLabel": undefined, "drop_partials": undefined, + "extendToTimeRange": undefined, "extended_bounds": undefined, "field": undefined, "format": undefined, @@ -71,6 +72,7 @@ describe('agg_expression_functions', () => { "params": Object { "customLabel": undefined, "drop_partials": false, + "extendToTimeRange": undefined, "extended_bounds": Object { "max": 2, "min": 1, diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.ts index d829356501b8e..b5175efd5cf8a 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram_fn.ts @@ -118,6 +118,13 @@ export const aggDateHistogram = (): FunctionDefinition => ({ 'With extended_bounds setting, you now can "force" the histogram aggregation to start building buckets on a specific min value and also keep on building buckets up to a max value ', }), }, + extendToTimeRange: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.extendToTimeRange.help', { + defaultMessage: + 'Auto-sets the extended bounds to the currently applied time range. Is ignored if extended_bounds is set', + }), + }, json: { types: ['string'], help: i18n.translate('data.search.aggs.buckets.dateHistogram.json.help', { diff --git a/src/plugins/data/common/search/tabify/buckets.test.ts b/src/plugins/data/common/search/tabify/buckets.test.ts index e88f8c8e266a7..615a3d160c48d 100644 --- a/src/plugins/data/common/search/tabify/buckets.test.ts +++ b/src/plugins/data/common/search/tabify/buckets.test.ts @@ -7,7 +7,7 @@ */ import { TabifyBuckets } from './buckets'; -import { AggGroupNames } from '../aggs'; +import { AggGroupNames, IAggConfig } from '../aggs'; import moment from 'moment'; interface Bucket { @@ -58,20 +58,22 @@ describe('Buckets wrapper', () => { }, }; - const aggParams = { - filters: [ - { - label: '', - input: { query: 'response:200' }, - }, - { - label: '', - input: { query: 'response:404' }, - }, - ], - }; + const agg = { + params: { + filters: [ + { + label: '', + input: { query: 'response:200' }, + }, + { + label: '', + input: { query: 'response:404' }, + }, + ], + }, + } as IAggConfig; - const buckets = new TabifyBuckets(aggResp, aggParams); + const buckets = new TabifyBuckets(aggResp, agg); expect(buckets).toHaveLength(2); @@ -88,20 +90,22 @@ describe('Buckets wrapper', () => { }, }; - const aggParams = { - filters: [ - { - label: '', - input: { query: { query_string: { query: 'response:200' } } }, - }, - { - label: '', - input: { query: { query_string: { query: 'response:404' } } }, - }, - ], - }; + const agg = { + params: { + filters: [ + { + label: '', + input: { query: { query_string: { query: 'response:200' } } }, + }, + { + label: '', + input: { query: { query_string: { query: 'response:404' } } }, + }, + ], + }, + } as IAggConfig; - const buckets = new TabifyBuckets(aggResp, aggParams); + const buckets = new TabifyBuckets(aggResp, agg); expect(buckets).toHaveLength(2); @@ -117,16 +121,18 @@ describe('Buckets wrapper', () => { }, }; - const aggParams = { - filters: [ - { - label: '', - input: { query: { match_all: {} } }, - }, - ], - }; + const agg = { + params: { + filters: [ + { + label: '', + input: { query: { match_all: {} } }, + }, + ], + }, + } as IAggConfig; - const buckets = new TabifyBuckets(aggResp, aggParams); + const buckets = new TabifyBuckets(aggResp, agg); expect(buckets).toHaveLength(1); @@ -174,103 +180,154 @@ describe('Buckets wrapper', () => { }; test('drops partial buckets when enabled', () => { - const aggParams = { - drop_partials: true, - field: { - name: 'date', + const agg = { + params: { + drop_partials: true, + field: { + name: 'date', + }, }, - }; + } as IAggConfig; const timeRange = { from: moment(150), to: moment(350), timeFields: ['date'], }; - const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + const buckets = new TabifyBuckets(aggResp, agg, timeRange); expect(buckets).toHaveLength(1); }); - test('keeps partial buckets when disabled', () => { - const aggParams = { - drop_partials: false, - field: { - name: 'date', + test('drops partial buckets with missing buckets based on used_interval if provided', () => { + const agg = { + params: { + drop_partials: true, + used_interval: 'auto', + field: { + name: 'date', + }, }, + // interval is 100ms, but the data has holes + serialize: () => ({ + params: { used_interval: '100ms' }, + }), + } as unknown as IAggConfig; + const timeRange = { + from: moment(1050), + to: moment(1700), + timeFields: ['date'], }; + const buckets = new TabifyBuckets( + { + [AggGroupNames.Buckets]: [ + { key: 0, value: {} }, + { key: 1000, value: {} }, + { key: 1100, value: {} }, + { key: 1400, value: {} }, + { key: 1500, value: {} }, + { key: 1700, value: {} }, + { key: 3000, value: {} }, + ], + }, + agg, + timeRange + ); + + // 1100, 1400 and 1500 + expect(buckets).toHaveLength(3); + }); + + test('keeps partial buckets when disabled', () => { + const agg = { + params: { + drop_partials: false, + field: { + name: 'date', + }, + }, + } as IAggConfig; const timeRange = { from: moment(150), to: moment(350), timeFields: ['date'], }; - const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + const buckets = new TabifyBuckets(aggResp, agg, timeRange); expect(buckets).toHaveLength(4); }); test('keeps aligned buckets when enabled', () => { - const aggParams = { - drop_partials: true, - field: { - name: 'date', + const agg = { + params: { + drop_partials: true, + field: { + name: 'date', + }, }, - }; + } as IAggConfig; const timeRange = { from: moment(100), to: moment(400), timeFields: ['date'], }; - const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + const buckets = new TabifyBuckets(aggResp, agg, timeRange); expect(buckets).toHaveLength(3); }); test('does not drop buckets for non-timerange fields', () => { - const aggParams = { - drop_partials: true, - field: { - name: 'other_time', + const agg = { + params: { + drop_partials: true, + field: { + name: 'other_time', + }, }, - }; + } as IAggConfig; const timeRange = { from: moment(150), to: moment(350), timeFields: ['date'], }; - const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + const buckets = new TabifyBuckets(aggResp, agg, timeRange); expect(buckets).toHaveLength(4); }); test('does drop bucket when multiple time fields specified', () => { - const aggParams = { - drop_partials: true, - field: { - name: 'date', + const agg = { + params: { + drop_partials: true, + field: { + name: 'date', + }, }, - }; + } as IAggConfig; const timeRange = { from: moment(100), to: moment(350), timeFields: ['date', 'other_datefield'], }; - const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + const buckets = new TabifyBuckets(aggResp, agg, timeRange); expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([100, 200]); }); test('does not drop bucket when no timeFields have been specified', () => { - const aggParams = { - drop_partials: true, - field: { - name: 'date', + const agg = { + params: { + drop_partials: true, + field: { + name: 'date', + }, }, - }; + } as IAggConfig; const timeRange = { from: moment(100), to: moment(350), timeFields: [], }; - const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + const buckets = new TabifyBuckets(aggResp, agg, timeRange); expect(buckets.buckets.map((b: Bucket) => b.key)).toEqual([0, 100, 200, 300]); }); diff --git a/src/plugins/data/common/search/tabify/buckets.ts b/src/plugins/data/common/search/tabify/buckets.ts index ccf0f33fb5040..ba2190a034e60 100644 --- a/src/plugins/data/common/search/tabify/buckets.ts +++ b/src/plugins/data/common/search/tabify/buckets.ts @@ -8,7 +8,7 @@ import { get, isPlainObject, keys, findKey } from 'lodash'; import moment from 'moment'; -import { IAggConfig } from '../aggs'; +import { IAggConfig, parseInterval } from '../aggs'; import { AggResponseBucket, TabbedRangeFilterParams, TimeRangeInformation } from './types'; type AggParams = IAggConfig['params'] & { @@ -25,7 +25,7 @@ export class TabifyBuckets { buckets: any; _keys: any[] = []; - constructor(aggResp: any, aggParams?: AggParams, timeRange?: TimeRangeInformation) { + constructor(aggResp: any, agg?: IAggConfig, timeRange?: TimeRangeInformation) { if (aggResp && aggResp.buckets) { this.buckets = aggResp.buckets; } else if (aggResp) { @@ -45,10 +45,10 @@ export class TabifyBuckets { this.length = this.buckets.length; } - if (this.length && aggParams) { - this.orderBucketsAccordingToParams(aggParams); - if (aggParams.drop_partials) { - this.dropPartials(aggParams, timeRange); + if (this.length && agg) { + this.orderBucketsAccordingToParams(agg.params); + if (agg.params.drop_partials) { + this.dropPartials(agg, timeRange); } } } @@ -96,23 +96,26 @@ export class TabifyBuckets { // dropPartials should only be called if the aggParam setting is enabled, // and the agg field is the same as the Time Range. - private dropPartials(params: AggParams, timeRange?: TimeRangeInformation) { + private dropPartials(agg: IAggConfig, timeRange?: TimeRangeInformation) { if ( !timeRange || this.buckets.length <= 1 || this.objectMode || - !timeRange.timeFields.includes(params.field.name) + !timeRange.timeFields.includes(agg.params.field.name) ) { return; } - const interval = this.buckets[1].key - this.buckets[0].key; + // serialize to turn into resolved interval + const interval = agg.params.used_interval + ? parseInterval((agg.serialize().params! as { used_interval: string }).used_interval) + : moment.duration(this.buckets[1].key - this.buckets[0].key); this.buckets = this.buckets.filter((bucket: AggResponseBucket) => { if (moment(bucket.key).isBefore(timeRange.from)) { return false; } - if (moment(bucket.key + interval).isAfter(timeRange.to)) { + if (moment(bucket.key).add(interval).isAfter(timeRange.to)) { return false; } return true; diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index 1bdca61d654f7..7bc02ce353d53 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -45,7 +45,7 @@ export function tabifyAggResponse( switch (agg.type.type) { case AggGroupNames.Buckets: const aggBucket = get(bucket, agg.id) as Record; - const tabifyBuckets = new TabifyBuckets(aggBucket, agg.params, respOpts?.timeRange); + const tabifyBuckets = new TabifyBuckets(aggBucket, agg, respOpts?.timeRange); const precisionError = agg.type.hasPrecisionError?.(aggBucket); if (precisionError) { diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts index ec01c8069f8f3..9f3a25e15d741 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts @@ -85,6 +85,7 @@ describe('test fetchCharts', () => { "id": "2", "params": Object { "drop_partials": false, + "extendToTimeRange": false, "extended_bounds": Object {}, "field": "timestamp", "interval": "auto", diff --git a/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts b/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts index 9960b18181ceb..ccd7584d4bff3 100644 --- a/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts @@ -42,6 +42,7 @@ describe('getChartAggConfigs', () => { "id": "2", "params": Object { "drop_partials": false, + "extendToTimeRange": false, "extended_bounds": Object {}, "field": "timestamp", "interval": "auto", diff --git a/test/interpreter_functional/snapshots/session/combined_test0.json b/test/interpreter_functional/snapshots/session/combined_test0.json new file mode 100644 index 0000000000000..8f00d72df8ab3 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/combined_test0.json @@ -0,0 +1 @@ +{"filters":[],"query":[],"timeRange":null,"type":"kibana_context"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test1.json b/test/interpreter_functional/snapshots/session/combined_test1.json new file mode 100644 index 0000000000000..8f00d72df8ab3 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/combined_test1.json @@ -0,0 +1 @@ +{"filters":[],"query":[],"timeRange":null,"type":"kibana_context"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test2.json b/test/interpreter_functional/snapshots/session/combined_test2.json new file mode 100644 index 0000000000000..b4129ac898eed --- /dev/null +++ b/test/interpreter_functional/snapshots/session/combined_test2.json @@ -0,0 +1 @@ +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test3.json b/test/interpreter_functional/snapshots/session/combined_test3.json new file mode 100644 index 0000000000000..dc1c037f45e95 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/combined_test3.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/final_output_test.json b/test/interpreter_functional/snapshots/session/final_output_test.json new file mode 100644 index 0000000000000..dc1c037f45e95 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/final_output_test.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_all_data.json b/test/interpreter_functional/snapshots/session/metric_all_data.json new file mode 100644 index 0000000000000..939e51b619928 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/metric_all_data.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_empty_data.json b/test/interpreter_functional/snapshots/session/metric_empty_data.json new file mode 100644 index 0000000000000..6adb4e117d2c7 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/metric_empty_data.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_invalid_data.json b/test/interpreter_functional/snapshots/session/metric_invalid_data.json deleted file mode 100644 index bb949523e978a..0000000000000 --- a/test/interpreter_functional/snapshots/session/metric_invalid_data.json +++ /dev/null @@ -1 +0,0 @@ -"[metricVis] > [visdimension] > Provided column name or index is invalid: 0" \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json new file mode 100644 index 0000000000000..4a324a133c057 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_percentage_mode.json b/test/interpreter_functional/snapshots/session/metric_percentage_mode.json new file mode 100644 index 0000000000000..944820d0ed16d --- /dev/null +++ b/test/interpreter_functional/snapshots/session/metric_percentage_mode.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json b/test/interpreter_functional/snapshots/session/metric_single_metric_data.json new file mode 100644 index 0000000000000..392649d410e15 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/metric_single_metric_data.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_1.json b/test/interpreter_functional/snapshots/session/partial_test_1.json new file mode 100644 index 0000000000000..8ce0ee16a0b3b --- /dev/null +++ b/test/interpreter_functional/snapshots/session/partial_test_1.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_2.json b/test/interpreter_functional/snapshots/session/partial_test_2.json new file mode 100644 index 0000000000000..dc1c037f45e95 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/partial_test_2.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test0.json b/test/interpreter_functional/snapshots/session/step_output_test0.json new file mode 100644 index 0000000000000..8f00d72df8ab3 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/step_output_test0.json @@ -0,0 +1 @@ +{"filters":[],"query":[],"timeRange":null,"type":"kibana_context"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test1.json b/test/interpreter_functional/snapshots/session/step_output_test1.json new file mode 100644 index 0000000000000..8f00d72df8ab3 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/step_output_test1.json @@ -0,0 +1 @@ +{"filters":[],"query":[],"timeRange":null,"type":"kibana_context"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test2.json b/test/interpreter_functional/snapshots/session/step_output_test2.json new file mode 100644 index 0000000000000..b4129ac898eed --- /dev/null +++ b/test/interpreter_functional/snapshots/session/step_output_test2.json @@ -0,0 +1 @@ +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test3.json b/test/interpreter_functional/snapshots/session/step_output_test3.json new file mode 100644 index 0000000000000..dc1c037f45e95 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/step_output_test3.json @@ -0,0 +1 @@ +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_all_data.json b/test/interpreter_functional/snapshots/session/tagcloud_all_data.json new file mode 100644 index 0000000000000..837251a438911 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_all_data.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json new file mode 100644 index 0000000000000..5c3ca14f4eab7 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json new file mode 100644 index 0000000000000..5e99024d6e52b --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json b/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json deleted file mode 100644 index d163fcefecabe..0000000000000 --- a/test/interpreter_functional/snapshots/session/tagcloud_invalid_data.json +++ /dev/null @@ -1 +0,0 @@ -"[tagcloud] > [visdimension] > Provided column name or index is invalid: 0" \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json new file mode 100644 index 0000000000000..e00233197bda3 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_options.json b/test/interpreter_functional/snapshots/session/tagcloud_options.json new file mode 100644 index 0000000000000..759b2752f9328 --- /dev/null +++ b/test/interpreter_functional/snapshots/session/tagcloud_options.json @@ -0,0 +1 @@ +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index 5a0e916395728..f19658d468d5f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -435,7 +435,7 @@ describe('IndexPattern Data Source', () => { "1d", ], "min_doc_count": Array [ - 0, + 1, ], "schema": Array [ "segment", diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index 4534270cf4b30..886ea04048a58 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -461,7 +461,7 @@ describe('date_histogram', () => { ); instance .find(EuiSwitch) - .last() + .at(2) .simulate('change', { target: { checked: false }, }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index 8b7ec3cc32e53..f5bc98cb00900 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -49,6 +49,7 @@ export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternC params: { interval: string; ignoreTimeRange?: boolean; + includeEmptyRows?: boolean; dropPartials?: boolean; }; } @@ -120,6 +121,7 @@ export const dateHistogramOperation: OperationDefinition< scale: 'interval', params: { interval: columnParams?.interval ?? autoInterval, + includeEmptyRows: true, dropPartials: Boolean(columnParams?.dropPartials), }, }; @@ -167,8 +169,9 @@ export const dateHistogramOperation: OperationDefinition< useNormalizedEsInterval: !usedField?.aggregationRestrictions?.date_histogram, interval, drop_partials: dropPartials, - min_doc_count: 0, + min_doc_count: column.params?.includeEmptyRows ? 0 : 1, extended_bounds: extendedBoundsToAst({}), + extendToTimeRange: column.params?.includeEmptyRows, }).toAst(); }, paramEditor: function ParamEditor({ @@ -441,6 +444,26 @@ export const dateHistogramOperation: OperationDefinition<
)} + + { + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'includeEmptyRows', + value: !currentColumn.params.includeEmptyRows, + }) + ); + }} + compressed + /> + ); }, diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index 8b61b1a071c63..7b17dc9977e6d 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -19,6 +19,7 @@ import { commonRenameFilterReferences, commonRenameOperationsForFormula, commonRenameRecordsField, + commonSetIncludeEmptyRowsDateHistogram, commonSetLastValueShowArrayValues, commonUpdateVisLayerType, getLensCustomVisualizationMigrations, @@ -95,7 +96,8 @@ export const makeLensEmbeddableFactory = '8.2.0': (state) => { const lensState = state as unknown as { attributes: LensDocShape810 }; let migratedLensState = commonSetLastValueShowArrayValues(lensState.attributes); - migratedLensState = commonEnhanceTableRowHeight(lensState.attributes); + migratedLensState = commonEnhanceTableRowHeight(migratedLensState); + migratedLensState = commonSetIncludeEmptyRowsDateHistogram(migratedLensState); return { ...lensState, attributes: migratedLensState, diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index c8f288a250601..f8f0b5d907d94 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -225,6 +225,20 @@ export const commonEnhanceTableRowHeight = ( return newAttributes; }; +export const commonSetIncludeEmptyRowsDateHistogram = ( + attributes: LensDocShape810 +): LensDocShape810 => { + const newAttributes = cloneDeep(attributes); + for (const layer of Object.values(newAttributes.state.datasourceStates.indexpattern.layers)) { + for (const column of Object.values(layer.columns)) { + if (column.operationType === 'date_histogram') { + column.params.includeEmptyRows = true; + } + } + } + return newAttributes; +}; + const getApplyCustomVisualizationMigrationToLens = (id: string, migration: MigrateFunction) => { return (savedObject: { attributes: LensDocShape }) => { if (savedObject.attributes.visualizationType !== id) return savedObject; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index c6c6a26f3c553..61342af5033dd 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -1993,4 +1993,75 @@ describe('Lens migrations', () => { }); }); }); + + describe('8.2.0 include empty rows for date histogram columns', () => { + const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const example = { + type: 'lens', + id: 'mocked-saved-object-id', + attributes: { + savedObjectId: '1', + title: 'MyRenamedOps', + description: '', + visualizationType: null, + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + currentIndexPatternId: 'logstash-*', + layers: { + '2': { + columns: { + '3': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto', timeZone: 'Europe/Berlin' }, + }, + '4': { + label: '@timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { interval: 'auto' }, + }, + '6': { + label: 'Sum of bytes', + dataType: 'number', + operationType: 'sum', + sourceField: 'bytes', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: ['3', '4', '5'], + }, + }, + }, + }, + visualization: {}, + query: { query: '', language: 'kuery' }, + filters: [], + }, + }, + } as unknown as SavedObjectUnsanitizedDoc; + + it('should set include empty rows for all date histogram columns', () => { + const result = migrations['8.2.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + + const layer2Columns = + result.attributes.state.datasourceStates.indexpattern.layers['2'].columns; + expect(layer2Columns['3'].params).toHaveProperty('includeEmptyRows', true); + expect(layer2Columns['4'].params).toHaveProperty('includeEmptyRows', true); + }); + }); }); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index ca8602d8d1ed3..b31e76a6c208d 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -42,6 +42,7 @@ import { fixLensTopValuesCustomFormatting, commonSetLastValueShowArrayValues, commonEnhanceTableRowHeight, + commonSetIncludeEmptyRowsDateHistogram, } from './common_migrations'; interface LensDocShapePre710 { @@ -477,6 +478,12 @@ const enhanceTableRowHeight: SavedObjectMigrationFn = ( + doc +) => { + return { ...doc, attributes: commonSetIncludeEmptyRowsDateHistogram(doc.attributes) }; +}; + const lensMigrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -491,7 +498,11 @@ const lensMigrations: SavedObjectMigrationMap = { '7.15.0': addLayerTypeToVisualization, '7.16.0': moveDefaultReversedPaletteToCustom, '8.1.0': flow(renameFilterReferences, renameRecordsField, addParentFormatter), - '8.2.0': flow(setLastValueShowArrayValues, enhanceTableRowHeight), + '8.2.0': flow( + setLastValueShowArrayValues, + setIncludeEmptyRowsDateHistogram, + enhanceTableRowHeight + ), }; export const getAllMigrations = ( diff --git a/x-pack/test/functional/apps/lens/formula.ts b/x-pack/test/functional/apps/lens/formula.ts index fcfec350112c4..c2e42328dc943 100644 --- a/x-pack/test/functional/apps/lens/formula.ts +++ b/x-pack/test/functional/apps/lens/formula.ts @@ -188,6 +188,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { dimension: 'lnsDatatable_rows > lns-empty-dimension', operation: 'date_histogram', field: '@timestamp', + disableEmptyRows: true, }); await PageObjects.lens.configureDimension({ diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index c638344f68df9..4887f96c6870a 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -413,6 +413,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { dimension: 'lnsPie_sliceByDimensionPanel > lns-empty-dimension', operation: 'date_histogram', field: '@timestamp', + disableEmptyRows: true, }); await PageObjects.lens.configureDimension({ @@ -507,6 +508,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { dimension: 'lnsDatatable_rows > lns-empty-dimension', operation: 'date_histogram', field: '@timestamp', + disableEmptyRows: true, }); await PageObjects.lens.configureDimension({ dimension: 'lnsDatatable_metrics > lns-empty-dimension', diff --git a/x-pack/test/functional/apps/lens/time_shift.ts b/x-pack/test/functional/apps/lens/time_shift.ts index 6f3cdf751da48..e3363bee419aa 100644 --- a/x-pack/test/functional/apps/lens/time_shift.ts +++ b/x-pack/test/functional/apps/lens/time_shift.ts @@ -21,6 +21,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { dimension: 'lnsDatatable_rows > lns-empty-dimension', operation: 'date_histogram', field: '@timestamp', + disableEmptyRows: true, }); await PageObjects.lens.configureDimension({ dimension: 'lnsDatatable_metrics > lns-empty-dimension', @@ -59,8 +60,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.hasFixAction()).to.be(true); await PageObjects.lens.useFixAction(); - expect(await PageObjects.lens.getDatatableCellText(2, 2)).to.eql('5,541.5'); - expect(await PageObjects.lens.getDatatableCellText(2, 3)).to.eql('3,628'); + expect(await PageObjects.lens.getDatatableCellText(1, 2)).to.eql('5,541.5'); + expect(await PageObjects.lens.getDatatableCellText(1, 3)).to.eql('3,628'); expect(await PageObjects.lens.getDatatableHeaderText(0)).to.eql('Filters of ip'); }); diff --git a/x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json b/x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json index 8f77950407841..7a86073539e93 100644 --- a/x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json +++ b/x-pack/test/functional/fixtures/kbn_archiver/lens/lens_basic.json @@ -111,7 +111,8 @@ "label": "@timestamp", "operationType": "date_histogram", "params": { - "interval": "auto" + "interval": "auto", + "includeEmptyRows": false }, "scale": "interval", "sourceField": "@timestamp" @@ -175,7 +176,7 @@ "coreMigrationVersion": "8.0.0", "id": "76fc4200-cf44-11e9-b933-fd84270f3ac2", "migrationVersion": { - "lens": "7.14.0" + "lens": "8.2.0" }, "references": [ { diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 550a5596068ca..a4f6493d513cb 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -116,6 +116,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont keepOpen?: boolean; palette?: string; formula?: string; + disableEmptyRows?: boolean; }, layerIndex = 0 ) { @@ -160,6 +161,10 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await this.setPalette(opts.palette); } + if (opts.disableEmptyRows) { + await testSubjects.setEuiSwitch('indexPattern-include-empty-rows', 'uncheck'); + } + if (!opts.keepOpen) { await this.closeDimensionEditor(); } From bdc06544c1c394899d5c99008a6625440e66a8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Casper=20H=C3=BCbertz?= Date: Tue, 22 Mar 2022 13:35:30 +0100 Subject: [PATCH 033/132] [APM] Service groups: Minor UI polish changes (#128233) --- .../app/service_groups/service_group_save/group_details.tsx | 2 +- .../components/routing/templates/service_group_template.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx index 5c32ce0b1cfb2..e409be7427a88 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/group_details.tsx @@ -141,7 +141,7 @@ export function GroupDetails({ { defaultMessage: 'Description' } )} labelAppend={ - + {i18n.translate( 'xpack.apm.serviceGroups.groupDetailsForm.description.optional', { defaultMessage: 'Optional' } diff --git a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx index 1e74e39bcbc71..3c078e1a56deb 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/service_group_template.tsx @@ -69,9 +69,10 @@ export function ServiceGroupTemplate({ const serviceGroupsPageTitle = ( Date: Tue, 22 Mar 2022 14:56:37 +0200 Subject: [PATCH 034/132] Debugging with apm - fixes and tutorial (#127892) * fixes + tutorial * cors config * omit secretToken so its not sent to FE Add config tests * lint * empty * swallow errors when parsing configs * read config test adjustment * apm docs review * new line * doc review Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- dev_docs/tutorials/apm_ui.png | Bin 0 -> 44173 bytes dev_docs/tutorials/debugging.mdx | 46 +++++++ .../src/config.test.mocks.ts | 8 -- .../kbn-apm-config-loader/src/config.test.ts | 124 +++++++----------- packages/kbn-apm-config-loader/src/config.ts | 30 +---- .../__snapshots__/read_config.test.ts.snap | 22 ++++ .../src/utils/get_config_file_paths.test.ts | 6 +- .../src/utils/get_config_file_paths.ts | 6 +- .../src/utils/read_config.test.ts | 11 +- .../src/utils/read_config.ts | 8 +- .../http_resources/get_apm_config.test.ts | 10 ++ .../server/http_resources/get_apm_config.ts | 12 +- 12 files changed, 169 insertions(+), 114 deletions(-) create mode 100644 dev_docs/tutorials/apm_ui.png diff --git a/dev_docs/tutorials/apm_ui.png b/dev_docs/tutorials/apm_ui.png new file mode 100644 index 0000000000000000000000000000000000000000..889fb6695e574e48b24fc67227bab813a5dd9f33 GIT binary patch literal 44173 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 z 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 + elastic.apm.secretToken: + ``` + 6. Once you run kibana and start using it, two new services (kibana, kibana-frontend) should appear under the APM UI on the APM deployment. + ![APM UI](./apm_ui.png) + +### Enabling APM via environment variables + +It is possible to enable APM via environment variables as well. +They take precedence over any values defined in `kibana.yml` or `kibana.dev.yml` + +Set the following environment variables to enable APM: + + * ELASTIC_APM_ACTIVE + * ELASTIC_APM_SERVER_URL + * ELASTIC_APM_SECRET_TOKEN diff --git a/packages/kbn-apm-config-loader/src/config.test.mocks.ts b/packages/kbn-apm-config-loader/src/config.test.mocks.ts index 4e148cbd32529..040d26deeba5d 100644 --- a/packages/kbn-apm-config-loader/src/config.test.mocks.ts +++ b/packages/kbn-apm-config-loader/src/config.test.mocks.ts @@ -17,13 +17,6 @@ export const packageMock = { }; jest.doMock(join(mockedRootDir, 'package.json'), () => packageMock.raw, { virtual: true }); -export const devConfigMock = { - raw: {} as any, -}; -jest.doMock(join(mockedRootDir, 'config', 'apm.dev.js'), () => devConfigMock.raw, { - virtual: true, -}); - export const gitRevExecMock = jest.fn(); jest.doMock('child_process', () => ({ ...childProcessModule, @@ -48,7 +41,6 @@ jest.doMock('fs', () => ({ export const resetAllMocks = () => { packageMock.raw = {}; - devConfigMock.raw = {}; gitRevExecMock.mockReset(); readUuidFileMock.mockReset(); jest.resetModules(); diff --git a/packages/kbn-apm-config-loader/src/config.test.ts b/packages/kbn-apm-config-loader/src/config.test.ts index 265362fa8f166..5bc723a27590b 100644 --- a/packages/kbn-apm-config-loader/src/config.test.ts +++ b/packages/kbn-apm-config-loader/src/config.test.ts @@ -5,23 +5,21 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import type { Labels } from 'elastic-apm-node'; +import type { AgentConfigOptions, Labels } from 'elastic-apm-node'; import { packageMock, mockedRootDir, gitRevExecMock, - devConfigMock, readUuidFileMock, resetAllMocks, } from './config.test.mocks'; -import { ApmConfiguration } from './config'; +import { ApmConfiguration, CENTRALIZED_SERVICE_BASE_CONFIG } from './config'; describe('ApmConfiguration', () => { beforeEach(() => { // start with an empty env to avoid CI from spoiling snapshots, env is unique for each jest file process.env = {}; - devConfigMock.raw = {}; packageMock.raw = { version: '8.0.0', build: { @@ -150,82 +148,58 @@ describe('ApmConfiguration', () => { ); }); - it('loads the configuration from the dev config is present', () => { - devConfigMock.raw = { - active: true, - serverUrl: 'https://dev-url.co', - }; - const config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - active: true, - serverUrl: 'https://dev-url.co', - }) - ); - }); - - it('does not load the configuration from the dev config in distributable', () => { - devConfigMock.raw = { - active: false, - }; - const config = new ApmConfiguration(mockedRootDir, {}, true); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - active: true, - }) - ); - }); + describe('env vars', () => { + beforeEach(() => { + delete process.env.ELASTIC_APM_ENVIRONMENT; + delete process.env.ELASTIC_APM_SECRET_TOKEN; + delete process.env.ELASTIC_APM_SERVER_URL; + delete process.env.NODE_ENV; + }); - it('overwrites the standard config file with the dev config', () => { - const kibanaConfig = { - elastic: { - apm: { - active: true, - serverUrl: 'https://url', - secretToken: 'secret', - }, - }, - }; - devConfigMock.raw = { - active: true, - serverUrl: 'https://dev-url.co', - }; - const config = new ApmConfiguration(mockedRootDir, kibanaConfig, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - active: true, - serverUrl: 'https://dev-url.co', - secretToken: 'secret', - }) - ); - }); + it('correctly sets environment by reading env vars', () => { + let config = new ApmConfiguration(mockedRootDir, {}, false); + expect(config.getConfig('serviceName')).toEqual( + expect.objectContaining({ + environment: 'development', + }) + ); - it('correctly sets environment by reading env vars', () => { - delete process.env.ELASTIC_APM_ENVIRONMENT; - delete process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + config = new ApmConfiguration(mockedRootDir, {}, false); + expect(config.getConfig('serviceName')).toEqual( + expect.objectContaining({ + environment: 'production', + }) + ); - let config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - environment: 'development', - }) - ); + process.env.ELASTIC_APM_ENVIRONMENT = 'ci'; + config = new ApmConfiguration(mockedRootDir, {}, false); + expect(config.getConfig('serviceName')).toEqual( + expect.objectContaining({ + environment: 'ci', + }) + ); + }); - process.env.NODE_ENV = 'production'; - config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - environment: 'production', - }) - ); + it('uses default config if serverUrl is not set', () => { + process.env.ELASTIC_APM_SECRET_TOKEN = 'banana'; + const config = new ApmConfiguration(mockedRootDir, {}, false); + const serverConfig = config.getConfig('serviceName'); + expect(serverConfig).toHaveProperty( + 'secretToken', + (CENTRALIZED_SERVICE_BASE_CONFIG as AgentConfigOptions).secretToken + ); + expect(serverConfig).toHaveProperty('serverUrl', CENTRALIZED_SERVICE_BASE_CONFIG.serverUrl); + }); - process.env.ELASTIC_APM_ENVIRONMENT = 'ci'; - config = new ApmConfiguration(mockedRootDir, {}, false); - expect(config.getConfig('serviceName')).toEqual( - expect.objectContaining({ - environment: 'ci', - }) - ); + it('uses env vars config if serverUrl is set', () => { + process.env.ELASTIC_APM_SECRET_TOKEN = 'banana'; + process.env.ELASTIC_APM_SERVER_URL = 'http://banana.com/'; + const config = new ApmConfiguration(mockedRootDir, {}, false); + const serverConfig = config.getConfig('serviceName'); + expect(serverConfig).toHaveProperty('secretToken', process.env.ELASTIC_APM_SECRET_TOKEN); + expect(serverConfig).toHaveProperty('serverUrl', process.env.ELASTIC_APM_SERVER_URL); + }); }); describe('contextPropagationOnly', () => { diff --git a/packages/kbn-apm-config-loader/src/config.ts b/packages/kbn-apm-config-loader/src/config.ts index 303ec931eeda0..1094b96620145 100644 --- a/packages/kbn-apm-config-loader/src/config.ts +++ b/packages/kbn-apm-config-loader/src/config.ts @@ -24,7 +24,7 @@ const DEFAULT_CONFIG: AgentConfigOptions = { globalLabels: {}, }; -const CENTRALIZED_SERVICE_BASE_CONFIG: AgentConfigOptions | RUMAgentConfigOptions = { +export const CENTRALIZED_SERVICE_BASE_CONFIG: AgentConfigOptions | RUMAgentConfigOptions = { serverUrl: 'https://kibana-cloud-apm.apm.us-east-1.aws.found.io', // The secretToken below is intended to be hardcoded in this file even though @@ -136,6 +136,10 @@ export class ApmConfiguration { config.serverUrl = process.env.ELASTIC_APM_SERVER_URL; } + if (process.env.ELASTIC_APM_SECRET_TOKEN) { + config.secretToken = process.env.ELASTIC_APM_SECRET_TOKEN; + } + if (process.env.ELASTIC_APM_GLOBAL_LABELS) { config.globalLabels = Object.fromEntries( process.env.ELASTIC_APM_GLOBAL_LABELS.split(',').map((p) => { @@ -156,23 +160,6 @@ export class ApmConfiguration { return this.rawKibanaConfig?.elastic?.apm ?? {}; } - /** - * Get the configuration from the apm.dev.js file, supersedes config - * from the --config file, disabled when running the distributable - */ - private getDevConfig(): AgentConfigOptions { - if (this.isDistributable) { - return {}; - } - - try { - const apmDevConfigPath = join(this.rootDir, 'config', 'apm.dev.js'); - return require(apmDevConfigPath); - } catch (e) { - return {}; - } - } - /** * Determine the Kibana UUID, initialized the value of `globalLabels.kibana_uuid` * when the UUID can be determined. @@ -266,12 +253,7 @@ export class ApmConfiguration { * Reads APM configuration from different sources and merges them together. */ private getConfigFromAllSources(): AgentConfigOptions { - const config = merge( - {}, - this.getConfigFromKibanaConfig(), - this.getDevConfig(), - this.getConfigFromEnv() - ); + const config = merge({}, this.getConfigFromKibanaConfig(), this.getConfigFromEnv()); if (config.active === false && config.contextPropagationOnly !== false) { throw new Error( diff --git a/packages/kbn-apm-config-loader/src/utils/__snapshots__/read_config.test.ts.snap b/packages/kbn-apm-config-loader/src/utils/__snapshots__/read_config.test.ts.snap index afdce4e76d3f5..ee7b6751e2c6c 100644 --- a/packages/kbn-apm-config-loader/src/utils/__snapshots__/read_config.test.ts.snap +++ b/packages/kbn-apm-config-loader/src/utils/__snapshots__/read_config.test.ts.snap @@ -74,6 +74,28 @@ Object { } `; +exports[`reads yaml files from file system and parses to json, even if one is missing 1`] = ` +Object { + "abc": Object { + "def": "test", + "qwe": 1, + "zyx": Object { + "val": 1, + }, + }, + "bar": true, + "empty_arr": Array [], + "foo": 1, + "pom": Object { + "bom": 3, + }, + "xyz": Array [ + "1", + "2", + ], +} +`; + exports[`returns a deep object 1`] = ` Object { "pid": Object { diff --git a/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.test.ts b/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.test.ts index 3b361a1c6958e..41ba8b07b66b4 100644 --- a/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.test.ts +++ b/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.test.ts @@ -23,6 +23,10 @@ describe('getConfigurationFilePaths', () => { }); it('fallbacks to `getConfigPath` value', () => { - expect(getConfigurationFilePaths([])).toEqual([getConfigPath()]); + const path = getConfigPath(); + expect(getConfigurationFilePaths([])).toEqual([ + path, + path.replace('kibana.yml', 'kibana.dev.yml'), + ]); }); }); diff --git a/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.ts b/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.ts index dc5a55935a90d..d2cb7fb75258c 100644 --- a/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.ts +++ b/packages/kbn-apm-config-loader/src/utils/get_config_file_paths.ts @@ -22,5 +22,9 @@ export const getConfigurationFilePaths = (argv: string[]): string[] => { if (rawPaths.length) { return rawPaths.map((path) => resolve(process.cwd(), path)); } - return [getConfigPath()]; + + const configPath = getConfigPath(); + + // Pick up settings from dev.yml as well + return [configPath, configPath.replace('kibana.yml', 'kibana.dev.yml')]; }; diff --git a/packages/kbn-apm-config-loader/src/utils/read_config.test.ts b/packages/kbn-apm-config-loader/src/utils/read_config.test.ts index 2838738c0ab6c..157cebf122faf 100644 --- a/packages/kbn-apm-config-loader/src/utils/read_config.test.ts +++ b/packages/kbn-apm-config-loader/src/utils/read_config.test.ts @@ -29,6 +29,12 @@ test('reads and merges multiple yaml files from file system and parses to json', expect(config).toMatchSnapshot(); }); +test('reads yaml files from file system and parses to json, even if one is missing', () => { + const config = getConfigFromFiles([fixtureFile('one.yml'), fixtureFile('boo.yml')]); + + expect(config).toMatchSnapshot(); +}); + test('should inject an environment variable value when setting a value with ${ENV_VAR}', () => { process.env.KBN_ENV_VAR1 = 'val1'; process.env.KBN_ENV_VAR2 = 'val2'; @@ -61,8 +67,9 @@ describe('different cwd()', () => { expect(config).toMatchSnapshot(); }); - test('fails to load relative paths, not found because of the cwd', () => { + test('ignores errors loading relative paths', () => { const relativePath = relative(resolve(__dirname, '..', '..'), fixtureFile('one.yml')); - expect(() => getConfigFromFiles([relativePath])).toThrowError(/ENOENT/); + const config = getConfigFromFiles([relativePath]); + expect(config).toStrictEqual({}); }); }); diff --git a/packages/kbn-apm-config-loader/src/utils/read_config.ts b/packages/kbn-apm-config-loader/src/utils/read_config.ts index 4371c7983515c..0d6fce88b0532 100644 --- a/packages/kbn-apm-config-loader/src/utils/read_config.ts +++ b/packages/kbn-apm-config-loader/src/utils/read_config.ts @@ -13,7 +13,13 @@ import { set } from '@elastic/safer-lodash-set'; import { isPlainObject } from 'lodash'; import { ensureDeepObject } from './ensure_deep_object'; -const readYaml = (path: string) => safeLoad(readFileSync(path, 'utf8')); +const readYaml = (path: string) => { + try { + return safeLoad(readFileSync(path, 'utf8')); + } catch (e) { + /* tslint:disable:no-empty */ + } +}; function replaceEnvVarRefs(val: string) { return val.replace(/\$\{(\w+)\}/g, (match, envVarName) => { diff --git a/src/core/server/http_resources/get_apm_config.test.ts b/src/core/server/http_resources/get_apm_config.test.ts index 9552a91da97b1..5e4e7b9b9525c 100644 --- a/src/core/server/http_resources/get_apm_config.test.ts +++ b/src/core/server/http_resources/get_apm_config.test.ts @@ -56,6 +56,16 @@ describe('getApmConfig', () => { ); }); + it('omits secret token', () => { + getConfigurationMock.mockReturnValue({ + ...defaultApmConfig, + secretToken: 'smurfs', + }); + const config = getApmConfig('/some-other-path'); + + expect(config).not.toHaveProperty('secretToken'); + }); + it('enhance the configuration with values from the current server-side transaction', () => { agentMock.currentTransaction = { sampled: 'sampled', diff --git a/src/core/server/http_resources/get_apm_config.ts b/src/core/server/http_resources/get_apm_config.ts index 3e7be65f96652..881efe261d79f 100644 --- a/src/core/server/http_resources/get_apm_config.ts +++ b/src/core/server/http_resources/get_apm_config.ts @@ -6,11 +6,19 @@ * Side Public License, v 1. */ -import agent from 'elastic-apm-node'; +import agent, { AgentConfigOptions } from 'elastic-apm-node'; import { getConfiguration, shouldInstrumentClient } from '@kbn/apm-config-loader'; +const OMIT_APM_CONFIG: Array = ['secretToken']; + export const getApmConfig = (requestPath: string) => { - const baseConfig = getConfiguration('kibana-frontend'); + const baseConfig = getConfiguration('kibana-frontend') || {}; + + // Omit configs not used by RUM agent. + OMIT_APM_CONFIG.forEach((config) => { + delete baseConfig[config]; + }); + if (!shouldInstrumentClient(baseConfig)) { return null; } From 201b352d7f2c2b093091a47020816005011c31f5 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 22 Mar 2022 07:14:52 -0700 Subject: [PATCH 035/132] [DOCS] Add create case and update case APIs (#127936) --- docs/api/cases.asciidoc | 7 +- docs/api/cases/cases-api-create.asciidoc | 237 ++++++++++++++++++++ docs/api/cases/cases-api-update.asciidoc | 271 +++++++++++++++++++++++ 3 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 docs/api/cases/cases-api-create.asciidoc create mode 100644 docs/api/cases/cases-api-update.asciidoc diff --git a/docs/api/cases.asciidoc b/docs/api/cases.asciidoc index 5e412c61926db..00fbedc2d1299 100644 --- a/docs/api/cases.asciidoc +++ b/docs/api/cases.asciidoc @@ -5,7 +5,7 @@ You can create, manage, configure, and send cases to external systems with these APIs: * {security-guide}/cases-api-add-comment.html[Add comment] -* {security-guide}/cases-api-create.html[Create case] +* <> * {security-guide}/cases-api-delete-case.html[Delete case] * {security-guide}/cases-api-delete-all-comments.html[Delete all comments] * {security-guide}/cases-api-delete-comment.html[Delete comment] @@ -24,5 +24,8 @@ these APIs: * {security-guide}/cases-api-push.html[Push case] * {security-guide}/assign-connector.html[Set default Elastic Security UI connector] * {security-guide}/case-api-update-connector.html[Update case configurations] -* {security-guide}/cases-api-update.html[Update case] +* <> * {security-guide}/cases-api-update-comment.html[Update comment] + +include::cases/cases-api-create.asciidoc[leveloffset=+1] +include::cases/cases-api-update.asciidoc[leveloffset=+1] \ No newline at end of file diff --git a/docs/api/cases/cases-api-create.asciidoc b/docs/api/cases/cases-api-create.asciidoc new file mode 100644 index 0000000000000..f08b69998321f --- /dev/null +++ b/docs/api/cases/cases-api-create.asciidoc @@ -0,0 +1,237 @@ +[[cases-api-create]] +== Create case API +++++ +Create case +++++ + +Creates a case. + +=== Request + +`POST :/api/cases` + +`POST :/s//api/cases` + +=== Prerequisite + +You must have `all` privileges for the *Cases* feature in the *Management*, +*{observability}*, or *Security* section of the +<>, depending on the +`owner` of the case you're creating. + +=== Path parameters + +``:: +(Optional, string) An identifier for the space. If it is not specified, the +default space is used. + +=== Request body + +`connector`:: +(Required, object) An object that contains the connector configuration. ++ +.Properties of `connector` +[%collapsible%open] +==== +`fields`:: +(Required, object) An object containing the connector fields. ++ +-- +To create a case without a connector, specify `null`. If you want to omit any +individual field, specify `null` as its value. + +For {ibm-r} connectors, specify: + +`issueTypes`::: +(Required, array of numbers) The type of the incident. + +`severityCode`::: +(Required, number) The severity code of the incident. + +For {jira} connectors, specify: + +`issueType`::: +(Required, string) The type of the issue. + +`parent`::: +(Required, string) The key of the parent issue, when the issue type is `Sub-task`. + +`priority`::: +(Required, string) The priority of the issue. + +For {sn-itsm} connectors, specify: + +`category`::: +(Required, string) The category of the incident. + +`impact`::: +(Required, string) The effect an incident had on business. + +`severity`::: +(Required, string) The severity of the incident. + +`subcategory`::: +(Required, string) The subcategory of the incident. + +`urgency`::: +(Required, string) The extent to which the incident resolution can be delayed. + +For {sn-sir} connectors, specify: + +`category`::: +(Required, string) The category of the incident. + +`destIp`::: +(Required, string) A comma separated list of destination IPs. + +`malwareHash`::: +(Required, string) A comma separated list of malware hashes. + +`malwareUrl`::: +(Required, string) A comma separated list of malware URLs. + +`priority`::: +(Required, string) The priority of the incident. + +`sourceIp`::: +(Required, string) A comma separated list of source IPs. + +`subcategory`::: +(Required, string) The subcategory of the incident. + +For {swimlane} connectors, specify: + +`caseId`::: +(Required, string) The case ID. +-- + +`id`:: +(Required, string) The identifier for the connector. To create a case without a +connector, use `none`. +//To retrieve connector IDs, use <>). + +`name`:: +(Required, string) The name of the connector. To create a case without a +connector, use `none`. + +`type`:: +(Required, string) The type of the connector. Valid values are: `.jira`, `.none`, +`.resilient`,`.servicenow`, `.servicenow-sir`, and `.swimlane`. To create a case +without a connector, use `.none`. +==== + +`description`:: +(Required, string) The description for the case. + +`owner`:: +(Required, string) The application that owns the case. Valid values are: +`cases`, `observability`, or `securitySolution`. This value affects +whether the case is visible in the {stack-manage-app}, {observability}, or +{security-app}. + +`settings`:: +(Required, object) +An object that contains the case settings. ++ +.Properties of `settings` +[%collapsible%open] +==== +`syncAlerts`:: +(Required, boolean) Turns alert syncing on or off. +==== + +`tags`:: +(Required, string array) The words and phrases that help +categorize cases. It can be an empty array. + +`title`:: +(Required, string) A title for the case. + +=== Response code + +`200`:: + Indicates a successful call. + +=== Example + +[source,sh] +-------------------------------------------------- +POST api/cases +{ + "description": "James Bond clicked on a highly suspicious email + banner advertising cheap holidays for underpaid civil servants.", + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering" + ], + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": "High", + "parent": null + } + }, + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution" +} +-------------------------------------------------- +// KIBANA + +The API returns a JSON object that includes the user who created the case and +the case identifier, version, and creation time. For example: + +[source,json] +-------------------------------------------------- +{ + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", <1> + "version": "WzUzMiwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": null, + "updated_by": null, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", <2> + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": "High" + } + }, + "external_service": null <3> +} +-------------------------------------------------- + +<1> The case identifier is also its saved object ID (`savedObjectId`), which is +used when pushing cases to external systems. +<2> The default connector used to push cases to external services. +<3> The `external_service` object stores information about the incident after it +is pushed to an external incident management system. \ No newline at end of file diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc new file mode 100644 index 0000000000000..ed0ef069e15f4 --- /dev/null +++ b/docs/api/cases/cases-api-update.asciidoc @@ -0,0 +1,271 @@ +[[cases-api-update]] +== Update cases API +++++ +Update cases +++++ + +Updates one or more cases. + +=== Request + +`PATCH :/api/cases` + +`PATCH :/s//api/cases` + +=== Prerequisite + +You must have `all` privileges for the *Cases* feature in the *Management*, +*{observability}*, or *Security* section of the +<>, depending on the +`owner` of the cases you're updating. + +=== Path parameters + +``:: +(Optional, string) An identifier for the space. If it is not specified, the +default space is used. + +=== Request body + +`cases`:: +(Required, array of objects) Array containing one or more case objects. ++ +.Properties of `cases` objects +[%collapsible%open] +==== +`connector`:: +(Optional, object) An object that contains the connector configuration. ++ +.Properties of `connector` +[%collapsible%open] +===== +`fields`:: +(Required, object) An object containing the connector fields. ++ +-- +To remove the connector, specify `null`. If you want to omit any individual +field, specify `null` as its value. + +For {ibm-r} connectors, specify: + +`issueTypes`::: +(Required, array of numbers) The issue types of the issue. + +`severityCode`::: +(Required, number) The severity code of the issue. + +For {jira} connectors, specify: + +`issueType`::: +(Required, string) The issue type of the issue. + +`parent`::: +(Required, string) The key of the parent issue, when the issue type is +`Sub-task`. + +`priority`::: +(Required, string) The priority of the issue. + +For {sn-itsm} connectors, specify: + +`category`::: +(Required, string) The category of the incident. + +`impact`::: +(Required, string) The effect an incident had on business. + +`severity`::: +(Required, string) The severity of the incident. + +`subcategory`::: +(Required, string) The subcategory of the incident. + +`urgency`::: +(Required, string) The extent to which the incident resolution can be delayed. + +For {sn-sir} connectors, specify: + +`category`::: +(Required, string) The category of the incident. + +`destIp`::: +(Required, string) A comma separated list of destination IPs. + +`malwareHash`::: +(Required, string) A comma separated list of malware hashes. + +`malwareUrl`::: +(Required, string) A comma separated list of malware URLs. + +`priority`::: +(Required, string) The priority of the incident. + +`sourceIp`::: +(Required, string) A comma separated list of source IPs. + +`subcategory`::: +(Required, string) The subcategory of the incident. + +For {swimlane} connectors, specify: + +`caseId`::: +(Required, string) The identifier for the case. +-- + +`id`:: +(Required, string) The identifier for the connector. To remove the connector, +use `none`. +//To retrieve connector IDs, use <>). + +`name`:: +(Required, string) The name of the connector. To remove the connector, use +`none`. + +`type`:: +(Required, string) The type of the connector. Valid values are: `.jira`, `.none`, +`.resilient`,`.servicenow`, `.servicenow-sir`, and `.swimlane`. To remove the +connector, use `.none`. + +===== + +`description`:: +(Optional, string) The updated case description. + +`id`:: +(Required, string) The identifier for the case. + +`settings`:: +(Optional, object) +An object that contains the case settings. ++ +.Properties of `settings` +[%collapsible%open] +===== +`syncAlerts`:: +(Required, boolean) Turn on or off synching with alerts. +===== + +`status`:: +(Optional, string) The case status. Valid values are: `closed`, `in-progress`, +and `open`. + +`tags`:: +(Optional, string array) The words and phrases that help categorize cases. + +`title`:: +(Optional, string) A title for the case. + +`version`:: +(Required, string) The current version of the case. +//To determine this value, use <> or <> +==== + +=== Response code + +`200`:: + Indicates a successful call. + +=== Example + +Update the description, tags, and connector of case ID +`a18b38a0-71b0-11ea-a0b2-c51ea50a58e2`: + +[source,sh] +-------------------------------------------------- +PATCH api/cases +{ + "cases": [ + { + "id": "a18b38a0-71b0-11ea-a0b2-c51ea50a58e2", + "version": "WzIzLDFd", + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": null, + "parent": null + } + }, + "description": "James Bond clicked on a highly suspicious email + banner advertising cheap holidays for underpaid civil servants. + Operation bubblegum is active. Repeat - operation bubblegum is + now active!", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + } + } + ] +} +-------------------------------------------------- +// KIBANA + +The API returns the updated case with a new `version` value. For example: + +[source,json] +-------------------------------------------------- +[ + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzU0OCwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": "2022-05-13T09:48:33.043Z", + "updated_by": { + "email": "classified@hms.oo.gov.uk", + "full_name": "Classified", + "username": "M" + }, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": null, + } + }, + "external_service": { + "external_title": "IS-4", + "pushed_by": { + "full_name": "Classified", + "email": "classified@hms.oo.gov.uk", + "username": "M" + }, + "external_url": "https://hms.atlassian.net/browse/IS-4", + "pushed_at": "2022-05-13T09:20:40.672Z", + "connector_id": "05da469f-1fde-4058-99a3-91e4807e2de8", + "external_id": "10003", + "connector_name": "Jira" + } + } +] +-------------------------------------------------- From 7640031a5053369140693dfd8601fc47a1cbce07 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 22 Mar 2022 15:34:48 +0100 Subject: [PATCH 036/132] [Uptime] Fix pings over time histogram when filters are defined (#127757) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../ping_histogram/ping_histogram_container.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_histogram/ping_histogram_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_histogram/ping_histogram_container.tsx index d8060e27f1aa2..cd60dcf725074 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_histogram/ping_histogram_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_histogram/ping_histogram_container.tsx @@ -40,9 +40,14 @@ const Container: React.FC = ({ height }) => { const { loading, pingHistogram: data } = useSelector(selectPingHistogram); useEffect(() => { - filterCheck(() => - dispatch(getPingHistogram.get({ monitorId, dateStart, dateEnd, query, filters: esKuery })) - ); + if (monitorId) { + // we don't need filter check on monitor details page, where we have monitorId defined + dispatch(getPingHistogram.get({ monitorId, dateStart, dateEnd, query, filters: esKuery })); + } else { + filterCheck(() => + dispatch(getPingHistogram.get({ monitorId, dateStart, dateEnd, query, filters: esKuery })) + ); + } }, [filterCheck, dateStart, dateEnd, monitorId, lastRefresh, esKuery, dispatch, query]); return ( Date: Tue, 22 Mar 2022 10:35:52 -0400 Subject: [PATCH 037/132] [Fleet] Add install all packages script (#128208) --- .../scripts/install_all_packages/index.js | 9 ++ .../install_all_packages.ts | 118 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 x-pack/plugins/fleet/scripts/install_all_packages/index.js create mode 100644 x-pack/plugins/fleet/scripts/install_all_packages/install_all_packages.ts diff --git a/x-pack/plugins/fleet/scripts/install_all_packages/index.js b/x-pack/plugins/fleet/scripts/install_all_packages/index.js new file mode 100644 index 0000000000000..aa620c4ea6a04 --- /dev/null +++ b/x-pack/plugins/fleet/scripts/install_all_packages/index.js @@ -0,0 +1,9 @@ +/* + * 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. + */ + +require('../../../../../src/setup_node_env'); +require('./install_all_packages').run(); diff --git a/x-pack/plugins/fleet/scripts/install_all_packages/install_all_packages.ts b/x-pack/plugins/fleet/scripts/install_all_packages/install_all_packages.ts new file mode 100644 index 0000000000000..7ff848f79185d --- /dev/null +++ b/x-pack/plugins/fleet/scripts/install_all_packages/install_all_packages.ts @@ -0,0 +1,118 @@ +/* + * 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 fetch from 'node-fetch'; +import { ToolingLog } from '@kbn/dev-utils'; +import ReadPackage from 'read-pkg'; + +const REGISTRY_URL = 'https://epr-snapshot.elastic.co'; +const KIBANA_URL = 'http://localhost:5601'; +const KIBANA_USERNAME = 'elastic'; +const KIBANA_PASSWORD = 'changeme'; + +const KIBANA_VERSION = ReadPackage.sync().version; + +const SKIP_PACKAGES: string[] = []; + +async function installPackage(name: string, version: string) { + const start = Date.now(); + const res = await fetch(`${KIBANA_URL}/api/fleet/epm/packages/${name}/${version}`, { + headers: { + accept: '*/*', + 'content-type': 'application/json', + 'kbn-xsrf': 'xyz', + Authorization: + 'Basic ' + Buffer.from(`${KIBANA_USERNAME}:${KIBANA_PASSWORD}`).toString('base64'), + }, + body: JSON.stringify({ force: true }), + method: 'POST', + }); + const end = Date.now(); + + const body = await res.json(); + + return { body, status: res.status, took: (end - start) / 1000 }; +} + +async function deletePackage(name: string, version: string) { + const res = await fetch(`${KIBANA_URL}/api/fleet/epm/packages/${name}-${version}`, { + headers: { + accept: '*/*', + 'content-type': 'application/json', + 'kbn-xsrf': 'xyz', + Authorization: + 'Basic ' + Buffer.from(`${KIBANA_USERNAME}:${KIBANA_PASSWORD}`).toString('base64'), + }, + method: 'DELETE', + }); + + const body = await res.json(); + + return { body, status: res.status }; +} + +async function getAllPackages() { + const res = await fetch( + `${REGISTRY_URL}/search?experimental=true&kibana.version=${KIBANA_VERSION}`, + { + headers: { + accept: '*/*', + }, + method: 'GET', + } + ); + const body = await res.json(); + return body; +} + +function logResult( + logger: ToolingLog, + pkg: { name: string; version: string }, + result: { took?: number; status?: number } +) { + const pre = `${pkg.name}-${pkg.version} ${result.took ? ` took ${result.took}s` : ''} : `; + if (result.status !== 200) { + logger.info('❌ ' + pre + JSON.stringify(result)); + } else { + logger.info('✅ ' + pre + 200); + } +} + +export async function run() { + const logger = new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }); + const allPackages = await getAllPackages(); + + logger.info('INSTALLING packages'); + + for (const pkg of allPackages) { + if (SKIP_PACKAGES.includes(pkg.name)) { + logger.info(`Skipping ${pkg.name}`); + continue; + } + const result = await installPackage(pkg.name, pkg.version); + + logResult(logger, pkg, result); + } + + const deletePackages = process.argv.includes('--delete'); + + if (!deletePackages) return; + + logger.info('DELETING packages'); + for (const pkg of allPackages) { + if (SKIP_PACKAGES.includes(pkg.name)) { + logger.info(`Skipping ${pkg.name}`); + continue; + } + const result = await deletePackage(pkg.name, pkg.version); + + logResult(logger, pkg, result); + } +} From 4a0b376ad4ba237a1347f1d345ae2153c1e221bd Mon Sep 17 00:00:00 2001 From: srinjon <99788429+srinjon@users.noreply.github.com> Date: Tue, 22 Mar 2022 20:40:39 +0530 Subject: [PATCH 038/132] Update endpoints.mdx (#128114) Fixed a sentence formation Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- dev_docs/tutorials/endpoints.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev_docs/tutorials/endpoints.mdx b/dev_docs/tutorials/endpoints.mdx index 5f2fc7da010c7..f6367580420db 100644 --- a/dev_docs/tutorials/endpoints.mdx +++ b/dev_docs/tutorials/endpoints.mdx @@ -46,7 +46,7 @@ HTTP method. All these APIs share the same signature, and receive two parameters When invoked, the `handler` receive three parameters: `context`, `request`, and `response`, and must return a response that will be sent to serve the request. -- `context` is a request-bound context exposed for the request. It allows for example to use an elasticsearch client bound to the request's credentials. +- `context` is a request-bound context exposed for the request. For example, it allows to use an elasticsearch client bound to the request's credentials. - `request` contains information related to the request, such as the path and query parameter - `response` contains factory helpers to create the response to return from the endpoint From e02b367063c218bf6254e2093fc25d6c772ddaef Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Tue, 22 Mar 2022 11:18:26 -0400 Subject: [PATCH 039/132] [Workplace Search] Submit `base_service_type` field when creating a pre-configured custom source (#128221) --- .../add_custom_source_logic.test.ts | 53 +++++++++++++++++++ .../add_source/add_custom_source_logic.ts | 16 ++++-- .../server/routes/workplace_search/sources.ts | 2 + 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts index 9360967985876..d019c66526e6c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts @@ -150,6 +150,31 @@ describe('AddCustomSourceLogic', () => { expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); + it('submits a base service type for pre-configured sources', () => { + mount( + { + customSourceNameValue: MOCK_NAME, + }, + { + ...MOCK_PROPS, + sourceData: { + ...CUSTOM_SOURCE_DATA_ITEM, + serviceType: 'sharepoint-server', + }, + } + ); + + AddCustomSourceLogic.actions.createContentSource(); + + expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', { + body: JSON.stringify({ + service_type: 'custom', + name: MOCK_NAME, + base_service_type: 'sharepoint-server', + }), + }); + }); + itShowsServerErrorAsFlashMessage(http.post, () => { AddCustomSourceLogic.actions.createContentSource(); }); @@ -173,6 +198,34 @@ describe('AddCustomSourceLogic', () => { ); }); + it('submits a base service type for pre-configured sources', () => { + mount( + { + customSourceNameValue: MOCK_NAME, + }, + { + ...MOCK_PROPS, + sourceData: { + ...CUSTOM_SOURCE_DATA_ITEM, + serviceType: 'sharepoint-server', + }, + } + ); + + AddCustomSourceLogic.actions.createContentSource(); + + expect(http.post).toHaveBeenCalledWith( + '/internal/workplace_search/account/create_source', + { + body: JSON.stringify({ + service_type: 'custom', + name: MOCK_NAME, + base_service_type: 'sharepoint-server', + }), + } + ); + }); + itShowsServerErrorAsFlashMessage(http.post, () => { AddCustomSourceLogic.actions.createContentSource(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts index 5bf86f6df41c7..c35436ccbf99a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts @@ -80,7 +80,7 @@ export const AddCustomSourceLogic = kea< ], sourceData: [props.sourceData], }), - listeners: ({ actions, values }) => ({ + listeners: ({ actions, values, props }) => ({ createContentSource: async () => { clearFlashMessages(); const { isOrganization } = AppLogic.values; @@ -90,14 +90,24 @@ export const AddCustomSourceLogic = kea< const { customSourceNameValue } = values; - const params = { + const baseParams = { service_type: 'custom', name: customSourceNameValue, }; + // pre-configured custom sources have a serviceType reflecting their target service + // we submit this as `base_service_type` to keep track of + const params = + props.sourceData.serviceType === 'custom' + ? baseParams + : { + ...baseParams, + base_service_type: props.sourceData.serviceType, + }; + try { const response = await HttpLogic.values.http.post(route, { - body: JSON.stringify({ ...params }), + body: JSON.stringify(params), }); actions.setNewCustomSource(response); } catch (e) { diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index ba1bd8119a3e5..10fad8b39ddae 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -206,6 +206,7 @@ export function registerAccountCreateSourceRoute({ validate: { body: schema.object({ service_type: schema.string(), + base_service_type: schema.maybe(schema.string()), name: schema.maybe(schema.string()), login: schema.maybe(schema.string()), password: schema.maybe(schema.string()), @@ -566,6 +567,7 @@ export function registerOrgCreateSourceRoute({ validate: { body: schema.object({ service_type: schema.string(), + base_service_type: schema.maybe(schema.string()), name: schema.maybe(schema.string()), login: schema.maybe(schema.string()), password: schema.maybe(schema.string()), From d8a1827b44ec8a41a4297f2f081454f8d1206ef9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 22 Mar 2022 11:20:04 -0400 Subject: [PATCH 040/132] Update dependency node-forge to ^1.3.0 (#128112) --- package.json | 4 ++-- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9f1cdfab2e305..a847db572fa4c 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "**/json-schema": "^0.4.0", "**/minimatch": "^3.1.2", "**/minimist": "^1.2.5", - "**/node-forge": "^1.2.1", + "**/node-forge": "^1.3.0", "**/pdfkit/crypto-js": "4.0.0", "**/react-syntax-highlighter": "^15.3.1", "**/react-syntax-highlighter/**/highlight.js": "^10.4.1", @@ -315,7 +315,7 @@ "mustache": "^2.3.2", "nock": "12.0.3", "node-fetch": "^2.6.7", - "node-forge": "^1.2.1", + "node-forge": "^1.3.0", "nodemailer": "^6.6.2", "normalize-path": "^3.0.0", "object-hash": "^1.3.1", diff --git a/yarn.lock b/yarn.lock index 6df682be18360..396cda03b1235 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20710,10 +20710,10 @@ node-fetch@2.6.1, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.1, node- dependencies: whatwg-url "^5.0.0" -node-forge@^0.10.0, node-forge@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.2.1.tgz#82794919071ef2eb5c509293325cec8afd0fd53c" - integrity sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w== +node-forge@^0.10.0, node-forge@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.0.tgz#37a874ea723855f37db091e6c186e5b67a01d4b2" + integrity sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA== node-gyp-build@^4.2.3: version "4.2.3" From e9d0769a3d6c1dd586aeaba591a17c02489c2749 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 22 Mar 2022 16:36:45 +0100 Subject: [PATCH 041/132] View request flyout (#127156) * wip: create code scaffold for component * Implement new flyout in ingest pipelines * Fix linter and i18n issues * Add base tests for new component * Wire everything together * Fix linter issues * Finish writing tests * fix linting issues * Refactor hook and fix small dependencies bug * commit using @elastic.co * Refactor out hook and fix linter issues * Enhance tests and fix typo * Refactor component name and fix tests * update snapshot * Address first round of CR * Update snapshot * Refactor apirequestflyout to consume applicationStart only * Fix import order --- src/plugins/es_ui_shared/kibana.json | 2 +- .../view_api_request_flyout.test.tsx.snap | 118 ++++++++++++++ .../view_api_request_flyout/index.ts | 9 ++ .../view_api_request_flyout.test.tsx | 93 +++++++++++ .../view_api_request_flyout.tsx | 147 ++++++++++++++++++ src/plugins/es_ui_shared/public/index.ts | 1 + .../helpers/pipeline_form.helpers.ts | 4 +- .../helpers/setup_environment.tsx | 16 ++ .../ingest_pipelines_create.test.tsx | 4 +- .../pipeline_request_flyout/index.ts | 2 +- .../pipeline_request_flyout.tsx | 113 ++++++-------- .../pipeline_request_flyout_provider.tsx | 46 ------ .../public/application/index.tsx | 2 + .../application/mount_management_section.ts | 2 + .../ingest_pipelines/public/shared_imports.ts | 1 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 18 files changed, 440 insertions(+), 123 deletions(-) create mode 100644 src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap create mode 100644 src/plugins/es_ui_shared/public/components/view_api_request_flyout/index.ts create mode 100644 src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.test.tsx create mode 100644 src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.tsx delete mode 100644 x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx diff --git a/src/plugins/es_ui_shared/kibana.json b/src/plugins/es_ui_shared/kibana.json index 2735b153f738c..1a4ff33674f95 100644 --- a/src/plugins/es_ui_shared/kibana.json +++ b/src/plugins/es_ui_shared/kibana.json @@ -14,5 +14,5 @@ "static/forms/components", "static/forms/helpers/field_validators/types" ], - "requiredBundles": ["data"] + "requiredBundles": ["data", "kibanaReact"] } diff --git a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap new file mode 100644 index 0000000000000..2d850ee8082f9 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ViewApiRequestFlyout is rendered 1`] = ` +
+ + +
+
+
+
+            
+              Hello world
+            
+          
+
+
+ + +
+ +
+ +`; diff --git a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/index.ts b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/index.ts new file mode 100644 index 0000000000000..deed3c5db27d6 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { ViewApiRequestFlyout } from './view_api_request_flyout'; diff --git a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.test.tsx b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.test.tsx new file mode 100644 index 0000000000000..4f6c954d4c37d --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.test.tsx @@ -0,0 +1,93 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithI18nProvider } from '@kbn/test-jest-helpers'; +import { findTestSubject, takeMountedSnapshot } from '@elastic/eui/lib/test'; +import { compressToEncodedURIComponent } from 'lz-string'; + +import { ViewApiRequestFlyout } from './view_api_request_flyout'; +import type { UrlService } from 'src/plugins/share/common/url_service'; +import { ApplicationStart } from 'src/core/public'; +import { applicationServiceMock } from 'src/core/public/mocks'; + +const payload = { + title: 'Test title', + description: 'Test description', + request: 'Hello world', + closeFlyout: jest.fn(), +}; + +const urlServiceMock = { + locators: { + get: jest.fn().mockReturnValue({ + useUrl: jest.fn().mockImplementation((value) => { + return `devToolsUrl_${value?.loadFrom}`; + }), + }), + }, +} as any as UrlService; + +const applicationMock = { + ...applicationServiceMock.createStartContract(), + capabilities: { + dev_tools: { + show: true, + }, + }, +} as any as ApplicationStart; + +describe('ViewApiRequestFlyout', () => { + test('is rendered', () => { + const component = mountWithI18nProvider(); + expect(takeMountedSnapshot(component)).toMatchSnapshot(); + }); + + describe('props', () => { + test('on closeFlyout', async () => { + const component = mountWithI18nProvider(); + + await act(async () => { + findTestSubject(component, 'apiRequestFlyoutClose').simulate('click'); + }); + + expect(payload.closeFlyout).toBeCalled(); + }); + + test('doesnt have openInConsole when some optional props are not supplied', async () => { + const component = mountWithI18nProvider(); + + const openInConsole = findTestSubject(component, 'apiRequestFlyoutOpenInConsoleButton'); + expect(openInConsole.length).toEqual(0); + + // Flyout should *not* be wrapped with RedirectAppLinks + const redirectWrapper = findTestSubject(component, 'apiRequestFlyoutRedirectWrapper'); + expect(redirectWrapper.length).toEqual(0); + }); + + test('has openInConsole when all optional props are supplied', async () => { + const encodedRequest = compressToEncodedURIComponent(payload.request); + const component = mountWithI18nProvider( + + ); + + const openInConsole = findTestSubject(component, 'apiRequestFlyoutOpenInConsoleButton'); + expect(openInConsole.length).toEqual(1); + expect(openInConsole.props().href).toEqual(`devToolsUrl_data:text/plain,${encodedRequest}`); + + // Flyout should be wrapped with RedirectAppLinks + const redirectWrapper = findTestSubject(component, 'apiRequestFlyoutRedirectWrapper'); + expect(redirectWrapper.length).toEqual(1); + }); + }); +}); diff --git a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.tsx b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.tsx new file mode 100644 index 0000000000000..fa7bc6088f5c0 --- /dev/null +++ b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/view_api_request_flyout.tsx @@ -0,0 +1,147 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { compressToEncodedURIComponent } from 'lz-string'; + +import { + EuiFlyout, + EuiFlyoutProps, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButtonEmpty, + EuiText, + EuiSpacer, + EuiCodeBlock, + EuiCopy, +} from '@elastic/eui'; +import type { UrlService } from 'src/plugins/share/common/url_service'; +import { ApplicationStart, APP_WRAPPER_CLASS } from '../../../../../core/public'; +import { RedirectAppLinks } from '../../../../kibana_react/public'; + +type FlyoutProps = Omit; +interface ViewApiRequestFlyoutProps { + title: string; + description: string; + request: string; + closeFlyout: () => void; + flyoutProps?: FlyoutProps; + application?: ApplicationStart; + urlService?: UrlService; +} + +export const ApiRequestFlyout: React.FunctionComponent = ({ + title, + description, + request, + closeFlyout, + flyoutProps, + urlService, + application, +}) => { + const getUrlParams = undefined; + const canShowDevtools = !!application?.capabilities?.dev_tools?.show; + const devToolsDataUri = compressToEncodedURIComponent(request); + + // Generate a console preview link if we have a valid locator + const consolePreviewLink = urlService?.locators.get('CONSOLE_APP_LOCATOR')?.useUrl( + { + loadFrom: `data:text/plain,${devToolsDataUri}`, + }, + getUrlParams, + [request] + ); + + // Check if both the Dev Tools UI and the Console UI are enabled. + const shouldShowDevToolsLink = canShowDevtools && consolePreviewLink !== undefined; + + return ( + + + +

{title}

+
+
+ + + +

{description}

+
+ + + +
+ + {(copy) => ( + + + + )} + + {shouldShowDevToolsLink && ( + + + + )} +
+ + + {request} + +
+ + + + + + +
+ ); +}; + +export const ViewApiRequestFlyout = (props: ViewApiRequestFlyoutProps) => { + if (props.application) { + return ( + + + + ); + } + + return ; +}; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index c21587c9a6040..8a861ac993170 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -25,6 +25,7 @@ export type { EuiCodeEditorProps } from './components/code_editor'; export { EuiCodeEditor } from './components/code_editor'; export type { Frequency } from './components/cron_editor'; export { CronEditor } from './components/cron_editor'; +export { ViewApiRequestFlyout } from './components/view_api_request_flyout'; export type { SendRequestConfig, diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts index 432b9046f1071..775d05a865189 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts @@ -61,8 +61,8 @@ export type PipelineFormTestSubjects = | 'onFailureEditor' | 'testPipelineButton' | 'showRequestLink' - | 'requestFlyout' - | 'requestFlyout.title' + | 'apiRequestFlyout' + | 'apiRequestFlyout.apiRequestFlyoutTitle' | 'testPipelineFlyout' | 'testPipelineFlyout.title' | 'documentationLink'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index 8e128692c41c5..96a0f9e23348a 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -11,6 +11,8 @@ import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { LocationDescriptorObject } from 'history'; import { HttpSetup } from 'kibana/public'; +import { ApplicationStart } from 'src/core/public'; +import { MockUrlService } from 'src/plugins/share/common/mocks'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { sharePluginMock } from '../../../../../../src/plugins/share/public/mocks'; import { @@ -18,6 +20,7 @@ import { docLinksServiceMock, scopedHistoryMock, uiSettingsServiceMock, + applicationServiceMock, } from '../../../../../../src/core/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../src/plugins/usage_collection/public/mocks'; @@ -38,6 +41,15 @@ history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; }); +const applicationMock = { + ...applicationServiceMock.createStartContract(), + capabilities: { + dev_tools: { + show: true, + }, + }, +} as any as ApplicationStart; + const appServices = { breadcrumbs: breadcrumbService, metric: uiMetricService, @@ -54,6 +66,10 @@ const appServices = { getMaxBytes: jest.fn().mockReturnValue(100), getMaxBytesFormatted: jest.fn().mockReturnValue('100'), }, + application: applicationMock, + share: { + url: new MockUrlService(), + }, }; export const setupEnvironment = () => { diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx index bb1d3f2503f9b..5be5cecd750f6 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx @@ -79,8 +79,8 @@ describe('', () => { await actions.clickShowRequestLink(); // Verify request flyout opens - expect(exists('requestFlyout')).toBe(true); - expect(find('requestFlyout.title').text()).toBe('Request'); + expect(exists('apiRequestFlyout')).toBe(true); + expect(find('apiRequestFlyout.apiRequestFlyoutTitle').text()).toBe('Request'); }); describe('form validation', () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/index.ts index 5905d10faad85..8368dbf93b96c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { PipelineRequestFlyoutProvider as PipelineRequestFlyout } from './pipeline_request_flyout_provider'; +export { PipelineRequestFlyout } from './pipeline_request_flyout'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx index feb7d55145083..66d95c18663c0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx @@ -5,86 +5,63 @@ * 2.0. */ -import React, { useRef } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { - EuiButtonEmpty, - EuiCodeBlock, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; +import React, { useState, useEffect, FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; import { Pipeline } from '../../../../../common/types'; +import { useFormContext, ViewApiRequestFlyout, useKibana } from '../../../../shared_imports'; + +import { ReadProcessorsFunction } from '../types'; interface Props { - pipeline: Pipeline; closeFlyout: () => void; + readProcessors: ReadProcessorsFunction; } -export const PipelineRequestFlyout: React.FunctionComponent = ({ +export const PipelineRequestFlyout: FunctionComponent = ({ closeFlyout, - pipeline, + readProcessors, }) => { - const { name, ...pipelineBody } = pipeline; - const endpoint = `PUT _ingest/pipeline/${name || ''}`; - const payload = JSON.stringify(pipelineBody, null, 2); - const request = `${endpoint}\n${payload}`; - // Hack so that copied-to-clipboard value updates as content changes - // Related issue: https://github.com/elastic/eui/issues/3321 - const uuid = useRef(0); - uuid.current++; + const { services } = useKibana(); + const form = useFormContext(); + const [formData, setFormData] = useState({} as Pipeline); + const pipeline = { ...formData, ...readProcessors() }; - return ( - - - -

- {name ? ( - - ) : ( - - )} -

-
-
+ useEffect(() => { + const subscription = form.subscribe(async ({ isValid, validate, data }) => { + const isFormValid = isValid ?? (await validate()); + if (isFormValid) { + setFormData(data.format() as Pipeline); + } + }); - - -

- -

-
+ return subscription.unsubscribe; + }, [form]); - - - {request} - -
+ const { name, ...pipelineBody } = pipeline; + const endpoint = `PUT _ingest/pipeline/${name || ''}`; + const request = `${endpoint}\n${JSON.stringify(pipelineBody, null, 2)}`; - - - - - -
+ const title = name + ? i18n.translate('xpack.ingestPipelines.requestFlyout.namedTitle', { + defaultMessage: "Request for '{name}'", + values: { name }, + }) + : i18n.translate('xpack.ingestPipelines.requestFlyout.unnamedTitle', { + defaultMessage: 'Request', + }); + + return ( + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx deleted file mode 100644 index 0b91b07a5a526..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx +++ /dev/null @@ -1,46 +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 React, { useState, useEffect, FunctionComponent } from 'react'; - -import { Pipeline } from '../../../../../common/types'; -import { useFormContext } from '../../../../shared_imports'; - -import { ReadProcessorsFunction } from '../types'; - -import { PipelineRequestFlyout } from './pipeline_request_flyout'; - -interface Props { - closeFlyout: () => void; - readProcessors: ReadProcessorsFunction; -} - -export const PipelineRequestFlyoutProvider: FunctionComponent = ({ - closeFlyout, - readProcessors, -}) => { - const form = useFormContext(); - const [formData, setFormData] = useState({} as Pipeline); - - useEffect(() => { - const subscription = form.subscribe(async ({ isValid, validate, data }) => { - const isFormValid = isValid ?? (await validate()); - if (isFormValid) { - setFormData(data.format() as Pipeline); - } - }); - - return subscription.unsubscribe; - }, [form]); - - return ( - - ); -}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx index bab3a1e0a074a..91c7665503a66 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/index.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -10,6 +10,7 @@ import React, { ReactNode } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Observable } from 'rxjs'; +import { ApplicationStart } from 'src/core/public'; import { NotificationsSetup, IUiSettingsClient, CoreTheme } from 'kibana/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import type { SharePluginStart } from 'src/plugins/share/public'; @@ -40,6 +41,7 @@ export interface AppServices { uiSettings: IUiSettingsClient; share: SharePluginStart; fileUpload: FileUploadPluginStart; + application: ApplicationStart; } export interface CoreServices { diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts index f90b8077e6281..81f7be35074d8 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -26,6 +26,7 @@ export async function mountManagementSection( const [coreStart, depsStart] = await getStartServices(); const { docLinks, + application, i18n: { Context: I18nContext }, } = coreStart; @@ -43,6 +44,7 @@ export async function mountManagementSection( uiSettings: coreStart.uiSettings, share: depsStart.share, fileUpload: depsStart.fileUpload, + application, }; return renderApp(element, I18nContext, services, { http }, { theme$ }); diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index f4c24f622e752..90ccf78355f1a 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -30,6 +30,7 @@ export { XJson, JsonEditor, attemptToURIDecode, + ViewApiRequestFlyout, } from '../../../../src/plugins/es_ui_shared/public/'; export type { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index b4832c4cd24c2..05ab45fc2756f 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -13609,7 +13609,6 @@ "xpack.ingestPipelines.processors.label.urldecode": "Décodage d'URL", "xpack.ingestPipelines.processors.label.userAgent": "Agent utilisateur", "xpack.ingestPipelines.processors.uriPartsDescription": "Analyse une chaîne d'URI (Uniform Resource Identifier, identifiant uniforme de ressource) et extrait ses composants sous forme d'objet.", - "xpack.ingestPipelines.requestFlyout.closeButtonLabel": "Fermer", "xpack.ingestPipelines.requestFlyout.descriptionText": "Cette requête Elasticsearch créera ou mettra à jour le pipeline.", "xpack.ingestPipelines.requestFlyout.namedTitle": "Requête pour \"{name}\"", "xpack.ingestPipelines.requestFlyout.unnamedTitle": "Requête", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 846ef5ef2ad28..bcdafe3c8c050 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -15741,7 +15741,6 @@ "xpack.ingestPipelines.processors.label.urldecode": "URLデコード", "xpack.ingestPipelines.processors.label.userAgent": "ユーザーエージェント", "xpack.ingestPipelines.processors.uriPartsDescription": "Uniform Resource Identifier(URI)文字列を解析し、コンポーネントをオブジェクトとして抽出します。", - "xpack.ingestPipelines.requestFlyout.closeButtonLabel": "閉じる", "xpack.ingestPipelines.requestFlyout.descriptionText": "このElasticsearchリクエストは、このパイプラインを作成または更新します。", "xpack.ingestPipelines.requestFlyout.namedTitle": "「{name}」のリクエスト", "xpack.ingestPipelines.requestFlyout.unnamedTitle": "リクエスト", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3a9065a878085..d2dbc9904b9a1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15765,7 +15765,6 @@ "xpack.ingestPipelines.processors.label.urldecode": "URL 解码", "xpack.ingestPipelines.processors.label.userAgent": "用户代理", "xpack.ingestPipelines.processors.uriPartsDescription": "解析统一资源标识符 (URI) 字符串并提取其组件作为对象。", - "xpack.ingestPipelines.requestFlyout.closeButtonLabel": "关闭", "xpack.ingestPipelines.requestFlyout.descriptionText": "此 Elasticsearch 请求将创建或更新管道。", "xpack.ingestPipelines.requestFlyout.namedTitle": "对“{name}”的请求", "xpack.ingestPipelines.requestFlyout.unnamedTitle": "请求", From 4b474815669648ca72cbbc7e366a647558907c97 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Tue, 22 Mar 2022 17:04:51 +0100 Subject: [PATCH 042/132] [Security Solution] [Timeline] Fields browser add a view all / selected option (#128049) * view selected option added * new header component * test fixed * Update x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.test.tsx use not.toBeInTheDocument Co-authored-by: Pablo Machado * pass callback down instead of state setter Co-authored-by: Pablo Machado Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../integration/hosts/events_viewer.spec.ts | 14 +- .../timelines/fields_browser.spec.ts | 15 +- .../cypress/screens/fields_browser.ts | 5 + .../cypress/tasks/fields_browser.ts | 12 ++ .../body/column_headers/default_headers.ts | 3 - .../fields_browser/field_browser.test.tsx | 7 +- .../toolbar/fields_browser/field_browser.tsx | 16 +- .../fields_browser/field_table.test.tsx | 10 +- .../toolbar/fields_browser/field_table.tsx | 22 ++- .../field_table_header.test.tsx | 119 +++++++++++ .../fields_browser/field_table_header.tsx | 112 +++++++++++ .../toolbar/fields_browser/helpers.test.tsx | 186 ++++++------------ .../t_grid/toolbar/fields_browser/helpers.tsx | 80 ++++---- .../t_grid/toolbar/fields_browser/index.tsx | 42 ++-- .../toolbar/fields_browser/translations.ts | 12 ++ 15 files changed, 443 insertions(+), 212 deletions(-) create mode 100644 x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.test.tsx create mode 100644 x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.tsx diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts index 47e71345ff0c4..25883b5156407 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/events_viewer.spec.ts @@ -9,6 +9,7 @@ import { FIELDS_BROWSER_CHECKBOX, FIELDS_BROWSER_CONTAINER, FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES, + FIELDS_BROWSER_VIEW_BUTTON, } from '../../screens/fields_browser'; import { HOST_GEO_CITY_NAME_HEADER, @@ -18,9 +19,10 @@ import { } from '../../screens/hosts/events'; import { + activateViewAll, + activateViewSelected, closeFieldsBrowser, filterFieldsBrowser, - toggleCategory, } from '../../tasks/fields_browser'; import { loginAndWaitForPage } from '../../tasks/login'; import { openEvents } from '../../tasks/hosts/main'; @@ -64,16 +66,20 @@ describe('Events Viewer', () => { cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); }); + it('displays "view all" option by default', () => { + cy.get(FIELDS_BROWSER_VIEW_BUTTON).should('contain.text', 'View: all'); + }); + it('displays all categories (by default)', () => { cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty'); }); - it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => { - const category = 'default ECS'; - toggleCategory(category); + it('displays only the default selected fields when "view selected" option is enabled', () => { + activateViewSelected(); defaultHeadersInDefaultEcsCategory.forEach((header) => cy.get(FIELDS_BROWSER_CHECKBOX(header.id)).should('be.checked') ); + activateViewAll(); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index 89a9fc4c0c6ba..580868fa0452c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -15,6 +15,7 @@ import { FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER, FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES, FIELDS_BROWSER_CATEGORY_BADGE, + FIELDS_BROWSER_VIEW_BUTTON, } from '../../screens/fields_browser'; import { TIMELINE_FIELDS_BUTTON } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; @@ -29,6 +30,8 @@ import { removesMessageField, resetFields, toggleCategory, + activateViewSelected, + activateViewAll, } from '../../tasks/fields_browser'; import { loginAndWaitForPage } from '../../tasks/login'; import { openTimelineUsingToggle } from '../../tasks/security_main'; @@ -65,6 +68,10 @@ describe('Fields Browser', () => { cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty'); }); + it('displays "view all" option by default', () => { + cy.get(FIELDS_BROWSER_VIEW_BUTTON).should('contain.text', 'View: all'); + }); + it('displays the expected count of categories that match the filter input', () => { const filterInput = 'host.mac'; @@ -80,15 +87,13 @@ describe('Fields Browser', () => { cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', '2'); }); - it('the `default ECS` category matches the default timeline header fields', () => { - const category = 'default ECS'; - toggleCategory(category); + it('displays only the selected fields when "view selected" option is enabled', () => { + activateViewSelected(); cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', `${defaultHeaders.length}`); - defaultHeaders.forEach((header) => { cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked'); }); - toggleCategory(category); + activateViewAll(); }); it('creates the category badge when it is selected', () => { diff --git a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts index 66a7ba50c8070..a9898f73207d7 100644 --- a/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/screens/fields_browser.ts @@ -17,6 +17,11 @@ export const FIELDS_BROWSER_FIELDS_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-te export const FIELDS_BROWSER_FILTER_INPUT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-search"]`; +export const FIELDS_BROWSER_VIEW_BUTTON = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="viewSelectorButton"]`; +export const FIELDS_BROWSER_VIEW_MENU = '[data-test-subj="viewSelectorMenu"]'; +export const FIELDS_BROWSER_VIEW_ALL = `${FIELDS_BROWSER_VIEW_MENU} [data-test-subj="viewSelectorOption-all"]`; +export const FIELDS_BROWSER_VIEW_SELECTED = `${FIELDS_BROWSER_VIEW_MENU} [data-test-subj="viewSelectorOption-selected"]`; + export const FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-host.geo.city_name-checkbox"]`; export const FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER = diff --git a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts index 04b59305b591a..6abc4b11aa59e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/fields_browser.ts @@ -16,6 +16,9 @@ import { FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON, FIELDS_BROWSER_CATEGORY_FILTER_OPTION, FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH, + FIELDS_BROWSER_VIEW_ALL, + FIELDS_BROWSER_VIEW_BUTTON, + FIELDS_BROWSER_VIEW_SELECTED, } from '../screens/fields_browser'; export const addsFields = (fields: string[]) => { @@ -74,3 +77,12 @@ export const removesMessageField = () => { export const resetFields = () => { cy.get(FIELDS_BROWSER_RESET_FIELDS).click({ force: true }); }; + +export const activateViewSelected = () => { + cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true }); + cy.get(FIELDS_BROWSER_VIEW_SELECTED).click({ force: true }); +}; +export const activateViewAll = () => { + cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true }); + cy.get(FIELDS_BROWSER_VIEW_ALL).click({ force: true }); +}; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.ts b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.ts index 9a32c514e7064..a5fb5f4bacd43 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/column_headers/default_headers.ts @@ -53,6 +53,3 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; - -/** The default category of fields shown in the Timeline */ -export const DEFAULT_CATEGORY_NAME = 'default ECS'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx index ed665155ddcf5..662608155d290 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.test.tsx @@ -12,7 +12,7 @@ import { TestProviders, mockBrowserFields, defaultHeaders } from '../../../../mo import { mockGlobalState } from '../../../../mock/global_state'; import { tGridActions } from '../../../../store/t_grid'; -import { FieldsBrowser } from './field_browser'; +import { FieldsBrowser, FieldsBrowserComponentProps } from './field_browser'; import { createStore, State } from '../../../../types'; import { createSecuritySolutionStorageMock } from '../../../../mock/mock_local_storage'; @@ -27,9 +27,8 @@ jest.mock('react-redux', () => { }); const timelineId = 'test'; const onHide = jest.fn(); -const testProps = { +const testProps: FieldsBrowserComponentProps = { columnHeaders: [], - browserFields: mockBrowserFields, filteredBrowserFields: mockBrowserFields, searchInput: '', appliedFilterInput: '', @@ -40,6 +39,8 @@ const testProps = { restoreFocusTo: React.createRef(), selectedCategoryIds: [], timelineId, + filterSelectedEnabled: false, + onFilterSelectedChange: jest.fn(), }; const { storage } = createSecuritySolutionStorageMock(); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx index c67d7cbe633b7..091f4cd79c52f 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_browser.tsx @@ -33,7 +33,10 @@ import { CategoriesSelector } from './categories_selector'; import { FieldTable } from './field_table'; import { CategoriesBadges } from './categories_badges'; -type Props = Pick & { +export type FieldsBrowserComponentProps = Pick< + FieldBrowserProps, + 'timelineId' | 'width' | 'options' +> & { /** * The current timeline column headers */ @@ -44,6 +47,9 @@ type Props = Pick void; /** * When true, a busy spinner will be shown to indicate the field browser * is searching for fields that match the specified `searchInput` @@ -83,17 +89,19 @@ type Props = Pick = ({ +const FieldsBrowserComponent: React.FC = ({ + appliedFilterInput, columnHeaders, filteredBrowserFields, + filterSelectedEnabled, isSearching, + onFilterSelectedChange, setSelectedCategoryIds, onSearchInputChange, onHide, options, restoreFocusTo, searchInput, - appliedFilterInput, selectedCategoryIds, timelineId, width = FIELD_BROWSER_WIDTH, @@ -182,8 +190,10 @@ const FieldsBrowserComponent: React.FC = ({ timelineId={timelineId} columnHeaders={columnHeaders} filteredBrowserFields={filteredBrowserFields} + filterSelectedEnabled={filterSelectedEnabled} searchInput={appliedFilterInput} selectedCategoryIds={selectedCategoryIds} + onFilterSelectedChange={onFilterSelectedChange} getFieldTableColumns={getFieldTableColumns} onHide={onHide} /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx index e6721c50f6e1c..151ed99c3621c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx @@ -46,16 +46,14 @@ const defaultProps: FieldTableProps = { filteredBrowserFields: {}, searchInput: '', timelineId, + filterSelectedEnabled: false, + onFilterSelectedChange: jest.fn(), onHide: jest.fn(), }; describe('FieldTable', () => { const timestampField = mockBrowserFields.base.fields![timestampFieldId]; const defaultPageSize = 10; - const totalFields = Object.values(mockBrowserFields).reduce( - (total, { fields }) => total + Object.keys(fields ?? {}).length, - 0 - ); beforeEach(() => { mockDispatch.mockClear(); @@ -69,7 +67,6 @@ describe('FieldTable', () => { ); expect(result.getByText('No items found')).toBeInTheDocument(); - expect(result.getByTestId('fields-count').textContent).toContain('0'); }); it('should render field table with fields of all categories', () => { @@ -80,7 +77,6 @@ describe('FieldTable', () => { ); expect(result.container.getElementsByClassName('euiTableRow').length).toBe(defaultPageSize); - expect(result.getByTestId('fields-count').textContent).toContain(totalFields); }); it('should render field table with fields of categories selected', () => { @@ -103,7 +99,6 @@ describe('FieldTable', () => { ); expect(result.container.getElementsByClassName('euiTableRow').length).toBe(fieldCount); - expect(result.getByTestId('fields-count').textContent).toContain(fieldCount); }); it('should render field table with custom columns', () => { @@ -125,7 +120,6 @@ describe('FieldTable', () => { ); - expect(result.getByTestId('fields-count').textContent).toContain(totalFields); expect(result.getAllByText('Custom column').length).toBeGreaterThan(0); expect(result.getAllByTestId('customColumn').length).toEqual(defaultPageSize); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx index f578d4e1b9dca..684b09d0395ab 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx @@ -7,14 +7,14 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; -import { EuiInMemoryTable, EuiText } from '@elastic/eui'; +import { EuiInMemoryTable } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { BrowserFields, ColumnHeaderOptions } from '../../../../../common'; -import * as i18n from './translations'; import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items'; import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers'; import { tGridActions } from '../../../../store/t_grid'; import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser'; +import { FieldTableHeader } from './field_table_header'; export interface FieldTableProps { timelineId: string; @@ -25,6 +25,9 @@ export interface FieldTableProps { * the filter input (as a substring). */ filteredBrowserFields: BrowserFields; + /** when true, show only the the selected field */ + filterSelectedEnabled: boolean; + onFilterSelectedChange: (enabled: boolean) => void; /** * Optional function to customize field table columns */ @@ -58,9 +61,11 @@ Count.displayName = 'Count'; const FieldTableComponent: React.FC = ({ columnHeaders, filteredBrowserFields, + filterSelectedEnabled, getFieldTableColumns, searchInput, selectedCategoryIds, + onFilterSelectedChange, timelineId, onHide, }) => { @@ -106,13 +111,13 @@ const FieldTableComponent: React.FC = ({ return ( <> - - {i18n.FIELDS_SHOWING} - {fieldItems.length} - {i18n.FIELDS_COUNT(fieldItems.length)} - + - + = ({ pagination={true} sorting={true} hasActions={hasActions} + compressed /> diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.test.tsx new file mode 100644 index 0000000000000..e7c8f5b7fe7a4 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../mock'; +import { FieldTableHeader, FieldTableHeaderProps } from './field_table_header'; + +const mockOnFilterSelectedChange = jest.fn(); +const defaultProps: FieldTableHeaderProps = { + fieldCount: 0, + filterSelectedEnabled: false, + onFilterSelectedChange: mockOnFilterSelectedChange, +}; + +describe('FieldTableHeader', () => { + describe('FieldCount', () => { + it('should render empty field table', () => { + const result = render( + + + + ); + + expect(result.getByTestId('fields-showing').textContent).toBe('Showing 0 fields'); + }); + + it('should render field table with one singular field count value', () => { + const result = render( + + + + ); + + expect(result.getByTestId('fields-showing').textContent).toBe('Showing 1 field'); + }); + it('should render field table with multiple fields count value', () => { + const result = render( + + + + ); + + expect(result.getByTestId('fields-showing').textContent).toBe('Showing 4 fields'); + }); + }); + + describe('View selected', () => { + beforeEach(() => { + mockOnFilterSelectedChange.mockClear(); + }); + + it('should render "view all" option when filterSelected is not enabled', () => { + const result = render( + + + + ); + + expect(result.getByTestId('viewSelectorButton').textContent).toBe('View: all'); + }); + + it('should render "view selected" option when filterSelected is not enabled', () => { + const result = render( + + + + ); + + expect(result.getByTestId('viewSelectorButton').textContent).toBe('View: selected'); + }); + + it('should open the view selector with button click', async () => { + const result = render( + + + + ); + + expect(result.queryByTestId('viewSelectorMenu')).not.toBeInTheDocument(); + expect(result.queryByTestId('viewSelectorOption-all')).not.toBeInTheDocument(); + expect(result.queryByTestId('viewSelectorOption-selected')).not.toBeInTheDocument(); + + result.getByTestId('viewSelectorButton').click(); + + expect(result.getByTestId('viewSelectorMenu')).toBeInTheDocument(); + expect(result.getByTestId('viewSelectorOption-all')).toBeInTheDocument(); + expect(result.getByTestId('viewSelectorOption-selected')).toBeInTheDocument(); + }); + + it('should callback when "view all" option is clicked', () => { + const result = render( + + + + ); + + result.getByTestId('viewSelectorButton').click(); + result.getByTestId('viewSelectorOption-all').click(); + expect(mockOnFilterSelectedChange).toHaveBeenCalledWith(false); + }); + + it('should callback when "view selected" option is clicked', () => { + const result = render( + + + + ); + + result.getByTestId('viewSelectorButton').click(); + result.getByTestId('viewSelectorOption-selected').click(); + expect(mockOnFilterSelectedChange).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.tsx new file mode 100644 index 0000000000000..ed7cc1e55b9c0 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import styled from 'styled-components'; +import { + EuiText, + EuiPopover, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import * as i18n from './translations'; + +export interface FieldTableHeaderProps { + fieldCount: number; + filterSelectedEnabled: boolean; + onFilterSelectedChange: (enabled: boolean) => void; +} + +const Count = styled.span` + font-weight: bold; +`; +Count.displayName = 'Count'; + +const FieldTableHeaderComponent: React.FC = ({ + fieldCount, + filterSelectedEnabled, + onFilterSelectedChange, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((open) => !open); + }, []); + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + return ( + + + + {i18n.FIELDS_SHOWING} + {fieldCount} + {i18n.FIELDS_COUNT(fieldCount)} + + + + + {`${i18n.VIEW_LABEL}: ${ + filterSelectedEnabled ? i18n.VIEW_VALUE_SELECTED : i18n.VIEW_VALUE_ALL + }`} + + } + > + { + onFilterSelectedChange(false); + closePopover(); + }} + > + {`${i18n.VIEW_LABEL} ${i18n.VIEW_VALUE_ALL}`} + , + , + { + onFilterSelectedChange(true); + closePopover(); + }} + > + {`${i18n.VIEW_LABEL} ${i18n.VIEW_VALUE_SELECTED}`} + , + ]} + /> + + + + ); +}; + +export const FieldTableHeader = React.memo(FieldTableHeaderComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx index ad90956013e41..8f4377ce020fd 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.test.tsx @@ -9,11 +9,12 @@ import { mockBrowserFields } from '../../../../mock'; import { categoryHasFields, - createVirtualCategory, getFieldCount, filterBrowserFieldsByFieldName, + filterSelectedBrowserFields, } from './helpers'; import { BrowserFields } from '../../../../../common/search_strategy'; +import { ColumnHeaderOptions } from '../../../../../common'; describe('helpers', () => { describe('categoryHasFields', () => { @@ -255,144 +256,83 @@ describe('helpers', () => { }); }); - describe('createVirtualCategory', () => { - test('it combines the specified fields into a virtual category when the input ONLY contains field names that contain dots (e.g. agent.hostname)', () => { - const expectedMatchingFields = { - fields: { - 'agent.hostname': { - aggregatable: true, - category: 'agent', - description: null, - example: null, - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'agent.hostname', - searchable: true, - type: 'string', - }, - 'client.domain': { - aggregatable: true, - category: 'client', - description: 'Client domain.', - example: null, - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'client.domain', - searchable: true, - type: 'string', - }, - 'client.geo.country_iso_code': { - aggregatable: true, - category: 'client', - description: 'Country ISO code.', - example: 'CA', - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'client.geo.country_iso_code', - searchable: true, - type: 'string', - }, - }, - }; - - const fieldIds = ['agent.hostname', 'client.domain', 'client.geo.country_iso_code']; + describe('filterSelectedBrowserFields', () => { + const columnHeaders = [ + { id: 'agent.ephemeral_id' }, + { id: 'agent.id' }, + { id: 'container.id' }, + ] as ColumnHeaderOptions[]; - expect( - createVirtualCategory({ - browserFields: mockBrowserFields, - fieldIds, - }) - ).toEqual(expectedMatchingFields); + test('it returns an empty collection when browserFields is empty', () => { + expect(filterSelectedBrowserFields({ browserFields: {}, columnHeaders: [] })).toEqual({}); }); - test('it combines the specified fields into a virtual category when the input includes field names from the base category that do NOT contain dots (e.g. @timestamp)', () => { - const expectedMatchingFields = { - fields: { - 'agent.hostname': { - aggregatable: true, - category: 'agent', - description: null, - example: null, - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'agent.hostname', - searchable: true, - type: 'string', - }, - '@timestamp': { - aggregatable: true, - category: 'base', - description: - 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', - example: '2016-05-23T08:05:34.853Z', - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: '@timestamp', - searchable: true, - type: 'date', - }, - 'client.domain': { - aggregatable: true, - category: 'client', - description: 'Client domain.', - example: null, - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'client.domain', - searchable: true, - type: 'string', - }, - }, - }; - - const fieldIds = ['agent.hostname', '@timestamp', 'client.domain']; + test('it returns an empty collection when browserFields is empty and columnHeaders is non empty', () => { + expect(filterSelectedBrowserFields({ browserFields: {}, columnHeaders })).toEqual({}); + }); + test('it returns an empty collection when browserFields is NOT empty and columnHeaders is empty', () => { expect( - createVirtualCategory({ + filterSelectedBrowserFields({ browserFields: mockBrowserFields, - fieldIds, + columnHeaders: [], }) - ).toEqual(expectedMatchingFields); + ).toEqual({}); }); - test('it combines the specified fields into a virtual category omitting the fields missing in the browser fields', () => { - const expectedMatchingFields = { - fields: { - '@timestamp': { - aggregatable: true, - category: 'base', - description: - 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', - example: '2016-05-23T08:05:34.853Z', - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: '@timestamp', - searchable: true, - type: 'date', + test('it returns (only) non-empty categories, where each category contains only the fields matching the substring', () => { + const filtered: BrowserFields = { + agent: { + fields: { + 'agent.ephemeral_id': { + aggregatable: true, + category: 'agent', + description: + 'Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.ephemeral_id', + searchable: true, + type: 'string', + }, + 'agent.id': { + aggregatable: true, + category: 'agent', + description: + 'Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.', + example: '8a4f500d', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.id', + searchable: true, + type: 'string', + }, }, - 'client.domain': { - aggregatable: true, - category: 'client', - description: 'Client domain.', - example: null, - format: '', - indexes: ['auditbeat', 'filebeat', 'packetbeat'], - name: 'client.domain', - searchable: true, - type: 'string', + }, + container: { + fields: { + 'container.id': { + aggregatable: true, + category: 'container', + description: 'Unique container id.', + example: null, + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'container.id', + searchable: true, + type: 'string', + }, }, }, }; - const fieldIds = ['agent.hostname', '@timestamp', 'client.domain']; - const { agent, ...mockBrowserFieldsWithoutAgent } = mockBrowserFields; - expect( - createVirtualCategory({ - browserFields: mockBrowserFieldsWithoutAgent, - fieldIds, + filterSelectedBrowserFields({ + browserFields: mockBrowserFields, + columnHeaders, }) - ).toEqual(expectedMatchingFields); + ).toEqual(filtered); }); }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx index 21829bda265e1..c0e1076073026 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/helpers.tsx @@ -6,13 +6,13 @@ */ import { EuiBadge, EuiLoadingSpinner } from '@elastic/eui'; -import { filter, get, pickBy } from 'lodash/fp'; +import { pickBy } from 'lodash/fp'; import styled from 'styled-components'; import { TimelineId } from '../../../../../public/types'; import type { BrowserField, BrowserFields } from '../../../../../common/search_strategy'; import { defaultHeaders } from '../../../../store/t_grid/defaults'; -import { DEFAULT_CATEGORY_NAME } from '../../body/column_headers/default_headers'; +import { ColumnHeaderOptions } from '../../../../../common'; export const LoadingSpinner = styled(EuiLoadingSpinner)` cursor: pointer; @@ -45,6 +45,9 @@ export const filterBrowserFieldsByFieldName = ({ substring: string; }): BrowserFields => { const trimmedSubstring = substring.trim(); + if (trimmedSubstring === '') { + return browserFields; + } // filter each category such that it only contains fields with field names // that contain the specified substring: @@ -53,11 +56,10 @@ export const filterBrowserFieldsByFieldName = ({ ...filteredCategories, [categoryId]: { ...browserFields[categoryId], - fields: filter( - (f) => f.name != null && f.name.includes(trimmedSubstring), + fields: pickBy( + ({ name }) => name != null && name.includes(trimmedSubstring), browserFields[categoryId].fields - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ).reduce((filtered, field) => ({ ...filtered, [field.name!]: field }), {}), + ), }, }), {} @@ -73,46 +75,40 @@ export const filterBrowserFieldsByFieldName = ({ }; /** - * Returns a "virtual" category (e.g. default ECS) from the specified fieldIds + * Filters the selected `BrowserFields` to return a new collection where every + * category contains at least one field that is present in the `columnHeaders`. */ -export const createVirtualCategory = ({ +export const filterSelectedBrowserFields = ({ browserFields, - fieldIds, + columnHeaders, }: { browserFields: BrowserFields; - fieldIds: string[]; -}): Partial => ({ - fields: fieldIds.reduce>((fields, fieldId) => { - const splitId = fieldId.split('.'); // source.geo.city_name -> [source, geo, city_name] - const browserField = get( - [splitId.length > 1 ? splitId[0] : 'base', 'fields', fieldId], - browserFields - ); - - return { - ...fields, - ...(browserField - ? { - [fieldId]: { - ...browserField, - name: fieldId, - }, - } - : {}), - }; - }, {}), -}); - -/** Merges the specified browser fields with the default category (i.e. `default ECS`) */ -export const mergeBrowserFieldsWithDefaultCategory = ( - browserFields: BrowserFields -): BrowserFields => ({ - ...browserFields, - [DEFAULT_CATEGORY_NAME]: createVirtualCategory({ - browserFields, - fieldIds: defaultHeaders.map((header) => header.id), - }), -}); + columnHeaders: ColumnHeaderOptions[]; +}): BrowserFields => { + const selectedFieldIds = new Set(columnHeaders.map(({ id }) => id)); + + const filteredBrowserFields: BrowserFields = Object.keys(browserFields).reduce( + (filteredCategories, categoryId) => ({ + ...filteredCategories, + [categoryId]: { + ...browserFields[categoryId], + fields: pickBy( + ({ name }) => name != null && selectedFieldIds.has(name), + browserFields[categoryId].fields + ), + }, + }), + {} + ); + + // only pick non-empty categories from the filtered browser fields + const nonEmptyCategories: BrowserFields = pickBy( + (category) => categoryHasFields(category), + filteredBrowserFields + ); + + return nonEmptyCategories; +}; export const getAlertColumnHeader = (timelineId: string, fieldId: string) => timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx index c5647c973b9d8..68bf6ca43ede9 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/index.tsx @@ -13,7 +13,7 @@ import styled from 'styled-components'; import type { BrowserFields } from '../../../../../common/search_strategy/index_fields'; import type { FieldBrowserProps } from '../../../../../common/types/fields_browser'; import { FieldsBrowser } from './field_browser'; -import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers'; +import { filterBrowserFieldsByFieldName, filterSelectedBrowserFields } from './helpers'; import * as i18n from './translations'; const FIELDS_BUTTON_CLASS_NAME = 'fields-button'; @@ -44,6 +44,8 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ const [appliedFilterInput, setAppliedFilterInput] = useState(''); /** all fields in this collection have field names that match the filterInput */ const [filteredBrowserFields, setFilteredBrowserFields] = useState(null); + /** when true, show only the the selected field */ + const [filterSelectedEnabled, setFilterSelectedEnabled] = useState(false); /** when true, show a spinner in the input to indicate the field browser is searching for matching field names */ const [isSearching, setIsSearching] = useState(false); /** this category will be displayed in the right-hand pane of the field browser */ @@ -67,14 +69,23 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ }; }, [debouncedApplyFilterInput]); + const selectionFilteredBrowserFields = useMemo( + () => + filterSelectedEnabled + ? filterSelectedBrowserFields({ browserFields, columnHeaders }) + : browserFields, + [browserFields, columnHeaders, filterSelectedEnabled] + ); + useEffect(() => { - const newFilteredBrowserFields = filterBrowserFieldsByFieldName({ - browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields), - substring: appliedFilterInput, - }); - setFilteredBrowserFields(newFilteredBrowserFields); + setFilteredBrowserFields( + filterBrowserFieldsByFieldName({ + browserFields: selectionFilteredBrowserFields, + substring: appliedFilterInput, + }) + ); setIsSearching(false); - }, [appliedFilterInput, browserFields]); + }, [appliedFilterInput, selectionFilteredBrowserFields]); /** Shows / hides the field browser */ const onShow = useCallback(() => { @@ -86,6 +97,7 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ setFilterInput(''); setAppliedFilterInput(''); setFilteredBrowserFields(null); + setFilterSelectedEnabled(false); setIsSearching(false); setSelectedCategoryIds([]); setShow(false); @@ -101,10 +113,13 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ [debouncedApplyFilterInput] ); - // only merge in the default category if the field browser is visible - const browserFieldsWithDefaultCategory = useMemo(() => { - return show ? mergeBrowserFieldsWithDefaultCategory(browserFields) : {}; - }, [show, browserFields]); + /** Invoked when the user changes the view all/selected value */ + const onFilterSelectedChange = useCallback( + (filterSelected: boolean) => { + setFilterSelectedEnabled(filterSelected); + }, + [setFilterSelectedEnabled] + ); return ( @@ -125,13 +140,14 @@ export const StatefulFieldsBrowserComponent: React.FC = ({ {show && ( values: { field }, defaultMessage: 'View {field} column', }); + +export const VIEW_LABEL = i18n.translate('xpack.timelines.fieldBrowser.viewLabel', { + defaultMessage: 'View', +}); + +export const VIEW_VALUE_SELECTED = i18n.translate('xpack.timelines.fieldBrowser.viewSelected', { + defaultMessage: 'selected', +}); + +export const VIEW_VALUE_ALL = i18n.translate('xpack.timelines.fieldBrowser.viewAll', { + defaultMessage: 'all', +}); From b0c3aab4e07f86dd2d3d9f13b2e6e1cad5116c6c Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 22 Mar 2022 11:18:19 -0500 Subject: [PATCH 043/132] [cft] Reuse elasticsearch snapshot on upgrade (#128264) Same-version snapshot upgrades have been causing deployments to become unhealthy. For now, lets reuse the original snapshot while we look for a workaround. --- .buildkite/scripts/steps/cloud/build_and_deploy.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/.buildkite/scripts/steps/cloud/build_and_deploy.sh b/.buildkite/scripts/steps/cloud/build_and_deploy.sh index 2bb9bc90e88da..91d207f8fcb31 100755 --- a/.buildkite/scripts/steps/cloud/build_and_deploy.sh +++ b/.buildkite/scripts/steps/cloud/build_and_deploy.sh @@ -75,7 +75,6 @@ if [ -z "${CLOUD_DEPLOYMENT_ID}" ]; then else ecctl deployment show "$CLOUD_DEPLOYMENT_ID" --generate-update-payload | jq ' .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | - .resources.elasticsearch[0].plan.elasticsearch.docker_image = "'$ELASTICSEARCH_CLOUD_IMAGE'" | (.. | select(.version? != null).version) = "'$VERSION'" ' > /tmp/deploy.json ecctl deployment update "$CLOUD_DEPLOYMENT_ID" --track --output json --file /tmp/deploy.json &> "$JSON_FILE" From f4f51e692e17e94d285a00cf82f8bceab0c939da Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Tue, 22 Mar 2022 17:30:21 +0100 Subject: [PATCH 044/132] User Page - KPIs and visualisations (#127617) * Create total users visualization * Organize visualizations --- .../common/experimental_features.ts | 1 + .../security_solution/index.ts | 8 ++ .../security_solution/users/index.ts | 5 + .../users/kpi/common/index.ts | 13 +++ .../users/kpi/total_users/index.ts | 19 ++++ .../integration/pagination/pagination.spec.ts | 8 +- .../cypress/screens/inspect.ts | 5 - .../security_solution/cypress/tasks/login.ts | 2 + .../navigation/tab_navigation/index.test.tsx | 8 +- .../common/components/stat_items/index.tsx | 9 +- .../users/kpi_total_users_area.ts | 84 +++++++++++++++ .../users/kpi_total_users_metric.ts | 55 ++++++++++ .../common/lib/kibana/kibana_react.mock.ts | 7 ++ .../kpi_hosts/authentications/index.tsx | 4 +- .../components/kpi_hosts/common/index.tsx | 22 ++-- .../components/kpi_hosts/hosts/index.tsx | 4 +- .../hosts/components/kpi_hosts/index.tsx | 81 +++++++------- .../kpi_hosts/risky_hosts/index.test.tsx | 2 +- .../kpi_hosts/risky_hosts/index.tsx | 4 +- .../components/kpi_hosts/unique_ips/index.tsx | 4 +- .../public/hosts/pages/details/index.tsx | 10 +- .../hosts/pages/details/nav_tabs.test.tsx | 20 +++- .../public/hosts/pages/details/nav_tabs.tsx | 14 ++- .../public/hosts/pages/hosts.tsx | 7 +- .../public/hosts/pages/index.tsx | 82 +++++++------- .../public/hosts/pages/nav_tabs.test.tsx | 49 ++++++++- .../public/hosts/pages/nav_tabs.tsx | 17 ++- .../components/kpi_network/common/index.tsx | 89 ---------------- .../components/kpi_network/dns/index.tsx | 5 +- .../kpi_network/network_events/index.tsx | 5 +- .../kpi_network/tls_handshakes/index.tsx | 4 +- .../kpi_network/unique_flows/index.tsx | 4 +- .../kpi_network/unique_private_ips/index.tsx | 4 +- .../users/components/kpi_users/index.tsx | 9 +- .../kpi_users/total_users/index.tsx | 100 ++++++++++++++++++ .../kpi_users/total_users/translations.ts | 19 ++++ .../public/users/pages/nav_tabs.test.tsx | 32 ++++++ .../public/users/pages/nav_tabs.tsx | 56 ++++++---- .../public/users/pages/navigation/types.ts | 5 +- .../public/users/pages/users.tsx | 10 +- .../security_solution/factory/users/index.ts | 2 + .../factory/users/kpi/total_users/index.ts | 50 +++++++++ .../query.build_total_users_kpi.dsl.ts | 65 ++++++++++++ .../test/security_solution_cypress/config.ts | 1 + 44 files changed, 748 insertions(+), 256 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/common/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/total_users/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts delete mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx create mode 100644 x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx create mode 100644 x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/translations.ts create mode 100644 x-pack/plugins/security_solution/public/users/pages/nav_tabs.test.tsx create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/query.build_total_users_kpi.dsl.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 733d90eee2e65..3a932238f3a34 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -21,6 +21,7 @@ export const allowedExperimentalValues = Object.freeze({ detectionResponseEnabled: false, disableIsolationUIPendingStatuses: false, riskyHostsEnabled: false, + riskyUsersEnabled: false, securityRulesCancelEnabled: false, pendingActionResponsesWithAck: true, policyListEnabled: false, diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index eb659b37a6888..a7176b3d30930 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -83,6 +83,10 @@ import { } from './risk_score'; import { UsersQueries } from './users'; import { UserDetailsRequestOptions, UserDetailsStrategyResponse } from './users/details'; +import { + TotalUsersKpiRequestOptions, + TotalUsersKpiStrategyResponse, +} from './users/kpi/total_users'; export * from './cti'; export * from './hosts'; @@ -141,6 +145,8 @@ export type StrategyResponseType = T extends HostsQ ? HostsKpiUniqueIpsStrategyResponse : T extends UsersQueries.details ? UserDetailsStrategyResponse + : T extends UsersQueries.kpiTotalUsers + ? TotalUsersKpiStrategyResponse : T extends NetworkQueries.details ? NetworkDetailsStrategyResponse : T extends NetworkQueries.dns @@ -199,6 +205,8 @@ export type StrategyRequestType = T extends HostsQu ? HostsKpiUniqueIpsRequestOptions : T extends UsersQueries.details ? UserDetailsRequestOptions + : T extends UsersQueries.kpiTotalUsers + ? TotalUsersKpiRequestOptions : T extends NetworkQueries.details ? NetworkDetailsRequestOptions : T extends NetworkQueries.dns diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts index fd5c90031b9a5..d8f6172dd80c2 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/index.ts @@ -5,6 +5,11 @@ * 2.0. */ +import { TotalUsersKpiStrategyResponse } from './kpi/total_users'; + export enum UsersQueries { details = 'userDetails', + kpiTotalUsers = 'usersKpiTotalUsers', } + +export type UserskKpiStrategyResponse = Omit; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/common/index.ts new file mode 100644 index 0000000000000..27f83e2ec623a --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/common/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { Maybe } from '../../../..'; + +export interface KpiHistogramData { + x?: Maybe; + y?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/total_users/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/total_users/index.ts new file mode 100644 index 0000000000000..9069393102a5b --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/kpi/total_users/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../../common'; +import { RequestBasicOptions } from '../../..'; +import { KpiHistogramData } from '../common'; + +export type TotalUsersKpiRequestOptions = RequestBasicOptions; + +export interface TotalUsersKpiStrategyResponse extends IEsSearchResponse { + users: Maybe; + usersHistogram: Maybe; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/cypress/integration/pagination/pagination.spec.ts b/x-pack/plugins/security_solution/cypress/integration/pagination/pagination.spec.ts index 645b2916d554d..23115e2598c69 100644 --- a/x-pack/plugins/security_solution/cypress/integration/pagination/pagination.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/pagination/pagination.spec.ts @@ -12,8 +12,8 @@ import { import { FIRST_PAGE_SELECTOR, THIRD_PAGE_SELECTOR } from '../../screens/pagination'; import { cleanKibana } from '../../tasks/common'; -import { waitForAuthenticationsToBeLoaded } from '../../tasks/hosts/authentications'; -import { openAuthentications, openUncommonProcesses } from '../../tasks/hosts/main'; +import { waitsForEventsToBeLoaded } from '../../tasks/hosts/events'; +import { openEvents, openUncommonProcesses } from '../../tasks/hosts/main'; import { waitForUncommonProcessesToBeLoaded } from '../../tasks/hosts/uncommon_processes'; import { loginAndWaitForPage } from '../../tasks/login'; import { goToFirstPage, goToThirdPage } from '../../tasks/pagination'; @@ -73,8 +73,8 @@ describe('Pagination', () => { .first() .invoke('text') .then((expectedThirdPageResult) => { - openAuthentications(); - waitForAuthenticationsToBeLoaded(); + openEvents(); + waitsForEventsToBeLoaded(); cy.get(FIRST_PAGE_SELECTOR).should('have.class', 'euiPaginationButton-isActive'); openUncommonProcesses(); waitForUncommonProcessesToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/screens/inspect.ts b/x-pack/plugins/security_solution/cypress/screens/inspect.ts index f0fbf7e6a3089..3ee675ae7ca8c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/inspect.ts +++ b/x-pack/plugins/security_solution/cypress/screens/inspect.ts @@ -21,11 +21,6 @@ export const INSPECT_HOSTS_BUTTONS_IN_SECURITY: InspectButtonMetadata[] = [ title: 'All Hosts Table', tabId: '[data-test-subj="navigation-allHosts"]', }, - { - id: '[data-test-subj="table-authentications-loading-false"]', - title: 'Authentications Table', - tabId: '[data-test-subj="navigation-authentications"]', - }, { id: '[data-test-subj="table-uncommonProcesses-loading-false"]', title: 'Uncommon processes Table', diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 736418325d3d2..de68a3f41d57d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -359,6 +359,8 @@ export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => { export const loginAndWaitForHostDetailsPage = (hostName = 'suricata-iowa') => { loginAndWaitForPage(hostDetailsUrl(hostName)); + + cy.get('[data-test-subj="hostDetailsPage"]', { timeout: 12000 }).should('exist'); cy.get('[data-test-subj="loading-spinner"]', { timeout: 12000 }).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx index d2a17e87cffcf..d90709f69ee03 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.test.tsx @@ -52,13 +52,19 @@ const hostName = 'siem-window'; describe('Table Navigation', () => { const mockHasMlUserPermissions = true; const mockRiskyHostEnabled = true; + const mockProps: TabNavigationProps & RouteSpyState = { pageName: 'hosts', pathName: '/hosts', detailName: undefined, search: '', tabName: HostsTableType.authentications, - navTabs: navTabsHostDetails(hostName, mockHasMlUserPermissions, mockRiskyHostEnabled), + navTabs: navTabsHostDetails({ + hostName, + hasMlUserPermissions: mockHasMlUserPermissions, + isRiskyHostsEnabled: mockRiskyHostEnabled, + }), + [CONSTANTS.timerange]: { global: { [CONSTANTS.timerange]: { diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 93a13dd5dee8b..424920d34e2e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -34,6 +34,7 @@ import { InspectButton } from '../inspect'; import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; import { HoverVisibilityContainer } from '../hover_visibility_container'; import { LensAttributes } from '../visualization_actions/types'; +import { UserskKpiStrategyResponse } from '../../../../common/search_strategy/security_solution/users'; const FlexItem = styled(EuiFlexItem)` min-width: 0; @@ -125,12 +126,12 @@ export const barchartConfigs = (config?: { onElementClick?: ElementClickListener export const addValueToFields = ( fields: StatItem[], - data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse + data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse ): StatItem[] => fields.map((field) => ({ ...field, value: get(field.key, data) })); export const addValueToAreaChart = ( fields: StatItem[], - data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse + data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse ): ChartSeriesData[] => fields .filter((field) => get(`${field.key}Histogram`, data) != null) @@ -142,7 +143,7 @@ export const addValueToAreaChart = ( export const addValueToBarChart = ( fields: StatItem[], - data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse + data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse ): ChartSeriesData[] => { if (fields.length === 0) return []; return fields.reduce((acc: ChartSeriesData[], field: StatItem, idx: number) => { @@ -171,7 +172,7 @@ export const addValueToBarChart = ( export const useKpiMatrixStatus = ( mappings: Readonly, - data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse, + data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse, id: string, from: string, to: string, diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts new file mode 100644 index 0000000000000..482086289e14d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts @@ -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 { LensAttributes } from '../../types'; + +export const kpiTotalUsersAreaLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '416b6fad-1923-4f6a-a2df-b223bb287e30': { + columnOrder: [ + '5eea817b-67b7-4268-8ecb-7688d1094721', + 'b00c65ea-32be-4163-bfc8-f795b1ef9d06', + ], + columns: { + '5eea817b-67b7-4268-8ecb-7688d1094721': { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: '@timestamp', + }, + 'b00c65ea-32be-4163-bfc8-f795b1ef9d06': { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'user.name', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: false }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + labelsOrientation: { x: 0, yLeft: 0, yRight: 0 }, + layers: [ + { + accessors: ['b00c65ea-32be-4163-bfc8-f795b1ef9d06'], + layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', + layerType: 'data', + seriesType: 'area', + xAccessor: '5eea817b-67b7-4268-8ecb-7688d1094721', + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'area', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + yLeftExtent: { mode: 'full' }, + yRightExtent: { mode: 'full' }, + }, + }, + title: '[User] Users - area', + visualizationType: 'lnsXY', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts new file mode 100644 index 0000000000000..7f1d2253eb3be --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts @@ -0,0 +1,55 @@ +/* + * 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 { LensAttributes } from '../../types'; + +export const kpiTotalUsersMetricLensAttributes: LensAttributes = { + description: '', + state: { + datasourceStates: { + indexpattern: { + layers: { + '416b6fad-1923-4f6a-a2df-b223bb287e30': { + columnOrder: ['3e51b035-872c-4b44-824b-fe069c222e91'], + columns: { + '3e51b035-872c-4b44-824b-fe069c222e91': { + dataType: 'number', + isBucketed: false, + label: 'Unique count of user.name', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'user.name', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: '3e51b035-872c-4b44-824b-fe069c222e91', + layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', + layerType: 'data', + }, + }, + title: '[User] Users - metric', + visualizationType: 'lnsMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', + type: 'index-pattern', + }, + ], +} as LensAttributes; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 366cd271fb57d..b683f4bd1375a 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -139,6 +139,13 @@ export const createStartServicesMock = ( next: jest.fn(), unsubscribe: jest.fn(), })), + pipe: jest.fn().mockImplementation(() => ({ + subscribe: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + next: jest.fn(), + unsubscribe: jest.fn(), + })), + })), })), }, }, diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx index ce73b1cd07f61..1158c842e04cb 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx @@ -13,7 +13,7 @@ import { kpiUserAuthenticationsBarLensAttributes } from '../../../../common/comp import { kpiUserAuthenticationsMetricSuccessLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success'; import { kpiUserAuthenticationsMetricFailureLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure'; import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; -import { HostsKpiBaseComponentManage } from '../common'; +import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; @@ -66,7 +66,7 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ }); return ( - ; - data: HostsKpiStrategyResponse; + data: HostsKpiStrategyResponse | NetworkKpiStrategyResponse | UserskKpiStrategyResponse; loading?: boolean; id: string; from: string; @@ -40,7 +44,7 @@ interface HostsKpiBaseComponentProps { narrowDateRange: UpdateDateRange; } -export const HostsKpiBaseComponent = React.memo( +export const KpiBaseComponent = React.memo( ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { const { cases } = useKibana().services; const CasesContext = cases.ui.getCasesContext(); @@ -57,7 +61,7 @@ export const HostsKpiBaseComponent = React.memo( ); if (loading) { - return ; + return ; } return ( @@ -80,12 +84,12 @@ export const HostsKpiBaseComponent = React.memo( deepEqual(prevProps.data, nextProps.data) ); -HostsKpiBaseComponent.displayName = 'HostsKpiBaseComponent'; +KpiBaseComponent.displayName = 'KpiBaseComponent'; -export const HostsKpiBaseComponentManage = manageQuery(HostsKpiBaseComponent); +export const KpiBaseComponentManage = manageQuery(KpiBaseComponent); -export const HostsKpiBaseComponentLoader: React.FC = () => ( - +export const KpiBaseComponentLoader: React.FC = () => ( + diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx index 4e73a429fbc1d..79118b66a3f71 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx @@ -11,7 +11,7 @@ import { StatItems } from '../../../../common/components/stat_items'; import { kpiHostAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_area'; import { kpiHostMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; import { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; -import { HostsKpiBaseComponentManage } from '../common'; +import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; @@ -51,7 +51,7 @@ const HostsKpiHostsComponent: React.FC = ({ }); return ( - ( ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { const [_, { isModuleEnabled }] = useHostRiskScore({}); + const usersEnabled = useIsExperimentalFeatureEnabled('usersEnabled'); return ( <> @@ -59,8 +61,21 @@ export const HostsKpiComponent = React.memo( skip={skip} />
+ {!usersEnabled && ( + + + + )} - ( skip={skip} /> - - + + ); + } +); + +HostsKpiComponent.displayName = 'HostsKpiComponent'; + +export const HostsDetailsKpiComponent = React.memo( + ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { + const usersEnabled = useIsExperimentalFeatureEnabled('usersEnabled'); + return ( + + {!usersEnabled && ( + + ( skip={skip} /> - - + )} + + + +
); } ); -HostsKpiComponent.displayName = 'HostsKpiComponent'; - -export const HostsDetailsKpiComponent = React.memo( - ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => ( - - - - - - - - - ) -); - HostsDetailsKpiComponent.displayName = 'HostsDetailsKpiComponent'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx index c4fa134bd88e2..b000b2f22dc95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx @@ -36,7 +36,7 @@ describe('RiskyHosts', () => { ); - expect(getByTestId('hostsKpiLoader')).toBeInTheDocument(); + expect(getByTestId('KpiLoader')).toBeInTheDocument(); }); test('it displays 0 risky hosts when initializing', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index d4897702f9407..f515490252d40 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -22,7 +22,7 @@ import { BUTTON_CLASS as INPECT_BUTTON_CLASS, } from '../../../../common/components/inspect'; -import { HostsKpiBaseComponentLoader } from '../common'; +import { KpiBaseComponentLoader } from '../common'; import * as i18n from './translations'; import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; @@ -66,7 +66,7 @@ const RiskyHostsComponent: React.FC<{ useErrorToast(i18n.ERROR_TITLE, error); if (loading) { - return ; + return ; } const criticalRiskCount = data?.kpiRiskScore.Critical ?? 0; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx index 2d95e3c98f4ae..ef7bdfa1dc031 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx @@ -13,7 +13,7 @@ import { kpiUniqueIpsBarLensAttributes } from '../../../../common/components/vis import { kpiUniqueIpsDestinationMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric'; import { kpiUniqueIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric'; import { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; -import { HostsKpiBaseComponentManage } from '../common'; +import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; @@ -66,7 +66,7 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ }); return ( - = ({ detailName, hostDeta diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx index 90f3c223c5501..8b951722439a6 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.test.tsx @@ -11,7 +11,11 @@ import { navTabsHostDetails } from './nav_tabs'; describe('navTabsHostDetails', () => { const mockHostName = 'mockHostName'; test('it should skip anomalies tab if without mlUserPermission', () => { - const tabs = navTabsHostDetails(mockHostName, false, false); + const tabs = navTabsHostDetails({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: false, + hostName: mockHostName, + }); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).not.toHaveProperty(HostsTableType.anomalies); @@ -20,7 +24,12 @@ describe('navTabsHostDetails', () => { }); test('it should display anomalies tab if with mlUserPermission', () => { - const tabs = navTabsHostDetails(mockHostName, true, false); + const tabs = navTabsHostDetails({ + hasMlUserPermissions: true, + isRiskyHostsEnabled: false, + hostName: mockHostName, + }); + expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).toHaveProperty(HostsTableType.anomalies); @@ -29,7 +38,12 @@ describe('navTabsHostDetails', () => { }); test('it should display risky hosts tab if when risky hosts is enabled', () => { - const tabs = navTabsHostDetails(mockHostName, false, true); + const tabs = navTabsHostDetails({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: true, + hostName: mockHostName, + }); + expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).not.toHaveProperty(HostsTableType.anomalies); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx index c58fbde09aef1..33cafd8ef2114 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/nav_tabs.tsx @@ -14,11 +14,15 @@ import { HOSTS_PATH } from '../../../../common/constants'; const getTabsOnHostDetailsUrl = (hostName: string, tabName: HostsTableType) => `${HOSTS_PATH}/${hostName}/${tabName}`; -export const navTabsHostDetails = ( - hostName: string, - hasMlUserPermissions: boolean, - isRiskyHostsEnabled: boolean -): HostDetailsNavTab => { +export const navTabsHostDetails = ({ + hasMlUserPermissions, + isRiskyHostsEnabled, + hostName, +}: { + hostName: string; + hasMlUserPermissions: boolean; + isRiskyHostsEnabled: boolean; +}): HostDetailsNavTab => { const hiddenTabs = []; const hostDetailsNavTabs = { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index 5ca7aa1f1dd49..3b57a22d15a6a 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -151,6 +151,7 @@ const HostsComponent = () => { ); const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); + const usersEnabled = useIsExperimentalFeatureEnabled('usersEnabled'); useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to }); @@ -214,7 +215,11 @@ const HostsComponent = () => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/index.tsx index 4eb8175aea4cb..453d6182984c1 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/index.tsx @@ -34,48 +34,46 @@ const getHostDetailsTabPath = () => `${HostsTableType.risk}|` + `${HostsTableType.alerts})`; -export const HostsContainer = React.memo(() => { - return ( - - - - - - - - } - /> - ( - - )} - /> +export const HostsContainer = React.memo(() => ( + + + + + + + + } + /> + ( + + )} + /> - ( - - )} - /> - - ); -}); + ( + + )} + /> + +)); HostsContainer.displayName = 'HostsContainer'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.test.tsx index 50e301d4b4f57..b882dca3faaf1 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.test.tsx @@ -10,7 +10,11 @@ import { navTabsHosts } from './nav_tabs'; describe('navTabsHosts', () => { test('it should skip anomalies tab if without mlUserPermission', () => { - const tabs = navTabsHosts(false, false); + const tabs = navTabsHosts({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: false, + isUsersEnabled: false, + }); expect(tabs).toHaveProperty(HostsTableType.hosts); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); @@ -19,15 +23,24 @@ describe('navTabsHosts', () => { }); test('it should display anomalies tab if with mlUserPermission', () => { - const tabs = navTabsHosts(true, false); + const tabs = navTabsHosts({ + hasMlUserPermissions: true, + isRiskyHostsEnabled: false, + isUsersEnabled: false, + }); expect(tabs).toHaveProperty(HostsTableType.hosts); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).toHaveProperty(HostsTableType.anomalies); expect(tabs).toHaveProperty(HostsTableType.events); }); + test('it should skip risk tab if without hostRisk', () => { - const tabs = navTabsHosts(false, false); + const tabs = navTabsHosts({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: false, + isUsersEnabled: false, + }); expect(tabs).toHaveProperty(HostsTableType.hosts); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); @@ -36,11 +49,39 @@ describe('navTabsHosts', () => { }); test('it should display risk tab if with hostRisk', () => { - const tabs = navTabsHosts(false, true); + const tabs = navTabsHosts({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: true, + isUsersEnabled: false, + }); expect(tabs).toHaveProperty(HostsTableType.hosts); expect(tabs).toHaveProperty(HostsTableType.authentications); expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); expect(tabs).toHaveProperty(HostsTableType.risk); expect(tabs).toHaveProperty(HostsTableType.events); }); + + test('it should skip authentications tab if isUsersEnabled is true', () => { + const tabs = navTabsHosts({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: false, + isUsersEnabled: true, + }); + expect(tabs).toHaveProperty(HostsTableType.hosts); + expect(tabs).not.toHaveProperty(HostsTableType.authentications); + expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); + expect(tabs).toHaveProperty(HostsTableType.events); + }); + + test('it should display authentications tab if isUsersEnabled is false', () => { + const tabs = navTabsHosts({ + hasMlUserPermissions: false, + isRiskyHostsEnabled: false, + isUsersEnabled: false, + }); + expect(tabs).toHaveProperty(HostsTableType.hosts); + expect(tabs).toHaveProperty(HostsTableType.authentications); + expect(tabs).toHaveProperty(HostsTableType.uncommonProcesses); + expect(tabs).toHaveProperty(HostsTableType.events); + }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.tsx index 0d8a5e252bfbb..789273da073e9 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/nav_tabs.tsx @@ -13,10 +13,15 @@ import { HOSTS_PATH } from '../../../common/constants'; const getTabsOnHostsUrl = (tabName: HostsTableType) => `${HOSTS_PATH}/${tabName}`; -export const navTabsHosts = ( - hasMlUserPermissions: boolean, - isRiskyHostsEnabled: boolean -): HostsNavTab => { +export const navTabsHosts = ({ + hasMlUserPermissions, + isRiskyHostsEnabled, + isUsersEnabled, +}: { + hasMlUserPermissions: boolean; + isRiskyHostsEnabled: boolean; + isUsersEnabled: boolean; +}): HostsNavTab => { const hiddenTabs = []; const hostsNavTabs = { [HostsTableType.hosts]: { @@ -71,5 +76,9 @@ export const navTabsHosts = ( hiddenTabs.push(HostsTableType.risk); } + if (isUsersEnabled) { + hiddenTabs.push(HostsTableType.authentications); + } + return omit(hiddenTabs, hostsNavTabs); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx deleted file mode 100644 index 8fbc75aff4e19..0000000000000 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/common/index.tsx +++ /dev/null @@ -1,89 +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 React from 'react'; -import { EuiFlexItem, EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui'; -import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; - -import { manageQuery } from '../../../../common/components/page/manage_query'; -import { NetworkKpiStrategyResponse } from '../../../../../common/search_strategy'; -import { - StatItemsComponent, - StatItemsProps, - useKpiMatrixStatus, - StatItems, -} from '../../../../common/components/stat_items'; -import { UpdateDateRange } from '../../../../common/components/charts/common'; -import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; -import { APP_ID } from '../../../../../common/constants'; - -const kpiWidgetHeight = 228; - -export const FlexGroup = styled(EuiFlexGroup)` - min-height: ${kpiWidgetHeight}px; -`; - -FlexGroup.displayName = 'FlexGroup'; - -export const NetworkKpiBaseComponent = React.memo<{ - fieldsMapping: Readonly; - data: NetworkKpiStrategyResponse; - loading?: boolean; - id: string; - from: string; - to: string; - narrowDateRange: UpdateDateRange; -}>( - ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { - const { cases } = useKibana().services; - const CasesContext = cases.ui.getCasesContext(); - const userPermissions = useGetUserCasesPermissions(); - const userCanCrud = userPermissions?.crud ?? false; - - const statItemsProps: StatItemsProps[] = useKpiMatrixStatus( - fieldsMapping, - data, - id, - from, - to, - narrowDateRange - ); - - if (loading) { - return ( - - - - - - ); - } - - return ( - - - {statItemsProps.map((mappedStatItemProps) => ( - - ))} - - - ); - }, - (prevProps, nextProps) => - prevProps.fieldsMapping === nextProps.fieldsMapping && - prevProps.loading === nextProps.loading && - prevProps.id === nextProps.id && - prevProps.from === nextProps.from && - prevProps.to === nextProps.to && - prevProps.narrowDateRange === nextProps.narrowDateRange && - deepEqual(prevProps.data, nextProps.data) -); - -NetworkKpiBaseComponent.displayName = 'NetworkKpiBaseComponent'; - -export const NetworkKpiBaseComponentManage = manageQuery(NetworkKpiBaseComponent); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx index 2c9db1cde6daf..6291e7fd4dc12 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiDnsQueriesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_dns_queries'; +import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; import { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; -import { NetworkKpiBaseComponentManage } from '../common'; + import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; @@ -46,7 +47,7 @@ const NetworkKpiDnsComponent: React.FC = ({ }); return ( - = ({ }); return ( - = ({ }); return ( - = ({ }); return ( - = ({ }); return ( - ( ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { @@ -19,7 +19,7 @@ export const UsersKpiComponent = React.memo( <> - ( skip={skip} /> - - + = [ + { + key: 'users', + fields: [ + { + key: 'users', + value: null, + color: euiColorVis1, + icon: 'storage', + lensAttributes: kpiTotalUsersMetricLensAttributes, + }, + ], + enableAreaChart: true, + description: i18n.USERS, + areaChartLensAttributes: kpiTotalUsersAreaLensAttributes, + }, +]; + +export interface UsersKpiProps { + filterQuery?: string; + from: string; + to: string; + indexNames: string[]; + narrowDateRange: UpdateDateRange; + setQuery: GlobalTimeArgs['setQuery']; + skip: boolean; +} + +const QUERY_ID = 'TotalUsersKpiQuery'; + +const TotalUsersKpiComponent: React.FC = ({ + filterQuery, + from, + indexNames, + to, + narrowDateRange, + setQuery, + skip, +}) => { + const { loading, result, search, refetch, inspect } = + useSearchStrategy({ + factoryQueryType: UsersQueries.kpiTotalUsers, + initialResult: { users: 0, usersHistogram: [] }, + errorMessage: i18n.ERROR_USERS_KPI, + }); + + useEffect(() => { + if (!skip) { + search({ + filterQuery, + defaultIndex: indexNames, + timerange: { + interval: '12h', + from, + to, + }, + }); + } + }, [search, from, to, filterQuery, indexNames, skip]); + + return ( + + ); +}; + +export const TotalUsersKpi = React.memo(TotalUsersKpiComponent); diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/translations.ts b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/translations.ts new file mode 100644 index 0000000000000..3bbcf3f08c119 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/translations.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const USERS = i18n.translate('xpack.securitySolution.kpiUsers.totalUsers.title', { + defaultMessage: 'Users', +}); + +export const ERROR_USERS_KPI = i18n.translate( + 'xpack.securitySolution.kpiUsers.totalUsers.errorSearchDescription', + { + defaultMessage: `An error has occurred on total users kpi search`, + } +); diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.test.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.test.tsx new file mode 100644 index 0000000000000..492f85ec7ec02 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { UsersTableType } from '../store/model'; +import { navTabsUsers } from './nav_tabs'; + +describe('navTabsUsers', () => { + test('it should display all tabs', () => { + const tabs = navTabsUsers(true, true); + expect(tabs).toHaveProperty(UsersTableType.allUsers); + expect(tabs).toHaveProperty(UsersTableType.anomalies); + expect(tabs).toHaveProperty(UsersTableType.risk); + }); + + test('it should not display anomalies tab if user has no ml permission', () => { + const tabs = navTabsUsers(false, true); + expect(tabs).toHaveProperty(UsersTableType.allUsers); + expect(tabs).not.toHaveProperty(UsersTableType.anomalies); + expect(tabs).toHaveProperty(UsersTableType.risk); + }); + + test('it should not display risk tab if isRiskyUserEnabled disabled', () => { + const tabs = navTabsUsers(true, false); + expect(tabs).toHaveProperty(UsersTableType.allUsers); + expect(tabs).toHaveProperty(UsersTableType.anomalies); + expect(tabs).not.toHaveProperty(UsersTableType.risk); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx index f991316983f49..35124d1deddb1 100644 --- a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash/fp'; import * as i18n from './translations'; import { UsersTableType } from '../store/model'; import { UsersNavTab } from './navigation/types'; @@ -12,23 +13,40 @@ import { USERS_PATH } from '../../../common/constants'; const getTabsOnUsersUrl = (tabName: UsersTableType) => `${USERS_PATH}/${tabName}`; -export const navTabsUsers: UsersNavTab = { - [UsersTableType.allUsers]: { - id: UsersTableType.allUsers, - name: i18n.NAVIGATION_ALL_USERS_TITLE, - href: getTabsOnUsersUrl(UsersTableType.allUsers), - disabled: false, - }, - [UsersTableType.anomalies]: { - id: UsersTableType.anomalies, - name: i18n.NAVIGATION_ANOMALIES_TITLE, - href: getTabsOnUsersUrl(UsersTableType.anomalies), - disabled: false, - }, - [UsersTableType.risk]: { - id: UsersTableType.risk, - name: i18n.NAVIGATION_RISK_TITLE, - href: getTabsOnUsersUrl(UsersTableType.risk), - disabled: false, - }, +export const navTabsUsers = ( + hasMlUserPermissions: boolean, + isRiskyUserEnabled: boolean +): UsersNavTab => { + const hiddenTabs = []; + + const userNavTabs = { + [UsersTableType.allUsers]: { + id: UsersTableType.allUsers, + name: i18n.NAVIGATION_ALL_USERS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.allUsers), + disabled: false, + }, + [UsersTableType.anomalies]: { + id: UsersTableType.anomalies, + name: i18n.NAVIGATION_ANOMALIES_TITLE, + href: getTabsOnUsersUrl(UsersTableType.anomalies), + disabled: false, + }, + [UsersTableType.risk]: { + id: UsersTableType.risk, + name: i18n.NAVIGATION_RISK_TITLE, + href: getTabsOnUsersUrl(UsersTableType.risk), + disabled: false, + }, + }; + + if (!hasMlUserPermissions) { + hiddenTabs.push(UsersTableType.anomalies); + } + + if (!isRiskyUserEnabled) { + hiddenTabs.push(UsersTableType.risk); + } + + return omit(hiddenTabs, userNavTabs); }; diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts index 1e4c28f38450e..f3fd099d78548 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts @@ -10,7 +10,10 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { DocValueFields } from '../../../../../timelines/common'; import { NavTab } from '../../../common/components/navigation/types'; -type KeyUsersNavTab = UsersTableType.allUsers | UsersTableType.anomalies | UsersTableType.risk; +type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & UsersTableType.risk; +type KeyUsersNavTabWithMlPermission = KeyUsersNavTabWithoutMlPermission & UsersTableType.anomalies; + +type KeyUsersNavTab = KeyUsersNavTabWithoutMlPermission | KeyUsersNavTabWithMlPermission; export type UsersNavTab = Record; export interface QueryTabBodyProps { diff --git a/x-pack/plugins/security_solution/public/users/pages/users.tsx b/x-pack/plugins/security_solution/public/users/pages/users.tsx index 91cdb5cc1e430..bd6cc2d097c46 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users.tsx @@ -46,6 +46,9 @@ import { UpdateDateRange } from '../../common/components/charts/common'; import { LastEventIndexKey } from '../../../common/search_strategy'; import { generateSeverityFilter } from '../../hosts/store/helpers'; import { UsersTableType } from '../store/model'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; +import { useMlCapabilities } from '../../common/components/ml/hooks/use_ml_capabilities'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; const ID = 'UsersQueryId'; @@ -157,6 +160,9 @@ const UsersComponent = () => { [dispatch] ); + const capabilities = useMlCapabilities(); + const riskyUsersFeatureEnabled = useIsExperimentalFeatureEnabled('riskyUsersEnabled'); + return ( <> {indicesExist ? ( @@ -191,7 +197,9 @@ const UsersComponent = () => { - + diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts index 211d9a71f8a55..2fe2f44c94e8d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/index.ts @@ -10,7 +10,9 @@ import { UsersQueries } from '../../../../../common/search_strategy/security_sol import { SecuritySolutionFactory } from '../types'; import { userDetails } from './details'; +import { totalUsersKpi } from './kpi/total_users'; export const usersFactory: Record> = { [UsersQueries.details]: userDetails, + [UsersQueries.kpiTotalUsers]: totalUsersKpi, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/index.ts new file mode 100644 index 0000000000000..50e4cfe50bca2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/index.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* + * 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 { getOr } from 'lodash/fp'; + +import type { IEsSearchResponse } from '../../../../../../../../../../src/plugins/data/common'; +import { UsersQueries } from '../../../../../../../common/search_strategy/security_solution/users'; +import { + TotalUsersKpiRequestOptions, + TotalUsersKpiStrategyResponse, +} from '../../../../../../../common/search_strategy/security_solution/users/kpi/total_users'; + +import { inspectStringifyObject } from '../../../../../../utils/build_query'; +import { formatGeneralHistogramData } from '../../../hosts/kpi'; +import { SecuritySolutionFactory } from '../../../types'; +import { buildTotalUsersKpiQuery } from './query.build_total_users_kpi.dsl'; + +export const totalUsersKpi: SecuritySolutionFactory = { + buildDsl: (options: TotalUsersKpiRequestOptions) => buildTotalUsersKpiQuery(options), + parse: async ( + options: TotalUsersKpiRequestOptions, + response: IEsSearchResponse + ): Promise => { + const inspect = { + dsl: [inspectStringifyObject(buildTotalUsersKpiQuery(options))], + }; + + const usersHistogram = getOr( + null, + 'aggregations.users_histogram.buckets', + response.rawResponse + ); + return { + ...response, + inspect, + users: getOr(null, 'aggregations.users.value', response.rawResponse), + usersHistogram: formatGeneralHistogramData(usersHistogram), + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/query.build_total_users_kpi.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/query.build_total_users_kpi.dsl.ts new file mode 100644 index 0000000000000..d86763e4cd3f6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/kpi/total_users/query.build_total_users_kpi.dsl.ts @@ -0,0 +1,65 @@ +/* + * 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 { HostsKpiHostsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/hosts'; +import { createQueryFilterClauses } from '../../../../../../utils/build_query'; + +export const buildTotalUsersKpiQuery = ({ + filterQuery, + timerange: { from, to }, + defaultIndex, +}: HostsKpiHostsRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + index: defaultIndex, + allow_no_indices: true, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggregations: { + users: { + cardinality: { + field: 'user.name', + }, + }, + users_histogram: { + auto_date_histogram: { + field: '@timestamp', + buckets: 6, + }, + aggs: { + count: { + cardinality: { + field: 'user.name', + }, + }, + }, + }, + }, + query: { + bool: { + filter, + }, + }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index 47be0c9b2c8ce..e32283685f0b2 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -51,6 +51,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'riskyHostsEnabled', 'usersEnabled', + 'riskyUsersEnabled', 'ruleRegistryEnabled', ])}`, `--home.disableWelcomeScreen=true`, From ef570f6f0c48581423fbcf4b04e37215a2c7a2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Tue, 22 Mar 2022 12:58:26 -0400 Subject: [PATCH 045/132] [CTI] fixes rule preview incorrect interval and from values (#128003) Co-authored-by: Ece Ozalp Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/detection_engine/constants.ts | 14 +++++++++++ .../rules/use_preview_rule.ts | 25 ++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/constants.ts b/x-pack/plugins/security_solution/common/detection_engine/constants.ts index 7f3c822800673..b61cd34dc4790 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/constants.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/constants.ts @@ -11,3 +11,17 @@ export enum RULE_PREVIEW_INVOCATION_COUNT { WEEK = 168, MONTH = 30, } + +export enum RULE_PREVIEW_INTERVAL { + HOUR = '5m', + DAY = '1h', + WEEK = '1h', + MONTH = '1d', +} + +export enum RULE_PREVIEW_FROM { + HOUR = 'now-6m', + DAY = 'now-65m', + WEEK = 'now-65m', + MONTH = 'now-25h', +} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts index 43572ddaf4d37..b610e96273ebd 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts @@ -8,7 +8,11 @@ import { useEffect, useState } from 'react'; import { Unit } from '@elastic/datemath'; -import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants'; +import { + RULE_PREVIEW_FROM, + RULE_PREVIEW_INTERVAL, + RULE_PREVIEW_INVOCATION_COUNT, +} from '../../../../../common/detection_engine/constants'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { PreviewResponse, @@ -31,16 +35,24 @@ export const usePreviewRule = (timeframe: Unit = 'h') => { const [isLoading, setIsLoading] = useState(false); const { addError } = useAppToasts(); let invocationCount = RULE_PREVIEW_INVOCATION_COUNT.HOUR; + let interval = RULE_PREVIEW_INTERVAL.HOUR; + let from = RULE_PREVIEW_FROM.HOUR; switch (timeframe) { case 'd': invocationCount = RULE_PREVIEW_INVOCATION_COUNT.DAY; + interval = RULE_PREVIEW_INTERVAL.DAY; + from = RULE_PREVIEW_FROM.DAY; break; case 'w': invocationCount = RULE_PREVIEW_INVOCATION_COUNT.WEEK; + interval = RULE_PREVIEW_INTERVAL.WEEK; + from = RULE_PREVIEW_FROM.WEEK; break; case 'M': invocationCount = RULE_PREVIEW_INVOCATION_COUNT.MONTH; + interval = RULE_PREVIEW_INTERVAL.MONTH; + from = RULE_PREVIEW_FROM.MONTH; break; } @@ -60,7 +72,14 @@ export const usePreviewRule = (timeframe: Unit = 'h') => { try { setIsLoading(true); const previewRuleResponse = await previewRule({ - rule: { ...transformOutput(rule), invocationCount }, + rule: { + ...transformOutput({ + ...rule, + interval, + from, + }), + invocationCount, + }, signal: abortCtrl.signal, }); if (isSubscribed) { @@ -82,7 +101,7 @@ export const usePreviewRule = (timeframe: Unit = 'h') => { isSubscribed = false; abortCtrl.abort(); }; - }, [rule, addError, invocationCount]); + }, [rule, addError, invocationCount, from, interval]); return { isLoading, response, rule, setRule }; }; From 725000d679348fe103aeba0c77c5a9e4d9a8486d Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 22 Mar 2022 18:51:26 +0100 Subject: [PATCH 046/132] [Lens] Implement null instead of zero switch (#127731) * implement null instead of zero switch * make default * fix tests * fix test * move into advanced options * show switch Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../search/aggs/buckets/multi_terms.test.ts | 3 + .../common/search/aggs/buckets/terms.test.ts | 3 + .../common/search/aggs/metrics/cardinality.ts | 2 + .../aggs/metrics/cardinality_fn.test.ts | 1 + .../search/aggs/metrics/cardinality_fn.ts | 7 ++ .../data/common/search/aggs/metrics/count.ts | 15 +++- .../search/aggs/metrics/count_fn.test.ts | 1 + .../common/search/aggs/metrics/count_fn.ts | 7 ++ .../search/aggs/metrics/metric_agg_type.ts | 20 ++++- .../data/common/search/aggs/metrics/sum.ts | 2 + .../common/search/aggs/metrics/sum_fn.test.ts | 1 + .../data/common/search/aggs/metrics/sum_fn.ts | 7 ++ src/plugins/data/common/search/aggs/types.ts | 3 +- .../search/tabify/response_writer.test.ts | 2 + .../main/utils/fetch_chart.test.ts | 4 +- .../main/utils/get_chart_agg_config.test.ts | 4 +- .../snapshots/baseline/combined_test2.json | 2 +- .../snapshots/baseline/combined_test3.json | 2 +- .../snapshots/baseline/final_output_test.json | 2 +- .../snapshots/baseline/metric_all_data.json | 2 +- .../snapshots/baseline/metric_empty_data.json | 2 +- .../baseline/metric_multi_metric_data.json | 2 +- .../baseline/metric_percentage_mode.json | 2 +- .../baseline/metric_single_metric_data.json | 2 +- .../snapshots/baseline/partial_test_1.json | 2 +- .../snapshots/baseline/partial_test_2.json | 2 +- .../snapshots/baseline/step_output_test2.json | 2 +- .../snapshots/baseline/step_output_test3.json | 2 +- .../snapshots/baseline/tagcloud_all_data.json | 2 +- .../baseline/tagcloud_empty_data.json | 2 +- .../snapshots/baseline/tagcloud_fontsize.json | 2 +- .../baseline/tagcloud_metric_data.json | 2 +- .../snapshots/baseline/tagcloud_options.json | 2 +- .../dimension_panel/advanced_options.tsx | 40 +++++----- .../dimension_panel/dimension_editor.tsx | 50 ++++++------ .../dimension_panel/dimension_panel.test.tsx | 2 +- .../droppable/droppable.test.ts | 4 +- .../operations/definitions/cardinality.tsx | 69 +++++++++++++++-- .../operations/definitions/column_types.ts | 14 ++-- .../operations/definitions/count.tsx | 76 ++++++++++++++++--- .../operations/definitions/formula/parse.ts | 13 ++-- .../operations/definitions/index.ts | 12 +++ .../operations/definitions/metrics.tsx | 72 +++++++++++++++--- .../operations/layer_helpers.test.ts | 1 + 44 files changed, 359 insertions(+), 110 deletions(-) diff --git a/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts b/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts index 7751c47575f42..f390bd860a219 100644 --- a/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/multi_terms.test.ts @@ -100,6 +100,9 @@ describe('Multi Terms Agg', () => { "chain": Array [ Object { "arguments": Object { + "emptyAsNull": Array [ + false, + ], "enabled": Array [ true, ], diff --git a/src/plugins/data/common/search/aggs/buckets/terms.test.ts b/src/plugins/data/common/search/aggs/buckets/terms.test.ts index 524606f7c562f..a2d9b94283e8b 100644 --- a/src/plugins/data/common/search/aggs/buckets/terms.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/terms.test.ts @@ -114,6 +114,9 @@ describe('Terms Agg', () => { "chain": Array [ Object { "arguments": Object { + "emptyAsNull": Array [ + false, + ], "enabled": Array [ true, ], diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality.ts b/src/plugins/data/common/search/aggs/metrics/cardinality.ts index 5a18924902fc3..f965deb6b0fe8 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality.ts @@ -19,6 +19,7 @@ const uniqueCountTitle = i18n.translate('data.search.aggs.metrics.uniqueCountTit export interface AggParamsCardinality extends BaseAggParams { field: string; + emptyAsNull?: boolean; } export const getCardinalityMetricAgg = () => @@ -27,6 +28,7 @@ export const getCardinalityMetricAgg = () => valueType: 'number', expressionName: aggCardinalityFnName, title: uniqueCountTitle, + enableEmptyAsNull: true, makeLabel(aggConfig: IMetricAggConfig) { return i18n.translate('data.search.aggs.metrics.uniqueCountLabel', { defaultMessage: 'Unique count of {field}', diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts index 08d64e599d8a9..2d1f8a7baa23b 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.test.ts @@ -25,6 +25,7 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, + "emptyAsNull": undefined, "field": "machine.os.keyword", "json": undefined, "timeShift": undefined, diff --git a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts index 89006761407f7..cdff364f7c45e 100644 --- a/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/cardinality_fn.ts @@ -74,6 +74,13 @@ export const aggCardinality = (): FunctionDefinition => ({ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', }), }, + emptyAsNull: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.metrics.emptyAsNull.help', { + defaultMessage: + 'If set to true, a missing value is treated as null in the resulting data table. If set to false, a "zero" is filled in', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/count.ts b/src/plugins/data/common/search/aggs/metrics/count.ts index fac1751290f70..be3c6b7cdfb53 100644 --- a/src/plugins/data/common/search/aggs/metrics/count.ts +++ b/src/plugins/data/common/search/aggs/metrics/count.ts @@ -7,10 +7,15 @@ */ import { i18n } from '@kbn/i18n'; +import { BaseAggParams } from '../types'; import { aggCountFnName } from './count_fn'; import { MetricAggType } from './metric_agg_type'; import { METRIC_TYPES } from './metric_agg_types'; +export interface AggParamsCount extends BaseAggParams { + emptyAsNull?: boolean; +} + export const getCountMetricAgg = () => new MetricAggType({ name: METRIC_TYPES.COUNT, @@ -20,6 +25,7 @@ export const getCountMetricAgg = () => }), hasNoDsl: true, json: false, + enableEmptyAsNull: true, makeLabel() { return i18n.translate('data.search.aggs.metrics.countLabel', { defaultMessage: 'Count', @@ -32,11 +38,16 @@ export const getCountMetricAgg = () => }, getValue(agg, bucket) { const timeShift = agg.getTimeShift(); + let value: unknown; if (!timeShift) { - return bucket.doc_count; + value = bucket.doc_count; } else { - return bucket[`doc_count_${timeShift.asMilliseconds()}`]; + value = bucket[`doc_count_${timeShift.asMilliseconds()}`]; + } + if (value === 0 && agg.params.emptyAsNull) { + return null; } + return value; }, isScalable() { return true; diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts index c6736c5b69f7d..7a68b7a962373 100644 --- a/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/count_fn.test.ts @@ -23,6 +23,7 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, + "emptyAsNull": undefined, "timeShift": undefined, }, "schema": undefined, diff --git a/src/plugins/data/common/search/aggs/metrics/count_fn.ts b/src/plugins/data/common/search/aggs/metrics/count_fn.ts index a3a4bcc16a391..c302e0abf7c5d 100644 --- a/src/plugins/data/common/search/aggs/metrics/count_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/count_fn.ts @@ -61,6 +61,13 @@ export const aggCount = (): FunctionDefinition => ({ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', }), }, + emptyAsNull: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.metrics.emptyAsNull.help', { + defaultMessage: + 'If set to true, a missing value is treated as null in the resulting data table. If set to false, a "zero" is filled in', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts index 5237c1ecffe58..c96ba217779a6 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts @@ -31,6 +31,7 @@ interface MetricAggTypeConfig extends AggTypeConfig> { isScalable?: () => boolean; subtype?: string; + enableEmptyAsNull?: boolean; } // TODO need to make a more explicit interface for this @@ -57,6 +58,17 @@ export class MetricAggType ); + if (config.enableEmptyAsNull) { + this.params.push( + new BaseParamType({ + name: 'emptyAsNull', + type: 'boolean', + default: false, + write: () => {}, + }) as MetricAggParam + ); + } + this.getValue = config.getValue || ((agg, bucket) => { @@ -67,9 +79,13 @@ export class MetricAggType { @@ -27,6 +28,7 @@ export const getSumMetricAgg = () => { expressionName: aggSumFnName, title: sumTitle, valueType: 'number', + enableEmptyAsNull: true, makeLabel(aggConfig) { return i18n.translate('data.search.aggs.metrics.sumLabel', { defaultMessage: 'Sum of {field}', diff --git a/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts b/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts index f4d4fb5451dcd..3f36f98f40eac 100644 --- a/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts +++ b/src/plugins/data/common/search/aggs/metrics/sum_fn.test.ts @@ -25,6 +25,7 @@ describe('agg_expression_functions', () => { "id": undefined, "params": Object { "customLabel": undefined, + "emptyAsNull": undefined, "field": "machine.os.keyword", "json": undefined, "timeShift": undefined, diff --git a/src/plugins/data/common/search/aggs/metrics/sum_fn.ts b/src/plugins/data/common/search/aggs/metrics/sum_fn.ts index d8e03d28bb12a..063cc10839813 100644 --- a/src/plugins/data/common/search/aggs/metrics/sum_fn.ts +++ b/src/plugins/data/common/search/aggs/metrics/sum_fn.ts @@ -69,6 +69,13 @@ export const aggSum = (): FunctionDefinition => ({ 'Shift the time range for the metric by a set time, for example 1h or 7d. "previous" will use the closest time range from the date histogram or time range filter.', }), }, + emptyAsNull: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.metrics.emptyAsNull.help', { + defaultMessage: + 'If set to true, a missing value is treated as null in the resulting data table. If set to false, a "zero" is filled in', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index edc328bcb5099..3781b35e5b767 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -95,6 +95,7 @@ import { AggParamsDiversifiedSampler } from './buckets/diversified_sampler'; import { AggParamsSignificantText } from './buckets/significant_text'; import { AggParamsTopMetrics } from './metrics/top_metrics'; import { aggTopMetrics } from './metrics/top_metrics_fn'; +import { AggParamsCount } from './metrics'; export type { IAggConfig, AggConfigSerialized } from './agg_config'; export type { CreateAggConfigParams, IAggConfigs } from './agg_configs'; @@ -168,7 +169,7 @@ export interface AggParamsMapping { [BUCKET_TYPES.DIVERSIFIED_SAMPLER]: AggParamsDiversifiedSampler; [METRIC_TYPES.AVG]: AggParamsAvg; [METRIC_TYPES.CARDINALITY]: AggParamsCardinality; - [METRIC_TYPES.COUNT]: BaseAggParams; + [METRIC_TYPES.COUNT]: AggParamsCount; [METRIC_TYPES.GEO_BOUNDS]: AggParamsGeoBounds; [METRIC_TYPES.GEO_CENTROID]: AggParamsGeoCentroid; [METRIC_TYPES.MAX]: AggParamsMax; diff --git a/src/plugins/data/common/search/tabify/response_writer.test.ts b/src/plugins/data/common/search/tabify/response_writer.test.ts index ec131458b8510..85f815447619a 100644 --- a/src/plugins/data/common/search/tabify/response_writer.test.ts +++ b/src/plugins/data/common/search/tabify/response_writer.test.ts @@ -201,6 +201,7 @@ describe('TabbedAggResponseWriter class', () => { indexPatternId: '1234', params: { field: 'machine.os.raw', + emptyAsNull: false, }, type: 'cardinality', }, @@ -264,6 +265,7 @@ describe('TabbedAggResponseWriter class', () => { indexPatternId: '1234', params: { field: 'machine.os.raw', + emptyAsNull: false, }, type: 'cardinality', }, diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts index 9f3a25e15d741..17423fad1ae9e 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts @@ -76,7 +76,9 @@ describe('test fetchCharts', () => { Object { "enabled": true, "id": "1", - "params": Object {}, + "params": Object { + "emptyAsNull": false, + }, "schema": "metric", "type": "count", }, diff --git a/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts b/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts index ccd7584d4bff3..ded1cf1d858af 100644 --- a/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_chart_agg_config.test.ts @@ -33,7 +33,9 @@ describe('getChartAggConfigs', () => { Object { "enabled": true, "id": "1", - "params": Object {}, + "params": Object { + "emptyAsNull": false, + }, "schema": "metric", "type": "count", }, diff --git a/test/interpreter_functional/snapshots/baseline/combined_test2.json b/test/interpreter_functional/snapshots/baseline/combined_test2.json index 3b030ec8fb597..b4129ac898eed 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test2.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/combined_test3.json b/test/interpreter_functional/snapshots/baseline/combined_test3.json index dc39ecfc53594..dc1c037f45e95 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test3.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/final_output_test.json b/test/interpreter_functional/snapshots/baseline/final_output_test.json index dc39ecfc53594..dc1c037f45e95 100644 --- a/test/interpreter_functional/snapshots/baseline/final_output_test.json +++ b/test/interpreter_functional/snapshots/baseline/final_output_test.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_all_data.json b/test/interpreter_functional/snapshots/baseline/metric_all_data.json index a26b85daee932..939e51b619928 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_all_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json index 0917ecc8f9b2e..6adb4e117d2c7 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json index 44bc7717db04f..4a324a133c057 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json index 99604aa377475..944820d0ed16d 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json +++ b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json index 63c91a3cc749d..392649d410e15 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/partial_test_1.json index e8a847b43de3b..8ce0ee16a0b3b 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_1.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_2.json b/test/interpreter_functional/snapshots/baseline/partial_test_2.json index dc39ecfc53594..dc1c037f45e95 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_2.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_2.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test2.json b/test/interpreter_functional/snapshots/baseline/step_output_test2.json index 3b030ec8fb597..b4129ac898eed 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test2.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test3.json b/test/interpreter_functional/snapshots/baseline/step_output_test3.json index dc39ecfc53594..dc1c037f45e95 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test3.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json index 518eb529e70f4..837251a438911 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index 7417545550cd8..5c3ca14f4eab7 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json index 986e6d19e91f3..5e99024d6e52b 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json index cf0aa1162c23f..e00233197bda3 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json index 357ac0fc76784..759b2752f9328 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx index db3dfe8901fb9..3d1928edf27dc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx @@ -8,15 +8,7 @@ import { EuiLink, EuiText, EuiPopover, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; - -interface AdvancedOption { - title: string; - dataTestSubj: string; - onClick: () => void; - showInPopover: boolean; - inlineElement: React.ReactElement | null; - helpPopup?: string | null; -} +import { AdvancedOption } from '../operations/definitions'; export function AdvancedOptions(props: { options: AdvancedOption[] }) { const [popoverOpen, setPopoverOpen] = useState(false); @@ -49,20 +41,24 @@ export function AdvancedOptions(props: { options: AdvancedOption[] }) { setPopoverOpen(false); }} > - {popoverOptions.map(({ dataTestSubj, onClick, title }, index) => ( + {popoverOptions.map(({ dataTestSubj, onClick, title, optionElement }, index) => ( - - { - setPopoverOpen(false); - onClick(); - }} - > - {title} - - + {optionElement ? ( + optionElement + ) : ( + + { + setPopoverOpen(false); + onClick(); + }} + > + {title} + + + )} {popoverOptions.length - 1 !== index && } ))} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 24f3a5a65c8d9..a9e37e2d53d70 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -57,6 +57,7 @@ import { import type { TemporaryState } from './dimensions_editor_helpers'; import { FieldInput } from './field_input'; import { NameInput } from '../../shared_components'; +import { ParamEditorProps } from '../operations/definitions'; const operationPanels = getOperationDisplay(); @@ -422,6 +423,28 @@ export function DimensionEditor(props: DimensionEditorProps) { const FieldInputComponent = selectedOperationDefinition?.renderFieldInput || FieldInput; + const paramEditorProps: ParamEditorProps = { + layer: state.layers[layerId], + layerId, + activeData: props.activeData, + updateLayer: (setter) => { + if (temporaryQuickFunction) { + setTemporaryState('none'); + } + setStateWrapper(setter, { forceRender: temporaryQuickFunction }); + }, + columnId, + currentColumn: state.layers[layerId].columns[columnId], + dateRange, + indexPattern: currentIndexPattern, + operationDefinitionMap, + toggleFullscreen, + isFullscreen, + setIsCloseable, + paramEditorCustomProps, + ...services, + }; + const quickFunctions = ( <>
@@ -517,29 +540,7 @@ export function DimensionEditor(props: DimensionEditorProps) { /> ) : null} - {shouldDisplayExtraOptions && ( - { - if (temporaryQuickFunction) { - setTemporaryState('none'); - } - setStateWrapper(setter, { forceRender: temporaryQuickFunction }); - }} - columnId={columnId} - currentColumn={state.layers[layerId].columns[columnId]} - dateRange={dateRange} - indexPattern={currentIndexPattern} - operationDefinitionMap={operationDefinitionMap} - toggleFullscreen={toggleFullscreen} - isFullscreen={isFullscreen} - setIsCloseable={setIsCloseable} - paramEditorCustomProps={paramEditorCustomProps} - {...services} - /> - )} + {shouldDisplayExtraOptions && }
); @@ -767,6 +768,9 @@ export function DimensionEditor(props: DimensionEditorProps) { /> ) : null, }, + ...(operationDefinitionMap[selectedColumn.operationType].getAdvancedOptions?.( + paramEditorProps + ) || []), ]} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 70baa1d772e1b..356ad5ac9543e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -602,7 +602,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: expect.objectContaining({ operationType: 'min', sourceField: 'bytes', - params: { format: { id: 'bytes' } }, + params: { format: { id: 'bytes' }, emptyAsNull: true }, // Other parts of this don't matter for this test }), }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 2b193eb01c9d6..ea3978ce8ca94 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -2306,7 +2306,9 @@ describe('IndexPatternDimensionEditorPanel', () => { sourceField: 'src', timeShift: undefined, dataType: 'number', - params: undefined, + params: { + emptyAsNull: true, + }, scale: 'ratio', }, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx index c97447803524d..8490b48ad320e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/cardinality.tsx @@ -6,10 +6,13 @@ */ import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiSwitch } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; import { AggFunctionsMapping } from '../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public'; -import { OperationDefinition } from './index'; -import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types'; +import { OperationDefinition, ParamEditorProps } from './index'; +import { FieldBasedIndexPatternColumn, FormatParams } from './column_types'; import { getFormatFromPreviousColumn, @@ -17,9 +20,11 @@ import { getSafeName, getFilter, combineErrorMessages, + isColumnOfType, } from './helpers'; import { adjustTimeScaleLabelSuffix } from '../time_scale_utils'; import { getDisallowedPreviousShiftMessage } from '../../time_shift_utils'; +import { updateColumnParam } from '../layer_helpers'; const supportedTypes = new Set([ 'string', @@ -51,10 +56,12 @@ function ofName(name: string, timeShift: string | undefined) { ); } -export interface CardinalityIndexPatternColumn - extends FormattedIndexPatternColumn, - FieldBasedIndexPatternColumn { +export interface CardinalityIndexPatternColumn extends FieldBasedIndexPatternColumn { operationType: typeof OPERATION_TYPE; + params?: { + emptyAsNull?: boolean; + format?: FormatParams; + }; } export const cardinalityOperation: OperationDefinition = { @@ -101,9 +108,58 @@ export const cardinalityOperation: OperationDefinition('unique_count', previousColumn) + ? previousColumn.params?.emptyAsNull + : !columnParams?.usedInMath, + }, }; }, + getAdvancedOptions: ({ + layer, + columnId, + currentColumn, + updateLayer, + }: ParamEditorProps) => { + return [ + { + dataTestSubj: 'hide-zero-values', + optionElement: ( + <> + { + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'emptyAsNull', + value: !currentColumn.params?.emptyAsNull, + }) + ); + }} + compressed + /> + + ), + title: '', + showInPopover: true, + inlineElement: null, + onClick: () => {}, + }, + ]; + }, toEsAggsFn: (column, columnId) => { return buildExpressionFunction('aggCardinality', { id: columnId, @@ -112,6 +168,7 @@ export const cardinalityOperation: OperationDefinition { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index 2b11d182eeed0..333312116949f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -19,15 +19,17 @@ export interface BaseIndexPatternColumn extends Operation { timeShift?: string; } +export interface FormatParams { + id: string; + params?: { + decimals: number; + }; +} + // Formatting can optionally be added to any column export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn { params?: { - format?: { - id: string; - params?: { - decimals: number; - }; - }; + format?: FormatParams; }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx index a35f8fbc08acf..7ecd5a4970c95 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/count.tsx @@ -6,31 +6,39 @@ */ import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EuiSwitch } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public'; -import { OperationDefinition } from './index'; -import { FormattedIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types'; +import { OperationDefinition, ParamEditorProps } from './index'; +import { FieldBasedIndexPatternColumn, FormatParams } from './column_types'; import { IndexPatternField } from '../../types'; import { getInvalidFieldMessage, getFilter, - isColumnFormatted, combineErrorMessages, + getFormatFromPreviousColumn, + isColumnOfType, } from './helpers'; import { adjustTimeScaleLabelSuffix, adjustTimeScaleOnOtherColumnChange, } from '../time_scale_utils'; import { getDisallowedPreviousShiftMessage } from '../../time_shift_utils'; +import { updateColumnParam } from '../layer_helpers'; const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', { defaultMessage: 'Count of records', }); -export type CountIndexPatternColumn = FormattedIndexPatternColumn & - FieldBasedIndexPatternColumn & { - operationType: 'count'; +export type CountIndexPatternColumn = FieldBasedIndexPatternColumn & { + operationType: 'count'; + params?: { + emptyAsNull?: boolean; + format?: FormatParams; }; +}; export const countOperation: OperationDefinition = { type: 'count', @@ -91,14 +99,57 @@ export const countOperation: OperationDefinition('count', previousColumn) + ? previousColumn.params?.emptyAsNull + : !columnParams?.usedInMath, + }, }; }, + getAdvancedOptions: ({ + layer, + columnId, + currentColumn, + updateLayer, + }: ParamEditorProps) => { + return [ + { + dataTestSubj: 'hide-zero-values', + optionElement: ( + <> + { + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'emptyAsNull', + value: !currentColumn.params?.emptyAsNull, + }) + ); + }} + compressed + /> + + ), + title: '', + showInPopover: true, + inlineElement: null, + onClick: () => {}, + }, + ]; + }, onOtherColumnChanged: (layer, thisColumnId, changedColumnId) => adjustTimeScaleOnOtherColumnChange( layer, @@ -112,6 +163,7 @@ export const countOperation: OperationDefinition { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts index a3b61429fb0bf..81a6943e600d0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts @@ -107,11 +107,14 @@ function extractColumns( ? indexPattern.getFieldByName(fieldName.value)! : documentField; - const mappedParams = mergeWithGlobalFilter( - nodeOperation, - getOperationParams(nodeOperation, namedArguments || []), - globalFilter - ); + const mappedParams = { + ...mergeWithGlobalFilter( + nodeOperation, + getOperationParams(nodeOperation, namedArguments || []), + globalFilter + ), + usedInMath: true, + }; const newCol = ( nodeOperation as OperationDefinition diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index dec70130d1282..87c7ab5913a20 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -191,6 +191,16 @@ export interface HelpProps { export type TimeScalingMode = 'disabled' | 'mandatory' | 'optional'; +export interface AdvancedOption { + title: string; + optionElement?: React.ReactElement; + dataTestSubj: string; + onClick: () => void; + showInPopover: boolean; + inlineElement: React.ReactElement | null; + helpPopup?: string | null; +} + interface BaseOperationDefinitionProps { type: C['operationType']; /** @@ -227,6 +237,7 @@ interface BaseOperationDefinitionProps * React component for operation specific settings shown in the flyout editor */ paramEditor?: React.ComponentType>; + getAdvancedOptions?: (params: ParamEditorProps) => AdvancedOption[] | undefined; /** * Returns true if the `column` can also be used on `newIndexPattern`. * If this function returns false, the column is removed when switching index pattern @@ -416,6 +427,7 @@ interface FieldBasedOperationDefinition C; /** diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index f2ab811427ac5..2b46e52defdba 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -6,30 +6,34 @@ */ import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { EuiSwitch } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; import { buildExpressionFunction } from '../../../../../../../src/plugins/expressions/public'; -import { OperationDefinition } from './index'; +import { OperationDefinition, ParamEditorProps } from './index'; import { getFormatFromPreviousColumn, getInvalidFieldMessage, getSafeName, getFilter, combineErrorMessages, + isColumnOfType, } from './helpers'; -import { - FormattedIndexPatternColumn, - FieldBasedIndexPatternColumn, - BaseIndexPatternColumn, -} from './column_types'; +import { FieldBasedIndexPatternColumn, BaseIndexPatternColumn, FormatParams } from './column_types'; import { adjustTimeScaleLabelSuffix, adjustTimeScaleOnOtherColumnChange, } from '../time_scale_utils'; import { getDisallowedPreviousShiftMessage } from '../../time_shift_utils'; +import { updateColumnParam } from '../layer_helpers'; -type MetricColumn = FormattedIndexPatternColumn & - FieldBasedIndexPatternColumn & { - operationType: T; +type MetricColumn = FieldBasedIndexPatternColumn & { + operationType: T; + params?: { + emptyAsNull?: boolean; + format?: FormatParams; }; +}; const typeToFn: Record = { min: 'aggMin', @@ -49,6 +53,7 @@ function buildMetricOperation>({ priority, optionalTimeScaling, supportsDate, + hideZeroOption, }: { type: T['operationType']; displayName: string; @@ -57,6 +62,7 @@ function buildMetricOperation>({ optionalTimeScaling?: boolean; description?: string; supportsDate?: boolean; + hideZeroOption?: boolean; }) { const labelLookup = (name: string, column?: BaseIndexPatternColumn) => { const label = ofName(name); @@ -115,7 +121,13 @@ function buildMetricOperation>({ timeScale: optionalTimeScaling ? previousColumn?.timeScale : undefined, filter: getFilter(previousColumn, columnParams), timeShift: columnParams?.shift || previousColumn?.timeShift, - params: getFormatFromPreviousColumn(previousColumn), + params: { + ...getFormatFromPreviousColumn(previousColumn), + emptyAsNull: + hideZeroOption && previousColumn && isColumnOfType(type, previousColumn) + ? previousColumn.params?.emptyAsNull + : !columnParams?.usedInMath, + }, } as T; }, onFieldChange: (oldColumn, field) => { @@ -126,6 +138,44 @@ function buildMetricOperation>({ sourceField: field.name, }; }, + getAdvancedOptions: ({ layer, columnId, currentColumn, updateLayer }: ParamEditorProps) => { + if (!hideZeroOption) return []; + return [ + { + dataTestSubj: 'hide-zero-values', + optionElement: ( + <> + { + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'emptyAsNull', + value: !currentColumn.params?.emptyAsNull, + }) + ); + }} + compressed + /> + + ), + title: '', + showInPopover: true, + inlineElement: null, + onClick: () => {}, + }, + ]; + }, toEsAggsFn: (column, columnId, _indexPattern) => { return buildExpressionFunction(typeToFn[type], { id: columnId, @@ -134,6 +184,7 @@ function buildMetricOperation>({ field: column.sourceField, // time shift is added to wrapping aggFilteredMetric if filter is set timeShift: column.filter ? undefined : column.timeShift, + emptyAsNull: hideZeroOption ? column.params?.emptyAsNull : undefined, }).toAst(); }, getErrorMessage: (layer, columnId, indexPattern) => @@ -242,6 +293,7 @@ export const sumOperation = buildMetricOperation({ defaultMessage: 'A single-value metrics aggregation that sums up numeric values that are extracted from the aggregated documents.', }), + hideZeroOption: true, }); export const medianOperation = buildMetricOperation({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index f7a8df3d5ef1f..b6398970056e2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -2162,6 +2162,7 @@ describe('state_helpers', () => { id: 'number', params: { decimals: 2 }, }, + emptyAsNull: true, }, }) ); From 5d519f3e72af628abdfa1d3d5d51712f1943d871 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Tue, 22 Mar 2022 19:13:33 +0100 Subject: [PATCH 047/132] [Workplace Search] Add feedback link to pages with external source (#128290) --- .../components/add_source/add_source.test.tsx | 16 +++++++++ .../components/add_source/add_source.tsx | 1 + .../add_source/config_completed.test.tsx | 7 ++++ .../add_source/config_completed.tsx | 29 +++++++++++++++ .../components/overview.test.tsx | 18 +++++++++- .../content_sources/components/overview.tsx | 30 ++++++++++++++++ .../components/source_config.test.tsx | 10 +++++- .../settings/components/source_config.tsx | 35 ++++++++++++++++++- 8 files changed, 143 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index 8e3171dc71bec..d4b5a1dbd9829 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -134,12 +134,28 @@ describe('AddSourceList', () => { addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); const wrapper = shallow(); + expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(false); wrapper.find(ConfigCompleted).prop('advanceStep')(); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); + it('renders Config Completed step with feedback for external connectors', () => { + setMockValues({ + ...mockValues, + sourceConfigData: { ...sourceConfigData, serviceType: 'external' }, + addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, + }); + const wrapper = shallow( + + ); + expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(true); + + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); + }); + it('renders Save Config step', () => { setMockValues({ ...mockValues, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index d2c665a4acd74..4bdf8db217a7b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -109,6 +109,7 @@ export const AddSource: React.FC = (props) => { advanceStep={goToConnectInstance} privateSourcesEnabled={privateSourcesEnabled} header={header} + showFeedbackLink={serviceType === 'external'} /> )} {addSourceCurrentStep === AddSourceSteps.ConnectInstanceStep && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.test.tsx index 163da5297e370..0980bd8a61cd6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.test.tsx @@ -9,6 +9,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiCallOut } from '@elastic/eui'; + import { ConfigCompleted } from './config_completed'; describe('ConfigCompleted', () => { @@ -26,6 +28,7 @@ describe('ConfigCompleted', () => { expect(wrapper.find('[data-test-subj="OrgCanConnectMessage"]')).toHaveLength(1); expect(wrapper.find('[data-test-subj="PersonalConnectLinkMessage"]')).toHaveLength(0); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); }); it('renders account context', () => { @@ -45,4 +48,8 @@ describe('ConfigCompleted', () => { expect(wrapper.find('[data-test-subj="PrivateDisabledMessage"]')).toHaveLength(1); }); + it('renders feedback callout when set', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx index 9b34053bfe524..edd39409893a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { EuiButton, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -37,6 +38,7 @@ interface ConfigCompletedProps { accountContextOnly?: boolean; privateSourcesEnabled: boolean; advanceStep(): void; + showFeedbackLink?: boolean; } export const ConfigCompleted: React.FC = ({ @@ -45,6 +47,7 @@ export const ConfigCompleted: React.FC = ({ accountContextOnly, header, privateSourcesEnabled, + showFeedbackLink, }) => ( <> {header} @@ -166,5 +169,31 @@ export const ConfigCompleted: React.FC = ({ )}
+ {showFeedbackLink && ( + <> + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.configCompleted.feedbackCallOutText', + { + defaultMessage: + 'Have feedback about deploying a {name} Connector Package? Let us know.', + values: { name }, + } + )} + + } + /> + + + + )} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index c9eb2e0afdf5e..21a71308a1832 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiConfirmModal, EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiEmptyPrompt, EuiPanel, EuiTable } from '@elastic/eui'; import { ComponentLoader } from '../../../components/shared/component_loader'; @@ -121,6 +121,22 @@ describe('Overview', () => { expect(wrapper.find('[data-test-subj="DocumentPermissionsDisabled"]')).toHaveLength(1); }); + it('renders feedback callout for external sources', () => { + setMockValues({ + ...mockValues, + contentSource: { + ...fullContentSources[1], + serviceTypeSupportsPermissions: true, + custom: false, + serviceType: 'external', + }, + }); + + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); + it('handles confirmModal submission', () => { const wrapper = shallow(); const button = wrapper.find('[data-test-subj="SyncButton"]'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index e5c4b3a09f93f..8f287537e4109 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -29,7 +29,9 @@ import { EuiText, EuiTextColor, EuiTitle, + EuiCallOut, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { CANCEL_BUTTON_LABEL, START_BUTTON_LABEL } from '../../../../shared/constants'; @@ -107,6 +109,8 @@ export const Overview: React.FC = () => { hasPermissions, isFederatedSource, isIndexedSource, + serviceType, + name, } = contentSource; const [isSyncing, setIsSyncing] = useState(false); @@ -582,6 +586,32 @@ export const Overview: React.FC = () => { + {serviceType === 'external' && ( + <> + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.feedbackCallOutText', + { + defaultMessage: + 'Have feedback about deploying a {name} Connector Package? Let us know.', + values: { name }, + } + )} + + } + /> + + + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index af8b8fe461f16..8399df946ea83 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiConfirmModal } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; @@ -40,6 +40,7 @@ describe('SourceConfig', () => { saveConfig.prop('onDeleteConfig')!(); expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); + expect(wrapper.find(EuiCallOut)).toHaveLength(0); }); it('renders a breadcrumb fallback while data is loading', () => { @@ -84,4 +85,11 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiConfirmModal)).toHaveLength(0); }); + + it('shows feedback link for external sources', () => { + const wrapper = shallow( + + ); + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index ea63f3bab77d9..6973732fa6727 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -9,7 +9,14 @@ import React, { useEffect, useState } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiConfirmModal } from '@elastic/eui'; +import { + EuiCallOut, + EuiConfirmModal, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; @@ -77,6 +84,32 @@ export const SourceConfig: React.FC = ({ sourceData }) => { )} )} + {serviceType === 'external' && ( + <> + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.feedbackCallOutText', + { + defaultMessage: + 'Have feedback about deploying a {name} Connector Package? Let us know.', + values: { name }, + } + )} + + } + /> + + + + )} ); }; From 0dc168e086f09ae1605ddab0a3d4b9e6f5366d47 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Tue, 22 Mar 2022 19:49:08 +0100 Subject: [PATCH 048/132] [Cases] Initial functional tests for cases in the stack management page (#127858) * Adding data-test-subj to cases header component * adding casesApp service for functional tests * Adding test for create case * Add more tests * Add tests for cases list * Update tests file structure * Improve test structure * Add cleanup methods * Remove empty functions * Use api to create case to edit * move some repeated code to a service * Unify casesapp provider in a single namespace * Apply PR comment suggestions * Remove .only from test suite * Fix broken unit test * Attempt to fix flaky test * Another attempt to fix flaky test * Move checks up for flaky tests * increase timeout for flaky test * Try to fix flaky test * MOre fixes for flaky test * rename cases app and fix nitpicks * Rename variables * fix more nits * add more create case validatioons * add more create and edit case validations * Add extra validations to edit case * Fix typo * try to fix flaky test --- .../public/components/all_cases/table.tsx | 2 +- .../public/components/header_page/index.tsx | 4 +- .../utility_bar/utility_bar_action.tsx | 4 +- x-pack/test/functional/config.js | 3 + x-pack/test/functional/services/cases/api.ts | 45 +++++ .../test/functional/services/cases/common.ts | 160 +++++++++++++++++ .../test/functional/services/cases/helpers.ts | 26 +++ .../test/functional/services/cases/index.ts | 17 ++ x-pack/test/functional/services/index.ts | 2 + .../apps/cases/create_case_form.ts | 51 ++++++ .../apps/cases/edit_case_form.ts | 169 ++++++++++++++++++ .../apps/cases/index.ts | 17 ++ .../apps/cases/list_view.ts | 122 +++++++++++++ x-pack/test/functional_with_es_ssl/config.ts | 1 + 14 files changed, 619 insertions(+), 4 deletions(-) create mode 100644 x-pack/test/functional/services/cases/api.ts create mode 100644 x-pack/test/functional/services/cases/common.ts create mode 100644 x-pack/test/functional/services/cases/helpers.ts create mode 100644 x-pack/test/functional/services/cases/index.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/cases/edit_case_form.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/cases/index.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts diff --git a/x-pack/plugins/cases/public/components/all_cases/table.tsx b/x-pack/plugins/cases/public/components/all_cases/table.tsx index 2a2cf79e6f690..8190acce9e784 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table.tsx @@ -88,7 +88,7 @@ export const CasesTable: FunctionComponent = ({ ) : ( -
+
= ({ @@ -73,6 +74,7 @@ const HeaderPageComponent: React.FC = ({ subtitle2, title, titleNode, + 'data-test-subj': dataTestSubj, }) => { const { releasePhase } = useCasesContext(); const { getAllCasesUrl, navigateToAllCases } = useAllCasesNavigation(); @@ -88,7 +90,7 @@ const HeaderPageComponent: React.FC = ({ ); return ( -
+
{showBackButton && ( diff --git a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx index 19cb8ef4f613b..e5bed87021491 100644 --- a/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx +++ b/x-pack/plugins/cases/public/components/utility_bar/utility_bar_action.tsx @@ -46,15 +46,15 @@ Popover.displayName = 'Popover'; export interface UtilityBarActionProps extends LinkIconProps { popoverContent?: (closePopover: () => void) => React.ReactNode; - dataTestSubj?: string; ownFocus?: boolean; + dataTestSubj?: string; } export const UtilityBarAction = React.memo( ({ + dataTestSubj, children, color, - dataTestSubj, disabled, href, iconSide, diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 1c627dc8af6da..28000c3d4bac8 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -119,6 +119,9 @@ export default async function ({ readConfigFile }) { logstashPipelines: { pathname: '/app/management/ingest/pipelines', }, + cases: { + pathname: '/app/management/insightsAndAlerting/cases/', + }, maps: { pathname: '/app/maps', }, diff --git a/x-pack/test/functional/services/cases/api.ts b/x-pack/test/functional/services/cases/api.ts new file mode 100644 index 0000000000000..bacb08cd19b2d --- /dev/null +++ b/x-pack/test/functional/services/cases/api.ts @@ -0,0 +1,45 @@ +/* + * 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 pMap from 'p-map'; +import { CasePostRequest } from '../../../../plugins/cases/common/api'; +import { createCase, deleteAllCaseItems } from '../../../cases_api_integration/common/lib/utils'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { generateRandomCaseWithoutConnector } from './helpers'; + +export function CasesAPIServiceProvider({ getService }: FtrProviderContext) { + const kbnSupertest = getService('supertest'); + const es = getService('es'); + + return { + async createCaseWithData(overwrites: { title?: string } = {}) { + const caseData = { + ...generateRandomCaseWithoutConnector(), + ...overwrites, + } as CasePostRequest; + await createCase(kbnSupertest, caseData); + }, + + async createNthRandomCases(amount: number = 3) { + const cases: CasePostRequest[] = Array.from( + { length: amount }, + () => generateRandomCaseWithoutConnector() as CasePostRequest + ); + await pMap( + cases, + (caseData) => { + return createCase(kbnSupertest, caseData); + }, + { concurrency: 4 } + ); + }, + + async deleteAllCases() { + deleteAllCaseItems(es); + }, + }; +} diff --git a/x-pack/test/functional/services/cases/common.ts b/x-pack/test/functional/services/cases/common.ts new file mode 100644 index 0000000000000..ad5fbb7be7233 --- /dev/null +++ b/x-pack/test/functional/services/cases/common.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import uuid from 'uuid'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function CasesCommonServiceProvider({ getService, getPageObject }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const comboBox = getService('comboBox'); + const header = getPageObject('header'); + return { + /** + * Opens the create case page pressing the "create case" button. + * + * Doesn't do navigation. Only works if you are already inside a cases app page. + * Does not work with the cases flyout. + */ + async openCreateCasePage() { + await testSubjects.click('createNewCaseBtn'); + await testSubjects.existOrFail('create-case-submit', { + timeout: 5000, + }); + }, + + /** + * it creates a new case from the create case page + * and leaves the navigation in the case view page + * + * Doesn't do navigation. Only works if you are already inside a cases app page. + * Does not work with the cases flyout. + */ + async createCaseFromCreateCasePage({ + title = 'test-' + uuid.v4(), + description = 'desc' + uuid.v4(), + tag = 'tagme', + }: { + title: string; + description: string; + tag: string; + }) { + await this.openCreateCasePage(); + + // case name + await testSubjects.setValue('input', title); + + // case tag + await comboBox.setCustom('comboBoxInput', tag); + + // case description + const descriptionArea = await find.byCssSelector('textarea.euiMarkdownEditorTextArea'); + await descriptionArea.focus(); + await descriptionArea.type(description); + + // save + await testSubjects.click('create-case-submit'); + + await testSubjects.existOrFail('case-view-title'); + }, + + /** + * Goes to the first case listed on the table. + * + * This will fail if the table doesn't have any case + */ + async goToFirstListedCase() { + await testSubjects.existOrFail('cases-table'); + await testSubjects.click('case-details-link'); + await testSubjects.existOrFail('case-view-title'); + }, + + /** + * Marks a case in progress via the status dropdown + */ + async markCaseInProgressViaDropdown() { + await this.openCaseSetStatusDropdown(); + + await testSubjects.click('case-view-status-dropdown-in-progress'); + + // wait for backend response + await testSubjects.existOrFail('header-page-supplements > status-badge-in-progress', { + timeout: 5000, + }); + }, + + /** + * Marks a case closed via the status dropdown + */ + async markCaseClosedViaDropdown() { + this.openCaseSetStatusDropdown(); + + await testSubjects.click('case-view-status-dropdown-closed'); + + // wait for backend response + await testSubjects.existOrFail('header-page-supplements > status-badge-closed', { + timeout: 5000, + }); + }, + + /** + * Marks a case open via the status dropdown + */ + async markCaseOpenViaDropdown() { + this.openCaseSetStatusDropdown(); + + await testSubjects.click('case-view-status-dropdown-open'); + + // wait for backend response + await testSubjects.existOrFail('header-page-supplements > status-badge-open', { + timeout: 5000, + }); + }, + + async bulkDeleteAllCases() { + await testSubjects.setCheckbox('checkboxSelectAll', 'check'); + const button = await find.byCssSelector('[aria-label="Bulk actions"]'); + await button.click(); + await testSubjects.click('cases-bulk-delete-button'); + await testSubjects.click('confirmModalConfirmButton'); + }, + + async selectAndDeleteAllCases() { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('cases-table', { timeout: 20 * 1000 }); + let rows: WebElementWrapper[]; + do { + await header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 }); + rows = await find.allByCssSelector('[data-test-subj*="cases-table-row-"', 100); + if (rows.length > 0) { + await this.bulkDeleteAllCases(); + // wait for a second + await new Promise((r) => setTimeout(r, 1000)); + await header.waitUntilLoadingHasFinished(); + } + } while (rows.length > 0); + }, + + async validateCasesTableHasNthRows(nrRows: number) { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('cases-table', { timeout: 20 * 1000 }); + await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 }); + const rows = await find.allByCssSelector('[data-test-subj*="cases-table-row-"', 100); + expect(rows.length).equal(nrRows); + }, + + async openCaseSetStatusDropdown() { + const button = await find.byCssSelector( + '[data-test-subj="case-view-status-dropdown"] button' + ); + await button.click(); + }, + }; +} diff --git a/x-pack/test/functional/services/cases/helpers.ts b/x-pack/test/functional/services/cases/helpers.ts new file mode 100644 index 0000000000000..46def1da05790 --- /dev/null +++ b/x-pack/test/functional/services/cases/helpers.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; + +export function generateRandomCaseWithoutConnector() { + return { + title: 'random-' + uuid.v4(), + tags: ['test', uuid.v4()], + description: 'This is a description with id: ' + uuid.v4(), + connector: { + id: 'none', + name: 'none', + type: '.none', + fields: null, + }, + settings: { + syncAlerts: false, + }, + owner: 'cases', + }; +} diff --git a/x-pack/test/functional/services/cases/index.ts b/x-pack/test/functional/services/cases/index.ts new file mode 100644 index 0000000000000..afe244a21842e --- /dev/null +++ b/x-pack/test/functional/services/cases/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; +import { CasesAPIServiceProvider } from './api'; +import { CasesCommonServiceProvider } from './common'; + +export function CasesServiceProvider(context: FtrProviderContext) { + return { + api: CasesAPIServiceProvider(context), + common: CasesCommonServiceProvider(context), + }; +} diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 96a6a88f11269..62e8ab1ac464d 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -69,6 +69,7 @@ import { import { SearchSessionsService } from './search_sessions'; import { ObservabilityProvider } from './observability'; import { CompareImagesProvider } from './compare_images'; +import { CasesServiceProvider } from './cases'; // define the name and providers for services that should be // available to your tests. If you don't specify anything here @@ -128,4 +129,5 @@ export const services = { searchSessions: SearchSessionsService, observability: ObservabilityProvider, compareImages: CompareImagesProvider, + cases: CasesServiceProvider, }; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts new file mode 100644 index 0000000000000..252f639feef48 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import uuid from 'uuid'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + describe('Create case', function () { + const common = getPageObject('common'); + const find = getService('find'); + const cases = getService('cases'); + const testSubjects = getService('testSubjects'); + + before(async () => { + await common.navigateToApp('cases'); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('creates a case from the stack management page', async () => { + const caseTitle = 'test-' + uuid.v4(); + await cases.common.createCaseFromCreateCasePage({ + title: caseTitle, + description: 'test description', + tag: 'tagme', + }); + + // validate title + const title = await find.byCssSelector('[data-test-subj="header-page-title"]'); + expect(await title.getVisibleText()).equal(caseTitle); + + // validate description + const description = await testSubjects.find('user-action-markdown'); + expect(await description.getVisibleText()).equal('test description'); + + // validate tag exists + await testSubjects.existOrFail('tag-tagme'); + + // validate no connector added + const button = await find.byCssSelector('[data-test-subj*="case-callout"] button'); + expect(await button.getVisibleText()).equal('Add connector'); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/edit_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/edit_case_form.ts new file mode 100644 index 0000000000000..adc7c3401aa96 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/cases/edit_case_form.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import uuid from 'uuid'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + const common = getPageObject('common'); + const header = getPageObject('header'); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const cases = getService('cases'); + const retry = getService('retry'); + const comboBox = getService('comboBox'); + + describe('Edit case', () => { + // create the case to test on + before(async () => { + await common.navigateToApp('cases'); + await cases.api.createNthRandomCases(1); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + beforeEach(async () => { + await common.navigateToApp('cases'); + await cases.common.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + }); + + it('edits a case title from the case view page', async () => { + const newTitle = `test-${uuid.v4()}`; + + await testSubjects.click('editable-title-edit-icon'); + await testSubjects.setValue('editable-title-input-field', newTitle); + await testSubjects.click('editable-title-submit-btn'); + + // wait for backend response + await retry.tryForTime(5000, async () => { + const title = await find.byCssSelector('[data-test-subj="header-page-title"]'); + expect(await title.getVisibleText()).equal(newTitle); + }); + + // validate user action + await find.byCssSelector('[data-test-subj*="title-update-action"]'); + }); + + it('adds a comment to a case', async () => { + const commentArea = await find.byCssSelector( + '[data-test-subj="add-comment"] textarea.euiMarkdownEditorTextArea' + ); + await commentArea.focus(); + await commentArea.type('Test comment from automation'); + await testSubjects.click('submit-comment'); + + // validate user action + const newComment = await find.byCssSelector( + '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + ); + expect(await newComment.getVisibleText()).equal('Test comment from automation'); + }); + + it('adds a tag to a case', async () => { + const tag = uuid.v4(); + await testSubjects.click('tag-list-edit-button'); + await comboBox.setCustom('comboBoxInput', tag); + await testSubjects.click('edit-tags-submit'); + + // validate tag was added + await testSubjects.existOrFail('tag-' + tag); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-add-action"]'); + }); + + it('deletes a tag from a case', async () => { + await testSubjects.click('tag-list-edit-button'); + // find the tag button and click the close button + const button = await find.byCssSelector('[data-test-subj="comboBoxInput"] button'); + await button.click(); + await testSubjects.click('edit-tags-submit'); + + // validate user action + await find.byCssSelector('[data-test-subj*="tags-delete-action"]'); + }); + + it('changes a case status to in-progress via dropdown menu', async () => { + await cases.common.markCaseInProgressViaDropdown(); + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="status-badge-in-progress"]' + ); + // validates dropdown tag + await testSubjects.existOrFail('case-view-status-dropdown > status-badge-in-progress'); + }); + + it('changes a case status to closed via dropdown-menu', async () => { + await cases.common.markCaseClosedViaDropdown(); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="status-badge-closed"]' + ); + // validates dropdown tag + await testSubjects.existOrFail('case-view-status-dropdown > status-badge-closed'); + }); + + it("reopens a case from the 'reopen case' button", async () => { + await cases.common.markCaseClosedViaDropdown(); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('case-view-status-action-button'); + await header.waitUntilLoadingHasFinished(); + + await testSubjects.existOrFail('header-page-supplements > status-badge-open', { + timeout: 5000, + }); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="status-badge-open"]' + ); + // validates dropdown tag + await testSubjects.existOrFail('case-view-status-dropdown > status-badge-open'); + }); + + it("marks in progress a case from the 'mark in progress' button", async () => { + await cases.common.markCaseOpenViaDropdown(); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('case-view-status-action-button'); + await header.waitUntilLoadingHasFinished(); + + await testSubjects.existOrFail('header-page-supplements > status-badge-in-progress', { + timeout: 5000, + }); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="status-badge-in-progress"]' + ); + // validates dropdown tag + await testSubjects.existOrFail('case-view-status-dropdown > status-badge-in-progress'); + }); + + it("closes a case from the 'close case' button", async () => { + await cases.common.markCaseInProgressViaDropdown(); + await header.waitUntilLoadingHasFinished(); + await testSubjects.click('case-view-status-action-button'); + await header.waitUntilLoadingHasFinished(); + + await testSubjects.existOrFail('header-page-supplements > status-badge-closed', { + timeout: 5000, + }); + + // validate user action + await find.byCssSelector( + '[data-test-subj*="status-update-action"] [data-test-subj="status-badge-closed"]' + ); + // validates dropdown tag + await testSubjects.existOrFail('case-view-status-dropdown > status-badge-closed'); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/index.ts b/x-pack/test/functional_with_es_ssl/apps/cases/index.ts new file mode 100644 index 0000000000000..583fce960fbbd --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/cases/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ loadTestFile }: FtrProviderContext) => { + describe('Cases', function () { + this.tags('ciGroup27'); + loadTestFile(require.resolve('./create_case_form')); + loadTestFile(require.resolve('./edit_case_form')); + loadTestFile(require.resolve('./list_view')); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts new file mode 100644 index 0000000000000..66d1e83700ded --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -0,0 +1,122 @@ +/* + * 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 uuid from 'uuid'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObject, getService }: FtrProviderContext) => { + const common = getPageObject('common'); + const header = getPageObject('header'); + const testSubjects = getService('testSubjects'); + const cases = getService('cases'); + const retry = getService('retry'); + const browser = getService('browser'); + + describe('cases list', () => { + before(async () => { + await common.navigateToApp('cases'); + await cases.api.deleteAllCases(); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + beforeEach(async () => { + await common.navigateToApp('cases'); + }); + + it('displays an empty list with an add button correctly', async () => { + await testSubjects.existOrFail('cases-table-add-case'); + }); + + it('lists cases correctly', async () => { + const NUMBER_CASES = 2; + await cases.api.createNthRandomCases(NUMBER_CASES); + await common.navigateToApp('cases'); + await cases.common.validateCasesTableHasNthRows(NUMBER_CASES); + }); + + it('deletes a case correctly from the list', async () => { + await cases.api.createNthRandomCases(1); + await common.navigateToApp('cases'); + await testSubjects.click('action-delete'); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.existOrFail('euiToastHeader'); + }); + + it('filters cases from the list with partial match', async () => { + await cases.api.deleteAllCases(); + await cases.api.createNthRandomCases(5); + const id = uuid.v4(); + const caseTitle = 'matchme-' + id; + await cases.api.createCaseWithData({ title: caseTitle }); + await common.navigateToApp('cases'); + await testSubjects.missingOrFail('cases-table-loading', { timeout: 5000 }); + + // search + const input = await testSubjects.find('search-cases'); + await input.type(caseTitle); + await input.pressKeys(browser.keys.ENTER); + + await retry.tryForTime(20000, async () => { + await cases.common.validateCasesTableHasNthRows(1); + }); + }); + + it('paginates cases correctly', async () => { + await cases.api.deleteAllCases(); + await cases.api.createNthRandomCases(8); + await common.navigateToApp('cases'); + await testSubjects.click('tablePaginationPopoverButton'); + await testSubjects.click('tablePagination-5-rows'); + await testSubjects.isEnabled('pagination-button-1'); + await testSubjects.click('pagination-button-1'); + await testSubjects.isEnabled('pagination-button-0'); + }); + + it('bulk delete cases from the list', async () => { + // deletes them from the API + await cases.api.deleteAllCases(); + await cases.api.createNthRandomCases(8); + await common.navigateToApp('cases'); + // deletes them from the UI + await cases.common.selectAndDeleteAllCases(); + await cases.common.validateCasesTableHasNthRows(0); + }); + + describe('changes status from the list', () => { + before(async () => { + await common.navigateToApp('cases'); + await cases.api.deleteAllCases(); + await cases.api.createNthRandomCases(1); + await common.navigateToApp('cases'); + }); + + it('to in progress', async () => { + await cases.common.openCaseSetStatusDropdown(); + await testSubjects.click('case-view-status-dropdown-in-progress'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('status-badge-in-progress'); + }); + + it('to closed', async () => { + await cases.common.openCaseSetStatusDropdown(); + await testSubjects.click('case-view-status-dropdown-closed'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('status-badge-closed'); + }); + + it('to open', async () => { + await cases.common.openCaseSetStatusDropdown(); + await testSubjects.click('case-view-status-dropdown-open'); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('status-badge-open'); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index e537603a0113b..e906e239a8892 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -48,6 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { resolve(__dirname, './apps/triggers_actions_ui'), resolve(__dirname, './apps/uptime'), resolve(__dirname, './apps/ml'), + resolve(__dirname, './apps/cases'), ], apps: { ...xpackFunctionalConfig.get('apps'), From 9449f1dcddaecca41970ff3b0d97ec52f9fb67fa Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 22 Mar 2022 20:11:26 +0100 Subject: [PATCH 049/132] [Lens] Xy gap settings (#127749) * add end value and fitting style settings * debug statement * fix tests * fix test and types * fix translation key * adjust copy Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/expressions/xy_chart/end_value.ts | 40 +++++ .../lens/common/expressions/xy_chart/index.ts | 1 + .../common/expressions/xy_chart/xy_args.ts | 3 + .../common/expressions/xy_chart/xy_chart.ts | 11 ++ .../__snapshots__/expression.test.tsx.snap | 6 + .../__snapshots__/to_expression.test.ts.snap | 6 + .../public/xy_visualization/expression.tsx | 36 ++++- .../xy_visualization/fitting_functions.ts | 18 ++- .../xy_visualization/to_expression.test.ts | 2 + .../public/xy_visualization/to_expression.ts | 2 + .../lens/public/xy_visualization/types.ts | 3 + .../visual_options_popover/index.tsx | 8 + .../missing_value_option.test.tsx | 13 +- .../missing_values_option.tsx | 151 ++++++++++++------ 14 files changed, 244 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/lens/common/expressions/xy_chart/end_value.ts diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/end_value.ts b/x-pack/plugins/lens/common/expressions/xy_chart/end_value.ts new file mode 100644 index 0000000000000..1ef664cb2e2ba --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/end_value.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export type EndValue = typeof endValueDefinitions[number]['id']; + +export const endValueDefinitions = [ + { + id: 'None', + title: i18n.translate('xpack.lens.endValue.none', { + defaultMessage: 'Hide', + }), + description: i18n.translate('xpack.lens.endValueDescription.none', { + defaultMessage: 'Do not extend series to the edge of the chart', + }), + }, + { + id: 'Zero', + title: i18n.translate('xpack.lens.endValue.zero', { + defaultMessage: 'Zero', + }), + description: i18n.translate('xpack.lens.endValueDescription.zero', { + defaultMessage: 'Extend series as zero to the edge of the chart', + }), + }, + { + id: 'Nearest', + title: i18n.translate('xpack.lens.endValue.nearest', { + defaultMessage: 'Nearest', + }), + description: i18n.translate('xpack.lens.endValueDescription.nearest', { + defaultMessage: 'Extend series with the first/last value to the edge of the chart', + }), + }, +] as const; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/index.ts index a6f6c715c0ed1..2f66c2c61a9f1 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/index.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/index.ts @@ -7,6 +7,7 @@ export * from './axis_config'; export * from './fitting_function'; +export * from './end_value'; export * from './grid_lines_config'; export * from './layer_config'; export * from './legend_config'; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts index 1334c1149f47b..940896a2079e6 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts @@ -7,6 +7,7 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from './axis_config'; import type { FittingFunction } from './fitting_function'; +import type { EndValue } from './end_value'; import type { GridlinesConfigResult } from './grid_lines_config'; import type { DataLayerArgs } from './layer_config'; import type { LegendConfigResult } from './legend_config'; @@ -29,6 +30,8 @@ export interface XYArgs { valueLabels: ValueLabelConfig; layers: DataLayerArgs[]; fittingFunction?: FittingFunction; + endValue?: EndValue; + emphasizeFitting?: boolean; axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult; tickLabelsVisibilitySettings?: TickLabelsConfigResult; gridlinesVisibilitySettings?: GridlinesConfigResult; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index 481494d52966f..d0f278d382be9 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -10,6 +10,7 @@ import type { ExpressionValueSearchContext } from '../../../../../../src/plugins import type { LensMultiTable } from '../../types'; import type { XYArgs } from './xy_args'; import { fittingFunctionDefinitions } from './fitting_function'; +import { endValueDefinitions } from './end_value'; import { logDataTable } from '../expressions_utils'; export interface XYChartProps { @@ -87,6 +88,16 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Define how missing values are treated', }), }, + endValue: { + types: ['string'], + options: [...endValueDefinitions.map(({ id }) => id)], + help: '', + }, + emphasizeFitting: { + types: ['boolean'], + default: false, + help: '', + }, valueLabels: { types: ['string'], options: ['hide', 'inside'], diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index b34d5e8639382..504a553c5a631 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -138,6 +138,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` enableHistogramMode={false} fit={ Object { + "endValue": undefined, "type": "none", } } @@ -198,6 +199,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` enableHistogramMode={false} fit={ Object { + "endValue": undefined, "type": "none", } } @@ -862,6 +864,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` enableHistogramMode={false} fit={ Object { + "endValue": undefined, "type": "none", } } @@ -922,6 +925,7 @@ exports[`xy_expression XYChart component it renders line 1`] = ` enableHistogramMode={false} fit={ Object { + "endValue": undefined, "type": "none", } } @@ -1094,6 +1098,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` enableHistogramMode={false} fit={ Object { + "endValue": undefined, "type": "none", } } @@ -1158,6 +1163,7 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` enableHistogramMode={false} fit={ Object { + "endValue": undefined, "type": "none", } } diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 5992d0bdb7264..a1b0431f67138 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -33,6 +33,12 @@ Object { "description": Array [ "", ], + "emphasizeFitting": Array [ + true, + ], + "endValue": Array [ + "Nearest", + ], "fillOpacity": Array [ 0.3, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 3e300778b85b9..72a3f5f4f6976 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -36,6 +36,7 @@ import { AreaSeriesProps, BarSeriesProps, LineSeriesProps, + ColorVariant, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n-react'; import type { @@ -240,6 +241,8 @@ export function XYChart({ legend, layers, fittingFunction, + endValue, + emphasizeFitting, gridlinesVisibilitySettings, valueLabels, hideEndzones, @@ -857,15 +860,38 @@ export function XYChart({ areaSeriesStyle: { point: { visible: !xAccessor, - radius: 5, + radius: xAccessor && !emphasizeFitting ? 5 : 0, }, ...(args.fillOpacity && { area: { opacity: args.fillOpacity } }), + ...(emphasizeFitting && { + fit: { + area: { + opacity: args.fillOpacity || 0.5, + }, + line: { + visible: true, + stroke: ColorVariant.Series, + opacity: 1, + dash: [], + }, + }, + }), }, lineSeriesStyle: { point: { visible: !xAccessor, - radius: 5, + radius: xAccessor && !emphasizeFitting ? 5 : 0, }, + ...(emphasizeFitting && { + fit: { + line: { + visible: true, + stroke: ColorVariant.Series, + opacity: 1, + dash: [], + }, + }, + }), }, name(d) { // For multiple y series, the name of the operation is used on each, either: @@ -913,7 +939,7 @@ export function XYChart({ ); @@ -945,7 +971,7 @@ export function XYChart({ ); @@ -954,7 +980,7 @@ export function XYChart({ ); diff --git a/x-pack/plugins/lens/public/xy_visualization/fitting_functions.ts b/x-pack/plugins/lens/public/xy_visualization/fitting_functions.ts index 0b0878dfe9684..63a3b308d8ae8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/fitting_functions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/fitting_functions.ts @@ -6,15 +6,25 @@ */ import { Fit } from '@elastic/charts'; -import { FittingFunction } from '../../common/expressions'; +import { EndValue, FittingFunction } from '../../common/expressions'; -export function getFitEnum(fittingFunction?: FittingFunction) { +export function getFitEnum(fittingFunction?: FittingFunction | EndValue) { if (fittingFunction) { return Fit[fittingFunction]; } return Fit.None; } -export function getFitOptions(fittingFunction?: FittingFunction) { - return { type: getFitEnum(fittingFunction) }; +export function getEndValue(endValue?: EndValue) { + if (endValue === 'Nearest') { + return Fit[endValue]; + } + if (endValue === 'Zero') { + return 0; + } + return undefined; +} + +export function getFitOptions(fittingFunction?: FittingFunction, endValue?: EndValue) { + return { type: getFitEnum(fittingFunction), endValue: getEndValue(endValue) }; } diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index ac3fdcf30a4ad..fa992d8829b20 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -54,6 +54,8 @@ describe('#toExpression', () => { valueLabels: 'hide', preferredSeriesType: 'bar', fittingFunction: 'Carry', + endValue: 'Nearest', + emphasizeFitting: true, tickLabelsVisibilitySettings: { x: false, yLeft: true, yRight: true }, labelsOrientation: { x: 0, diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 37457c61b2603..a9c166a9c13eb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -194,6 +194,8 @@ export const buildExpression = ( }, ], fittingFunction: [state.fittingFunction || 'None'], + endValue: [state.endValue || 'None'], + emphasizeFitting: [state.emphasizeFitting || false], curveType: [state.curveType || 'LINEAR'], fillOpacity: [state.fillOpacity || 0.3], yLeftExtent: [ diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index b59d69bd8cbe6..2b9d5687979be 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -27,6 +27,7 @@ import type { AxesSettingsConfig, FittingFunction, LabelsOrientationConfig, + EndValue, } from '../../common/expressions'; import type { ValueLabelConfig } from '../../common/types'; @@ -36,6 +37,8 @@ export interface XYState { legend: LegendConfig; valueLabels?: ValueLabelConfig; fittingFunction?: FittingFunction; + emphasizeFitting?: boolean; + endValue?: EndValue; yLeftExtent?: AxisExtentConfig; yRightExtent?: AxisExtentConfig; layers: XYLayerConfig[]; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx index 0436a93be94ee..0bdd513c1f881 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/index.tsx @@ -115,9 +115,17 @@ export const VisualOptionsPopover: React.FC = ({ { setState({ ...state, fittingFunction: newVal }); }} + onEmphasizeFittingChange={(newVal) => { + setState({ ...state, emphasizeFitting: newVal }); + }} + onEndValueChange={(newVal) => { + setState({ ...state, endValue: newVal }); + }} /> { it('should show currently selected fitting function', () => { const component = shallow( - + ); - expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); + expect(component.find(EuiSuperSelect).first().prop('valueOfSelected')).toEqual('Carry'); }); it('should show the fitting option when enabled', () => { @@ -25,6 +30,8 @@ describe('Missing values option', () => { onFittingFnChange={jest.fn()} fittingFunction={'Carry'} isFittingEnabled={true} + onEmphasizeFittingChange={jest.fn()} + onEndValueChange={jest.fn()} /> ); @@ -37,6 +44,8 @@ describe('Missing values option', () => { onFittingFnChange={jest.fn()} fittingFunction={'Carry'} isFittingEnabled={false} + onEmphasizeFittingChange={jest.fn()} + onEndValueChange={jest.fn()} /> ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx index a858d1c879efe..9bd59cb5c4a08 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/visual_options_popover/missing_values_option.tsx @@ -7,69 +7,130 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; -import { fittingFunctionDefinitions } from '../../../../common/expressions'; -import type { FittingFunction } from '../../../../common/expressions'; +import { EuiFormRow, EuiIconTip, EuiSuperSelect, EuiSwitch, EuiText } from '@elastic/eui'; +import { fittingFunctionDefinitions, endValueDefinitions } from '../../../../common/expressions'; +import type { FittingFunction, EndValue } from '../../../../common/expressions'; export interface MissingValuesOptionProps { fittingFunction?: FittingFunction; onFittingFnChange: (newMode: FittingFunction) => void; + emphasizeFitting?: boolean; + onEmphasizeFittingChange: (emphasize: boolean) => void; + endValue?: EndValue; + onEndValueChange: (endValue: EndValue) => void; isFittingEnabled?: boolean; } export const MissingValuesOptions: React.FC = ({ onFittingFnChange, fittingFunction, + emphasizeFitting, + onEmphasizeFittingChange, + onEndValueChange, + endValue, isFittingEnabled = true, }) => { return ( <> {isFittingEnabled && ( - + + {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { + defaultMessage: 'Missing values', + })}{' '} + + + } + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={fittingFunction || 'None'} + onChange={(value) => onFittingFnChange(value)} + itemLayoutAlign="top" + hasDividers + /> +
+ {fittingFunction && fittingFunction !== 'None' && ( <> - {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { - defaultMessage: 'Missing values', - })}{' '} - + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={endValue || 'None'} + onChange={(value) => onEndValueChange(value)} + itemLayoutAlign="top" + hasDividers + /> +
+ + { + onEmphasizeFittingChange(!emphasizeFitting); + }} + compressed + /> + - } - > - { - return { - value: id, - dropdownDisplay: ( - <> - {title} - -

{description}

-
- - ), - inputDisplay: title, - }; - })} - valueOfSelected={fittingFunction || 'None'} - onChange={(value) => onFittingFnChange(value)} - itemLayoutAlign="top" - hasDividers - /> - + )} + )} ); From 22e481af6b52da19a8da7c121b3e5910a4d9c4e6 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Tue, 22 Mar 2022 12:25:36 -0700 Subject: [PATCH 050/132] Fix search_profiler ally test (#128084) * search_profiler * review comments * addressed review comments --- .../accessibility/apps/search_profiler.ts | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/x-pack/test/accessibility/apps/search_profiler.ts b/x-pack/test/accessibility/apps/search_profiler.ts index 6559d58be6298..47909662fb132 100644 --- a/x-pack/test/accessibility/apps/search_profiler.ts +++ b/x-pack/test/accessibility/apps/search_profiler.ts @@ -13,16 +13,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const aceEditor = getService('aceEditor'); const a11y = getService('a11y'); - const flyout = getService('flyout'); + const esArchiver = getService('esArchiver'); - // FLAKY: https://github.com/elastic/kibana/issues/91939 - describe.skip('Accessibility Search Profiler Editor', () => { + describe('Accessibility Search Profiler Editor', () => { before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await PageObjects.common.navigateToApp('searchProfiler'); await a11y.testAppSnapshot(); expect(await testSubjects.exists('searchProfilerEditor')).to.be(true); }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + }); + it('input the JSON in the aceeditor', async () => { const input = { query: { @@ -65,14 +69,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('click on the open-close shard details link', async () => { - const openShardDetailslink = await testSubjects.findAll('openCloseShardDetails'); - await openShardDetailslink[0].click(); + it('close the flyout', async () => { + await testSubjects.click('euiFlyoutCloseButton'); await a11y.testAppSnapshot(); }); - it('close the fly out', async () => { - await flyout.ensureAllClosed(); + it('click on the open-close shard details link', async () => { + const openShardDetailslink = await testSubjects.findAll('openCloseShardDetails'); + await openShardDetailslink[0].click(); await a11y.testAppSnapshot(); }); @@ -80,16 +84,5 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('aggregationProfileTab'); await a11y.testAppSnapshot(); }); - - it('click on the view details link', async () => { - const viewShardDetailslink = await testSubjects.findAll('viewShardDetails'); - await viewShardDetailslink[0].click(); - await a11y.testAppSnapshot(); - }); - - it('close the fly out', async () => { - await flyout.ensureAllClosed(); - await a11y.testAppSnapshot(); - }); }); } From 12e789401894f0325306f0adc964aba999b9d2b0 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Tue, 22 Mar 2022 15:26:29 -0400 Subject: [PATCH 051/132] [Workplace Search] Update UX for custom api source creation flow (#127155) --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + packages/kbn-doc-links/src/types.ts | 1 + .../shared/doc_links/doc_links.ts | 3 + .../applications/workplace_search/types.ts | 1 - .../add_custom_source.test.tsx | 10 +- .../add_custom_source.tsx | 10 +- .../add_custom_source_logic.test.ts | 21 +- .../add_custom_source_logic.ts | 8 +- .../configure_custom.test.tsx | 8 +- .../add_custom_source/configure_custom.tsx | 202 +++++++++++++++ .../add_source/add_custom_source/index.ts | 8 + .../add_custom_source/save_custom.test.tsx | 87 +++++++ .../add_custom_source/save_custom.tsx | 201 +++++++++++++++ .../add_source/config_completed.tsx | 2 +- .../add_source/configure_custom.tsx | 123 --------- .../components/add_source/constants.ts | 56 ----- .../add_source/save_custom.test.tsx | 55 ---- .../components/add_source/save_custom.tsx | 236 ------------------ .../components/source_identifier.tsx | 34 +-- .../views/content_sources/source_data.tsx | 13 +- .../translations/translations/fr-FR.json | 8 - .../translations/translations/ja-JP.json | 12 - .../translations/translations/zh-CN.json | 12 - 23 files changed, 542 insertions(+), 570 deletions(-) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_custom_source}/add_custom_source.test.tsx (85%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_custom_source}/add_custom_source.tsx (85%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_custom_source}/add_custom_source_logic.test.ts (89%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_custom_source}/add_custom_source_logic.ts (94%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_custom_source}/configure_custom.test.tsx (84%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 49708aa5fafc4..d0ff7dc704f76 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -139,6 +139,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { security: `${WORKPLACE_SEARCH_DOCS}workplace-search-security.html`, serviceNow: `${WORKPLACE_SEARCH_DOCS}workplace-search-servicenow-connector.html`, sharePoint: `${WORKPLACE_SEARCH_DOCS}workplace-search-sharepoint-online-connector.html`, + sharePointServer: `${WORKPLACE_SEARCH_DOCS}sharepoint-server.html`, slack: `${WORKPLACE_SEARCH_DOCS}workplace-search-slack-connector.html`, synch: `${WORKPLACE_SEARCH_DOCS}workplace-search-customizing-indexing-rules.html`, zendesk: `${WORKPLACE_SEARCH_DOCS}workplace-search-zendesk-connector.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index ef3b490bbb094..fa1b4d6af41c8 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -128,6 +128,7 @@ export interface DocLinks { readonly security: string; readonly serviceNow: string; readonly sharePoint: string; + readonly sharePointServer: string; readonly slack: string; readonly synch: string; readonly zendesk: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index f512a680efdfe..de29cf931c770 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -59,6 +59,7 @@ class DocLinks { public workplaceSearchSecurity: string; public workplaceSearchServiceNow: string; public workplaceSearchSharePoint: string; + public workplaceSearchSharePointServer: string; public workplaceSearchSlack: string; public workplaceSearchSynch: string; public workplaceSearchZendesk: string; @@ -115,6 +116,7 @@ class DocLinks { this.workplaceSearchSecurity = ''; this.workplaceSearchServiceNow = ''; this.workplaceSearchSharePoint = ''; + this.workplaceSearchSharePointServer = ''; this.workplaceSearchSlack = ''; this.workplaceSearchSynch = ''; this.workplaceSearchZendesk = ''; @@ -174,6 +176,7 @@ class DocLinks { this.workplaceSearchSecurity = docLinks.links.workplaceSearch.security; this.workplaceSearchServiceNow = docLinks.links.workplaceSearch.serviceNow; this.workplaceSearchSharePoint = docLinks.links.workplaceSearch.sharePoint; + this.workplaceSearchSharePointServer = docLinks.links.workplaceSearch.sharePointServer; this.workplaceSearchSlack = docLinks.links.workplaceSearch.slack; this.workplaceSearchSynch = docLinks.links.workplaceSearch.synch; this.workplaceSearchZendesk = docLinks.links.workplaceSearch.zendesk; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 6e0622c98be64..971b00b6529ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -66,7 +66,6 @@ export interface Configuration { needsConfiguration?: boolean; hasOauthRedirect: boolean; baseUrlTitle?: string; - helpText?: string; documentationUrl: string; applicationPortalUrl?: string; applicationLinkTitle?: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx similarity index 85% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx index b13cc6583cf2f..b606f9d7f56fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockValues } from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -16,8 +16,8 @@ import { shallow } from 'enzyme'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, -} from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; +} from '../../../../../components/layout'; +import { staticSourceData } from '../../../source_data'; import { AddCustomSource } from './add_custom_source'; import { AddCustomSourceSteps } from './add_custom_source_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx similarity index 85% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx index 6f7dc2bcdb342..c2f6afba032c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx @@ -9,21 +9,19 @@ import React from 'react'; import { useValues } from 'kea'; -import { AppLogic } from '../../../../app_logic'; +import { AppLogic } from '../../../../../app_logic'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, -} from '../../../../components/layout'; -import { NAV } from '../../../../constants'; +} from '../../../../../components/layout'; +import { NAV } from '../../../../../constants'; -import { SourceDataItem } from '../../../../types'; +import { SourceDataItem } from '../../../../../types'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; -import './add_source.scss'; - interface Props { sourceData: SourceDataItem; initialValue?: string; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts similarity index 89% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts index d019c66526e6c..d2187bd0b21a1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts @@ -9,22 +9,21 @@ import { LogicMounter, mockFlashMessageHelpers, mockHttpValues, -} from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +} from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; -import { i18n } from '@kbn/i18n'; import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../shared/doc_links'; -import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; +import { docLinks } from '../../../../../../shared/doc_links'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; -jest.mock('../../../../app_logic', () => ({ +jest.mock('../../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { AppLogic } from '../../../../app_logic'; +import { AppLogic } from '../../../../../app_logic'; -import { SOURCE_NAMES } from '../../../../constants'; -import { CustomSource, SourceDataItem } from '../../../../types'; +import { SOURCE_NAMES } from '../../../../../constants'; +import { CustomSource, SourceDataItem } from '../../../../../types'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; @@ -36,10 +35,6 @@ const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { - defaultMessage: - 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', - }), documentationUrl: docLinks.workplaceSearchCustomSources, applicationPortalUrl: '', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts index c35436ccbf99a..f85e0761f51b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts @@ -7,10 +7,10 @@ import { kea, MakeLogicType } from 'kea'; -import { flashAPIErrors, clearFlashMessages } from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; -import { AppLogic } from '../../../../app_logic'; -import { CustomSource, SourceDataItem } from '../../../../types'; +import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../../shared/http'; +import { AppLogic } from '../../../../../app_logic'; +import { CustomSource, SourceDataItem } from '../../../../../types'; export interface AddCustomSourceProps { sourceData: SourceDataItem; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx similarity index 84% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx index 645226c546f10..3ed60614d294a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; import React from 'react'; @@ -14,7 +14,7 @@ import { shallow } from 'enzyme'; import { EuiForm, EuiFieldText } from '@elastic/eui'; -import { staticSourceData } from '../../source_data'; +import { staticSourceData } from '../../../source_data'; import { ConfigureCustom } from './configure_custom'; @@ -50,7 +50,7 @@ describe('ConfigureCustom', () => { const wrapper = shallow(); const preventDefault = jest.fn(); - wrapper.find('form').simulate('submit', { preventDefault }); + wrapper.find('EuiForm').simulate('submit', { preventDefault }); expect(preventDefault).toHaveBeenCalled(); expect(createContentSource).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx new file mode 100644 index 0000000000000..024dd698cc0a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ChangeEvent, FormEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { docLinks } from '../../../../../../shared/doc_links'; + +import connectionIllustration from '../../../../../assets/connection_illustration.svg'; +import { SOURCE_NAME_LABEL } from '../../../constants'; + +import { AddSourceHeader } from '../add_source_header'; +import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT, CONFIG_INTRO_ALT_TEXT } from '../constants'; + +import { AddCustomSourceLogic } from './add_custom_source_logic'; + +export const ConfigureCustom: React.FC = () => { + const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); + + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + createContentSource(); + }; + + const handleNameChange = (e: ChangeEvent) => + setCustomSourceNameValue(e.target.value); + + const { + serviceType, + configuration: { documentationUrl, githubRepository }, + name, + categories = [], + } = sourceData; + + return ( + <> + + + + +
+ {CONFIG_INTRO_ALT_TEXT} +
+
+ + + +

+ +

+
+ + + + {serviceType === 'custom' ? ( + <> +

+ +

+

+ + {CONFIG_CUSTOM_LINK_TEXT} + + ), + }} + /> +

+ + ) : ( + <> +

+ +

+

+ + + + ), + }} + /> +

+

+ + + +

+

+ + + +

+

+ + + +

+ + )} +
+ + + + + + + + {serviceType === 'custom' ? ( + CONFIG_CUSTOM_BUTTON + ) : ( + + )} + + +
+
+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/index.ts new file mode 100644 index 0000000000000..3565ea46632f7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AddCustomSource } from './add_custom_source'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx new file mode 100644 index 0000000000000..73add51b87955 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { setMockValues } from '../../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButtonTo } from '../../../../../../shared/react_router_helpers'; + +import { staticCustomSourceData } from '../../../source_data'; + +import { SourceIdentifier } from '../../source_identifier'; + +import { SaveCustom } from './save_custom'; + +const mockValues = { + newCustomSource: { + id: 'id', + accessToken: 'token', + name: 'name', + }, + sourceData: staticCustomSourceData, +}; + +describe('SaveCustom', () => { + describe('default behavior', () => { + let wrapper: ShallowWrapper; + + beforeAll(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + + wrapper = shallow(); + }); + + it('contains a button back to the sources list', () => { + expect(wrapper.find(EuiButtonTo)).toHaveLength(1); + }); + + it('contains a source identifier', () => { + expect(wrapper.find(SourceIdentifier)).toHaveLength(1); + }); + + it('includes a link to generic documentation', () => { + expect(wrapper.find('[data-test-subj="GenericDocumentationLink"]')).toHaveLength(1); + }); + }); + + describe('for pre-configured custom sources', () => { + let wrapper: ShallowWrapper; + + beforeAll(() => { + jest.clearAllMocks(); + setMockValues({ + ...mockValues, + sourceData: { + ...staticCustomSourceData, + serviceType: 'sharepoint-server', + configuration: { + ...staticCustomSourceData.configuration, + githubRepository: 'elastic/sharepoint-server-connector', + }, + }, + }); + + wrapper = shallow(); + }); + + it('includes a to the github repository', () => { + expect(wrapper.find('[data-test-subj="GithubRepositoryLink"]')).toHaveLength(1); + }); + + it('includes a link to service-type specific documentation', () => { + expect(wrapper.find('[data-test-subj="PreconfiguredDocumentationLink"]')).toHaveLength(1); + }); + + it('includes a link to provide feedback', () => { + expect(wrapper.find('[data-test-subj="FeedbackCallout"]')).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx new file mode 100644 index 0000000000000..8d0612f36fc0d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiText, + EuiTextAlign, + EuiTitle, + EuiLink, + EuiPanel, + EuiHorizontalRule, + EuiCallOut, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { EuiButtonTo, EuiLinkTo } from '../../../../../../shared/react_router_helpers'; +import { AppLogic } from '../../../../../app_logic'; +import { API_KEY_LABEL } from '../../../../../constants'; +import { SOURCES_PATH, getSourcesPath, API_KEYS_PATH } from '../../../../../routes'; + +import { SourceIdentifier } from '../../source_identifier'; + +import { AddSourceHeader } from '../add_source_header'; +import { SAVE_CUSTOM_BODY1 as READY_TO_ACCEPT_REQUESTS_LABEL } from '../constants'; + +import { AddCustomSourceLogic } from './add_custom_source_logic'; + +export const SaveCustom: React.FC = () => { + const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); + const { + serviceType, + configuration: { githubRepository, documentationUrl }, + name, + categories = [], + } = sourceData; + + return ( + <> + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading', + { + defaultMessage: '{name} Created', + values: { name: newCustomSource.name }, + } + )} +

+
+
+
+ + + {READY_TO_ACCEPT_REQUESTS_LABEL} + + + + + + + +
+
+
+ + + + {serviceType !== 'custom' && githubRepository ? ( + <> + + + + ), + }} + /> + + + + + ), + }} + /> + + ) : ( + + + + ), + }} + /> + )} + + + {API_KEY_LABEL} + + ), + }} + /> + + + + + +
+ {serviceType !== 'custom' && ( + <> + + + + + + + } + iconType="email" + /> + + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx index edd39409893a6..8af79587fefbb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/config_completed.tsx @@ -62,7 +62,7 @@ export const ConfigCompleted: React.FC = ({ - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx deleted file mode 100644 index bf5a7fea21333..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx +++ /dev/null @@ -1,123 +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 React, { ChangeEvent, FormEvent } from 'react'; - -import { useActions, useValues } from 'kea'; - -import { - EuiButton, - EuiFieldText, - EuiForm, - EuiFormRow, - EuiLink, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { docLinks } from '../../../../../shared/doc_links'; - -import { SOURCE_NAME_LABEL } from '../../constants'; - -import { AddCustomSourceLogic } from './add_custom_source_logic'; -import { AddSourceHeader } from './add_source_header'; -import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT } from './constants'; - -export const ConfigureCustom: React.FC = () => { - const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); - const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); - - const handleFormSubmit = (e: FormEvent) => { - e.preventDefault(); - createContentSource(); - }; - - const handleNameChange = (e: ChangeEvent) => - setCustomSourceNameValue(e.target.value); - - const { - serviceType, - configuration: { documentationUrl, helpText }, - name, - categories = [], - } = sourceData; - - return ( - <> - - -
- - -

{helpText}

-

- {serviceType === 'custom' ? ( - - {CONFIG_CUSTOM_LINK_TEXT} - - ), - }} - /> - ) : ( - - {CONFIG_CUSTOM_LINK_TEXT} - - ), - name, - }} - /> - )} -

-
- - - - - - - - {serviceType === 'custom' ? ( - CONFIG_CUSTOM_BUTTON - ) : ( - - )} - - -
-
- - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index 5963f4cb25635..4499fc0483ce5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -272,62 +272,6 @@ export const SAVE_CUSTOM_BODY1 = i18n.translate( } ); -export const SAVE_CUSTOM_BODY2 = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2', - { - defaultMessage: 'Be sure to copy your Source Identifier below.', - } -); - -export const SAVE_CUSTOM_RETURN_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.return.button', - { - defaultMessage: 'Return to Sources', - } -); - -export const SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title', - { - defaultMessage: 'Visual Walkthrough', - } -); - -export const SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.link', - { - defaultMessage: 'Check out the documentation', - } -); - -export const SAVE_CUSTOM_STYLING_RESULTS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.title', - { - defaultMessage: 'Styling Results', - } -); - -export const SAVE_CUSTOM_STYLING_RESULTS_LINK = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.link', - { - defaultMessage: 'Display Settings', - } -); - -export const SAVE_CUSTOM_DOC_PERMISSIONS_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.title', - { - defaultMessage: 'Set document-level permissions', - } -); - -export const SAVE_CUSTOM_DOC_PERMISSIONS_LINK = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.link', - { - defaultMessage: 'Document-level permissions', - } -); - export const INCLUDED_FEATURES_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.includedFeaturesTitle', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx deleted file mode 100644 index c05110bd4e6ac..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx +++ /dev/null @@ -1,55 +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 { setMockValues } from '../../../../../__mocks__/kea_logic'; - -import React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiPanel, EuiTitle } from '@elastic/eui'; - -import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; - -import { LicenseBadge } from '../../../../components/shared/license_badge'; -import { staticCustomSourceData } from '../../source_data'; - -import { SaveCustom } from './save_custom'; - -describe('SaveCustom', () => { - const mockValues = { - newCustomSource: { - id: 'id', - accessToken: 'token', - name: 'name', - }, - sourceData: staticCustomSourceData, - isOrganization: true, - hasPlatinumLicense: true, - }; - - beforeEach(() => { - setMockValues(mockValues); - }); - - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(EuiPanel)).toHaveLength(1); - expect(wrapper.find(EuiTitle)).toHaveLength(4); - expect(wrapper.find(EuiLinkTo)).toHaveLength(1); - expect(wrapper.find(LicenseBadge)).toHaveLength(0); - }); - it('renders platinum license badge if license is not present', () => { - setMockValues({ ...mockValues, hasPlatinumLicense: false }); - const wrapper = shallow(); - - expect(wrapper.find(LicenseBadge)).toHaveLength(1); - expect(wrapper.find(EuiTitle)).toHaveLength(4); - expect(wrapper.find(EuiLinkTo)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx deleted file mode 100644 index 14d088f377f5e..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ /dev/null @@ -1,236 +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 React from 'react'; - -import { useValues } from 'kea'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiIcon, - EuiSpacer, - EuiText, - EuiTextAlign, - EuiTitle, - EuiLink, - EuiPanel, - EuiCode, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { docLinks } from '../../../../../shared/doc_links'; -import { LicensingLogic } from '../../../../../shared/licensing'; -import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; -import { AppLogic } from '../../../../app_logic'; -import { LicenseBadge } from '../../../../components/shared/license_badge'; -import { - SOURCES_PATH, - SOURCE_DISPLAY_SETTINGS_PATH, - getContentSourcePath, - getSourcesPath, -} from '../../../../routes'; -import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; - -import { SourceIdentifier } from '../source_identifier'; - -import { AddCustomSourceLogic } from './add_custom_source_logic'; -import { AddSourceHeader } from './add_source_header'; -import { - SAVE_CUSTOM_BODY1, - SAVE_CUSTOM_BODY2, - SAVE_CUSTOM_RETURN_BUTTON, - SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE, - SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK, - SAVE_CUSTOM_STYLING_RESULTS_TITLE, - SAVE_CUSTOM_STYLING_RESULTS_LINK, - SAVE_CUSTOM_DOC_PERMISSIONS_TITLE, - SAVE_CUSTOM_DOC_PERMISSIONS_LINK, -} from './constants'; - -export const SaveCustom: React.FC = () => { - const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); - const { isOrganization } = useValues(AppLogic); - const { hasPlatinumLicense } = useValues(LicensingLogic); - const { - serviceType, - configuration: { githubRepository, documentationUrl }, - name, - categories = [], - } = sourceData; - - return ( - <> - - - - - - - - - - - - -

- {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading', - { - defaultMessage: '{name} Created', - values: { name: newCustomSource.name }, - } - )} -

-
-
- - - {SAVE_CUSTOM_BODY1} - - {serviceType !== 'custom' && githubRepository && ( - <> - -
- - - {githubRepository} - - - - - )} - {SAVE_CUSTOM_BODY2} -
- - {SAVE_CUSTOM_RETURN_BUTTON} - -
-
-
-
- - - -
-
- - - - -
- -

{SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE}

-
- - -

- {serviceType === 'custom' ? ( - - {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} - - ), - }} - /> - ) : ( - - {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} - - ), - name, - }} - /> - )} -

-
-
- -
- -

{SAVE_CUSTOM_STYLING_RESULTS_TITLE}

-
- - -

- - {SAVE_CUSTOM_STYLING_RESULTS_LINK} - - ), - }} - /> -

-
-
- -
- - {!hasPlatinumLicense && } - - -

{SAVE_CUSTOM_DOC_PERMISSIONS_TITLE}

-
- - -

- - {SAVE_CUSTOM_DOC_PERMISSIONS_LINK} - - ), - }} - /> -

-
- - {!hasPlatinumLicense && ( - - - {LEARN_CUSTOM_FEATURES_BUTTON} - - - )} -
-
-
-
-
- - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx index 2c7784a554a25..83d11d781bbda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_identifier.tsx @@ -14,14 +14,11 @@ import { EuiCopy, EuiButtonIcon, EuiFieldText, - EuiSpacer, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiLinkTo } from '../../../../shared/react_router_helpers'; +import { i18n } from '@kbn/i18n'; -import { API_KEY_LABEL, COPY_TOOLTIP, COPIED_TOOLTIP } from '../../../constants'; -import { API_KEYS_PATH } from '../../../routes'; +import { COPY_TOOLTIP, COPIED_TOOLTIP } from '../../../constants'; import { ID_LABEL } from '../constants'; @@ -50,24 +47,17 @@ export const SourceIdentifier: React.FC = ({ id }) => (
- +
- - -

- - {API_KEY_LABEL} - - ), - }} - /> -

-
); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index a4c6b3c6fd4d0..361eccbe8da38 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -560,14 +560,7 @@ export const staticSourceData: SourceDataItem[] = [ isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - // helpText: i18n.translate( // TODO updatae this - // 'xpack.enterpriseSearch.workplaceSearch.sources.helpText.sharepointServer', - // { - // defaultMessage: - // "Here is some help text. It should probably give the user a heads up that they're going to have to deploy some code.", - // } - // ), - documentationUrl: docLinks.workplaceSearchCustomSources, // TODO update this + documentationUrl: docLinks.workplaceSearchSharePointServer, applicationPortalUrl: '', githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', }, @@ -638,10 +631,6 @@ export const staticCustomSourceData: SourceDataItem = { isPublicKey: false, hasOauthRedirect: false, needsBaseUrl: false, - helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { - defaultMessage: - 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', - }), documentationUrl: docLinks.workplaceSearchCustomSources, applicationPortalUrl: '', }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 05ab45fc2756f..4a9913fe97aba 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -9395,15 +9395,7 @@ "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "Créer une application OAuth dans le compte {sourceName} de votre organisation", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "Fournir les informations de configuration appropriées", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "Vos points de terminaison sont prêts à accepter les requêtes.", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "Veillez à copier vos clés d'API ci-dessous.", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "Utilisez {link} pour personnaliser le mode d'affichage de vos documents dans vos résultats de recherche. Par défaut, Workplace Search utilisera les champs par ordre alphabétique.", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.title": "Définir les autorisations de niveau document", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.documentation.text": "{link} pour en savoir plus sur les sources d'API personnalisées.", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading": "{name} créé", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.permissions.text": "{link} gèrent le contenu de l'accès au contenu selon les attributs individuels ou de groupe. Autorisez ou refusez l'accès à des documents spécifiques.", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.return.button": "Retour aux sources", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.title": "Résultats de styles", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title": "Présentation visuelle", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.addField.button": "Ajouter un champ", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.empty.description": "Un schéma est créé à votre place une fois que vous avez indexé quelques documents. Cliquez ci-dessous pour créer des champs de schéma à l'avance.", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.empty.title": "La source de contenu ne possède pas de schéma", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bcdafe3c8c050..1dea85f7f1499 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11076,18 +11076,7 @@ "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "組織の{sourceName}アカウントでOAuthアプリを作成する", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "適切な構成情報を入力する", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "エンドポイントは要求を承認できます。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "必ず以下のソースIDをコピーしてください。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "{link}を使用して、検索結果内でドキュメントが表示される方法をカスタマイズします。デフォルトでは、Workplace Searchは英字順でフィールドを使用します。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.link": "ドキュメントレベルのアクセス権", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.title": "ドキュメントレベルのアクセス権を設定", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.documentation.text": "カスタムAPIソースの詳細については、{link}。", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading": "{name}が作成されました", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.permissions.text": "{link}は個別またはグループの属性でコンテンツアクセスコンテンツを管理します。特定のドキュメントへのアクセスを許可または拒否。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.return.button": "ソースに戻る", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.link": "表示設定", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.title": "スタイルの結果", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.link": "ドキュメントを確認", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title": "表示の確認", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.addField.button": "フィールドの追加", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.empty.description": "一部のドキュメントにインデックスを作成すると、スキーマが作成されます。あらかじめスキーマフィールドを作成するには、以下をクリックします。", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.empty.title": "コンテンツソースにはスキーマがありません", @@ -11443,7 +11432,6 @@ "xpack.enterpriseSearch.workplaceSearch.sources.groupAccess.title": "グループアクセス", "xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom": "カスタムAPIソースを作成するには、人間が読み取れるわかりやすい名前を入力します。この名前はさまざまな検索エクスペリエンスと管理インターフェースでそのまま表示されます。", "xpack.enterpriseSearch.workplaceSearch.sources.id.label": "ソース識別子", - "xpack.enterpriseSearch.workplaceSearch.sources.identifier.helpText": "ソースIDと{apiKeyLink}を使用して、このカスタムソースのドキュメントを同期します。", "xpack.enterpriseSearch.workplaceSearch.sources.incrementalSyncDescription": "前回の同期ジョブ以降に発生したドキュメント/更新を取得します", "xpack.enterpriseSearch.workplaceSearch.sources.incrementalSyncLabel": "差分同期", "xpack.enterpriseSearch.workplaceSearch.sources.items.header": "アイテム", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d2dbc9904b9a1..fabf4d7a2d590 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11097,18 +11097,7 @@ "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep1": "在组织的 {sourceName} 帐户中创建 OAuth 应用", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveConfig.oauthStep2": "提供适当的配置信息", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body1": "您的终端已准备好接受请求。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.body2": "确保复制下面的源标识符。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.displaySettings.text": "请使用 {link} 定制您的文档在搜索结果内显示的方式。Workplace Search 默认按字母顺序使用字段。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.link": "文档级权限", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.docPermissions.title": "设置文档级权限", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.documentation.text": "{link}以详细了解定制 API 源。", "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading": "{name} 已创建", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.permissions.text": "{link} 管理有关单个属性或组属性的内容访问内容。允许或拒绝对特定文档的访问。", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.return.button": "返回到源", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.link": "显示设置", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.stylingResults.title": "正在为结果应用样式", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.link": "查阅文档", - "xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title": "直观的演练", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.addField.button": "添加字段", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.empty.description": "您索引一些文档后,系统便会为您创建架构。单击下面,以提前创建架构字段。", "xpack.enterpriseSearch.workplaceSearch.contentSource.schema.empty.title": "内容源没有架构", @@ -11464,7 +11453,6 @@ "xpack.enterpriseSearch.workplaceSearch.sources.groupAccess.title": "组访问权限", "xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom": "要创建定制 API 源,请提供可人工读取的描述性名称。名称在各种搜索体验和管理界面中都原样显示。", "xpack.enterpriseSearch.workplaceSearch.sources.id.label": "源标识符", - "xpack.enterpriseSearch.workplaceSearch.sources.identifier.helpText": "将源标识符用于 {apiKeyLink},以同步此定制源的文档。", "xpack.enterpriseSearch.workplaceSearch.sources.incrementalSyncDescription": "检索自上次同步作业以来的文档/更新", "xpack.enterpriseSearch.workplaceSearch.sources.incrementalSyncLabel": "增量同步", "xpack.enterpriseSearch.workplaceSearch.sources.items.header": "项", From 7d29236a8e61e2432889c0f4e93ef49e44646fe1 Mon Sep 17 00:00:00 2001 From: Rachel Shen Date: Tue, 22 Mar 2022 13:47:05 -0600 Subject: [PATCH 052/132] [shared-ux] Migrate add from library button to shared ux directory (#127605) --- .../kbn-shared-ux-components/src/index.ts | 5 ++ .../add_from_library.test.tsx.snap | 74 +++++++++++++++++++ .../add_from_library/add_from_library.mdx | 10 +++ .../add_from_library.stories.tsx | 25 +++++++ .../add_from_library.test.tsx | 19 +++++ .../add_from_library/add_from_library.tsx | 24 ++++++ .../icon_button_group.stories.tsx | 2 +- .../src/toolbar/buttons/primary/primary.mdx | 6 +- .../buttons/primary/primary.stories.tsx | 2 +- .../toolbar/buttons/primary/primary.test.tsx | 4 - .../src/toolbar/index.ts | 2 +- 11 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/__snapshots__/add_from_library.test.tsx.snap create mode 100644 packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.mdx create mode 100644 packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.stories.tsx create mode 100644 packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.test.tsx create mode 100644 packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.tsx diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index a43b53a6e7cd1..c5e719a904ebd 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -41,6 +41,11 @@ export const ExitFullScreenButton = withSuspense(LazyExitFullScreenButton); */ export const ToolbarButton = withSuspense(LazyToolbarButton); +/** + * An example of the solution toolbar button + */ +export { AddFromLibraryButton } from './toolbar'; + /** * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the * `withSuspense` HOC to load this component. diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/__snapshots__/add_from_library.test.tsx.snap b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/__snapshots__/add_from_library.test.tsx.snap new file mode 100644 index 0000000000000..4cdc858c7e50c --- /dev/null +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/__snapshots__/add_from_library.test.tsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` is rendered 1`] = ` + + + + + + + + + +`; diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.mdx b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.mdx new file mode 100644 index 0000000000000..f6a2f92cd41eb --- /dev/null +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.mdx @@ -0,0 +1,10 @@ +--- +id: sharedUX/Components/AddFromLibraryButton +slug: /shared-ux/components/toolbar/buttons/add_from_library +title: Add From Library Button +summary: An example of the primary button +tags: ['shared-ux', 'component'] +date: 2022-03-18 +--- + +This button is an example of the primary button. diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.stories.tsx b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.stories.tsx new file mode 100644 index 0000000000000..ea50431545028 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.stories.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { AddFromLibraryButton } from './add_from_library'; +import mdx from './add_from_library.mdx'; + +export default { + title: 'Toolbar/Buttons/Add From Library Button', + description: 'An implementation of the solution toolbar primary button', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const Component = () => { + return ; +}; diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.test.tsx b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.test.tsx new file mode 100644 index 0000000000000..a2ba1d8bff174 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.test.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { mount as enzymeMount } from 'enzyme'; +import React from 'react'; +import { AddFromLibraryButton } from './add_from_library'; + +describe('', () => { + test('is rendered', () => { + const component = enzymeMount(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.tsx b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.tsx new file mode 100644 index 0000000000000..190edc8f29491 --- /dev/null +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/add_from_library/add_from_library.tsx @@ -0,0 +1,24 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ToolbarButton, Props as ToolbarButtonProps } from '../primary/primary'; + +export type Props = Omit; + +const label = { + getLibraryButtonLabel: () => + i18n.translate('sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel', { + defaultMessage: 'Add from library', + }), +}; + +export const AddFromLibraryButton = ({ onClick, ...rest }: Props) => ( + +); diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/icon_button_group.stories.tsx b/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/icon_button_group.stories.tsx index c30f015325672..988a5bddd513f 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/icon_button_group.stories.tsx +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/icon_button_group.stories.tsx @@ -13,7 +13,7 @@ import { IconButtonGroup } from './icon_button_group'; import mdx from './icon_button_group.mdx'; export default { - title: 'Toolbar/Icon Button Group', + title: 'Toolbar/Buttons/Icon Button Group', description: 'A collection of buttons that is a part of a toolbar.', parameters: { docs: { diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.mdx b/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.mdx index 489596e771c29..c1fa431f39bdc 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.mdx +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.mdx @@ -1,7 +1,7 @@ --- -id: sharedUX/Components/Toolbar/Primary_Button +id: sharedUX/Components/ToolbarButton slug: /shared-ux/components/toolbar/buttons/primary -title: Toolbar Button +title: Solution Toolbar Button summary: An opinionated implementation of the toolbar extracted to just the button. tags: ['shared-ux', 'component'] date: 2022-02-17 @@ -9,4 +9,4 @@ date: 2022-02-17 > This documentation is in-progress. -This button is a part of the toolbar component. This button has primary styling and requires a `label`. Interaction (`onClick`) handlers and `iconType`s are supported as an extension of EuiButtonProps. Icons are always on the left of any labels within the button. +This button is a part of the solution toolbar component. This button has primary styling and requires a label. OnClick handlers and icon types are supported as an extension of EuiButtonProps. Icons are always on the left of any labels within the button. diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.stories.tsx b/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.stories.tsx index 0388cccb60c3f..a81be610c1508 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.stories.tsx +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.stories.tsx @@ -12,7 +12,7 @@ import { ToolbarButton } from './primary'; import mdx from './primary.mdx'; export default { - title: 'Toolbar/Primary button', + title: 'Toolbar/Buttons/Primary button', description: 'A primary button that is a part of a toolbar.', parameters: { docs: { diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.test.tsx b/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.test.tsx index e93d537a40ce5..3e0e153f453e5 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.test.tsx +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/primary/primary.test.tsx @@ -27,10 +27,6 @@ describe('', () => { enzymeMount({element}); }); - afterEach(() => { - jest.resetAllMocks(); - }); - test('is rendered', () => { const component = mount(); diff --git a/packages/kbn-shared-ux-components/src/toolbar/index.ts b/packages/kbn-shared-ux-components/src/toolbar/index.ts index e68abf2916a72..513f81c1ddfc7 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/index.ts +++ b/packages/kbn-shared-ux-components/src/toolbar/index.ts @@ -5,6 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - export { ToolbarButton } from './buttons/primary/primary'; export { IconButtonGroup } from './buttons/icon_button_group/icon_button_group'; +export { AddFromLibraryButton } from './buttons/add_from_library/add_from_library'; From 72399c47aa265e4386d342804d6286f3763be92e Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Tue, 22 Mar 2022 13:47:30 -0700 Subject: [PATCH 053/132] ally geo smoke test (#127982) * ally geo * remove unwanted data * addressed review comments * added maps listing page tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/accessibility/apps/maps.ts | 127 +++++++++++++++++++++++++ x-pack/test/accessibility/config.ts | 1 + 2 files changed, 128 insertions(+) create mode 100644 x-pack/test/accessibility/apps/maps.ts diff --git a/x-pack/test/accessibility/apps/maps.ts b/x-pack/test/accessibility/apps/maps.ts new file mode 100644 index 0000000000000..079972273c19b --- /dev/null +++ b/x-pack/test/accessibility/apps/maps.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const a11y = getService('a11y'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'settings', 'header', 'home', 'maps']); + + describe('Maps app meets ally validations', () => { + before(async () => { + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.addSampleDataSet('flights'); + await PageObjects.common.navigateToApp('maps'); + }); + + after(async () => { + await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { + useActualUrl: true, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.home.removeSampleDataSet('flights'); + }); + + it('loads maps workpads', async function () { + await PageObjects.maps.loadSavedMap('[Flights] Origin Time Delayed'); + await a11y.testAppSnapshot(); + }); + + it('click map settings', async function () { + await testSubjects.click('openSettingsButton'); + await a11y.testAppSnapshot(); + }); + + it('map save button', async function () { + await testSubjects.click('mapSaveButton'); + await a11y.testAppSnapshot(); + }); + + it('map cancel button', async function () { + await testSubjects.click('saveCancelButton'); + await a11y.testAppSnapshot(); + }); + + it('map inspect button', async function () { + await testSubjects.click('openInspectorButton'); + await a11y.testAppSnapshot(); + }); + + it('map inspect view chooser ', async function () { + await testSubjects.click('inspectorViewChooser'); + await a11y.testAppSnapshot(); + }); + + it('map inspector view chooser requests', async function () { + await testSubjects.click('inspectorViewChooserRequests'); + await a11y.testAppSnapshot(); + }); + + it('map inspector view chooser requests', async function () { + await PageObjects.maps.openInspectorMapView(); + await a11y.testAppSnapshot(); + }); + + it('map inspector close', async function () { + await testSubjects.click('euiFlyoutCloseButton'); + await a11y.testAppSnapshot(); + }); + + it('full screen button should exist', async () => { + await testSubjects.click('mapsFullScreenMode'); + await a11y.testAppSnapshot(); + }); + + it('displays exit full screen logo button', async () => { + await testSubjects.click('exitFullScreenModeLogo'); + await a11y.testAppSnapshot(); + }); + + it(`allows a map to be created`, async () => { + await PageObjects.maps.openNewMap(); + await a11y.testAppSnapshot(); + await PageObjects.maps.expectExistAddLayerButton(); + await a11y.testAppSnapshot(); + await PageObjects.maps.saveMap('my test map'); + await a11y.testAppSnapshot(); + }); + + it('maps listing page', async function () { + await PageObjects.common.navigateToApp('maps'); + await retry.waitFor( + 'maps workpads visible', + async () => await testSubjects.exists('itemsInMemTable') + ); + await a11y.testAppSnapshot(); + }); + + it('provides bulk selection', async function () { + await testSubjects.click('checkboxSelectAll'); + await a11y.testAppSnapshot(); + }); + + it('provides bulk delete', async function () { + await testSubjects.click('deleteSelectedItems'); + await a11y.testAppSnapshot(); + }); + + it('single delete modal', async function () { + await testSubjects.click('confirmModalConfirmButton'); + await a11y.testAppSnapshot(); + }); + + it('single cancel modal', async function () { + await testSubjects.click('confirmModalCancelButton'); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index a85259d465084..e85b8a9ef17d8 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -35,6 +35,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/lens'), require.resolve('./apps/upgrade_assistant'), require.resolve('./apps/canvas'), + require.resolve('./apps/maps'), require.resolve('./apps/security_solution'), require.resolve('./apps/ml_embeddables_in_dashboard'), require.resolve('./apps/remote_clusters'), From 9845a5c21769850b1b3580cf1f08417102596ece Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 22 Mar 2022 16:50:33 -0400 Subject: [PATCH 054/132] [Security team: AWP] [Session view] Add alert fly out callback (#127991) * Add alertFlyoutCallback to process_tree_alert * Add useCallback hook to callback functions * Rename to loadAlertDetails and add handleOnAlertDetailsClosed * Finish functionality * Fix jest tests * Add tests for updateAlertEventStatus * Fix PR comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/session_view/common/constants.ts | 24 ++++ .../constants/session_view_process.mock.ts | 3 + .../common/types/process_tree/index.ts | 8 ++ .../components/process_tree/helpers.test.ts | 53 ++++++++- .../public/components/process_tree/helpers.ts | 33 +++++- .../public/components/process_tree/hooks.ts | 29 ++++- .../components/process_tree/index.test.tsx | 107 ++++-------------- .../public/components/process_tree/index.tsx | 20 +++- .../process_tree_alert/index.test.tsx | 56 +++++---- .../components/process_tree_alert/index.tsx | 36 ++++-- .../process_tree_alerts/index.test.tsx | 17 +-- .../components/process_tree_alerts/index.tsx | 21 +++- .../process_tree_node/index.test.tsx | 1 + .../components/process_tree_node/index.tsx | 29 +++-- .../public/components/session_view/hooks.ts | 55 ++++++++- .../public/components/session_view/index.tsx | 46 ++++++-- .../session_view/public/methods/index.tsx | 8 +- x-pack/plugins/session_view/public/types.ts | 6 + .../server/routes/alert_status_route.ts | 56 +++++++++ .../session_view/server/routes/index.ts | 2 + 20 files changed, 441 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/session_view/server/routes/alert_status_route.ts diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 4ca130c6af7b4..42e1d33ab6dba 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -6,11 +6,18 @@ */ export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const ALERT_STATUS_ROUTE = '/internal/session_view/alert_status_route'; export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; export const ALERTS_INDEX = '.siem-signals-default'; export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; +export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid'; export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; +export const ALERT_STATUS = { + OPEN: 'open', + ACKNOWLEDGED: 'acknowledged', + CLOSED: 'closed', +}; // We fetch a large number of events per page to mitigate a few design caveats in session viewer // 1. Due to the hierarchical nature of the data (e.g we are rendering a time ordered pid tree) there are common scenarios where there @@ -26,6 +33,23 @@ export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; // search functionality will instead use a separate ES backend search to avoid this. // 3. Fewer round trips to the backend! export const PROCESS_EVENTS_PER_PAGE = 1000; + +// As an initial approach, we won't be implementing pagination for alerts. +// Instead we will load this fixed amount of alerts as a maximum for a session. +// This could cause an edge case, where a noisy rule that alerts on every process event +// causes a session to only list and highlight up to 1000 alerts, even though there could +// be far greater than this amount. UX should be added to let the end user know this is +// happening and to revise their rule to be more specific. +export const ALERTS_PER_PAGE = 1000; + +// when showing the count of alerts in details panel tab, if the number +// exceeds ALERT_COUNT_THRESHOLD we put a + next to it, e.g 999+ +export const ALERT_COUNT_THRESHOLD = 999; + +// react-query caching keys +export const QUERY_KEY_PROCESS_EVENTS = 'sessionViewProcessEvents'; +export const QUERY_KEY_ALERTS = 'sessionViewAlerts'; + export const MOUSE_EVENT_PLACEHOLDER = { stopPropagation: () => undefined } as React.MouseEvent; export const DEBOUNCE_TIMEOUT = 500; diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts index 83cd250d45691..f9ace9fee7a75 100644 --- a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -920,6 +920,7 @@ export const childProcessMock: Process = { hasOutput: () => false, hasAlerts: () => false, getAlerts: () => [], + updateAlertsStatus: (_) => undefined, hasExec: () => false, getOutput: () => '', getDetails: () => @@ -998,6 +999,7 @@ export const processMock: Process = { hasOutput: () => false, hasAlerts: () => false, getAlerts: () => [], + updateAlertsStatus: (_) => undefined, hasExec: () => false, getOutput: () => '', getDetails: () => @@ -1173,6 +1175,7 @@ export const mockProcessMap = mockEvents.reduce( hasOutput: () => false, hasAlerts: () => false, getAlerts: () => [], + updateAlertsStatus: (_) => undefined, hasExec: () => false, getOutput: () => '', getDetails: () => event, diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts index 746c1b2093661..3475e8d425908 100644 --- a/x-pack/plugins/session_view/common/types/process_tree/index.ts +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -5,6 +5,13 @@ * 2.0. */ +export interface AlertStatusEventEntityIdMap { + [alertUuid: string]: { + status: string; + processEntityId: string; + }; +} + export const enum EventKind { event = 'event', signal = 'signal', @@ -150,6 +157,7 @@ export interface Process { hasOutput(): boolean; hasAlerts(): boolean; getAlerts(): ProcessEvent[]; + updateAlertsStatus(updatedAlertsStatus: AlertStatusEventEntityIdMap): void; hasExec(): boolean; getOutput(): string; getDetails(): ProcessEvent; diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts index 9092009a7d291..39947da471499 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.test.ts @@ -4,12 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { cloneDeep } from 'lodash'; import { - mockData, + mockEvents, + mockAlerts, mockProcessMap, } from '../../../common/mocks/constants/session_view_process.mock'; -import { Process, ProcessMap } from '../../../common/types/process_tree'; import { + AlertStatusEventEntityIdMap, + Process, + ProcessMap, + ProcessEvent, +} from '../../../common/types/process_tree'; +import { ALERT_STATUS } from '../../../common/constants'; +import { + updateAlertEventStatus, updateProcessMap, buildProcessTree, searchProcessTree, @@ -20,8 +29,6 @@ const SESSION_ENTITY_ID = '3d0192c6-7c54-5ee6-a110-3539a7cf42bc'; const SEARCH_QUERY = 'vi'; const SEARCH_RESULT_PROCESS_ID = '8e4daeb2-4a4e-56c4-980e-f0dcfdbc3727'; -const mockEvents = mockData[0].events; - describe('process tree hook helpers tests', () => { let processMap: ProcessMap; @@ -73,4 +80,42 @@ describe('process tree hook helpers tests', () => { // session leader should have autoExpand to be true expect(processMap[SESSION_ENTITY_ID].autoExpand).toBeTruthy(); }); + + it('updateAlertEventStatus works', () => { + let events: ProcessEvent[] = cloneDeep([...mockEvents, ...mockAlerts]); + const updatedAlertsStatus: AlertStatusEventEntityIdMap = { + [mockAlerts[0].kibana?.alert.uuid!]: { + status: ALERT_STATUS.CLOSED, + processEntityId: mockAlerts[0].process.entity_id, + }, + }; + + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[0].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.OPEN); + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[1].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.OPEN); + + events = updateAlertEventStatus(events, updatedAlertsStatus); + + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[0].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.CLOSED); + expect( + events.find( + (event) => + event.kibana?.alert.uuid && event.kibana?.alert.uuid === mockAlerts[1].kibana?.alert.uuid + )?.kibana?.alert.workflow_status + ).toEqual(ALERT_STATUS.OPEN); + }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts index d3d7af1c62eda..df4a6cf70abec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/helpers.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/helpers.ts @@ -4,9 +4,40 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { Process, ProcessEvent, ProcessMap } from '../../../common/types/process_tree'; +import { + AlertStatusEventEntityIdMap, + Process, + ProcessEvent, + ProcessMap, +} from '../../../common/types/process_tree'; import { ProcessImpl } from './hooks'; +// if given event is an alert, and it exist in updatedAlertsStatus, update the alert's status +// with the updated status value in updatedAlertsStatus Map +export const updateAlertEventStatus = ( + events: ProcessEvent[], + updatedAlertsStatus: AlertStatusEventEntityIdMap +) => + events.map((event) => { + // do nothing if event is not an alert + if (!event.kibana) { + return event; + } + + return { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana.alert, + workflow_status: + updatedAlertsStatus[event.kibana.alert?.uuid]?.status ?? + event.kibana.alert?.workflow_status, + }, + }, + }; + }); + // given a page of new events, add these events to the appropriate process class model // create a new process if none are created and return the mutated processMap export const updateProcessMap = (processMap: ProcessMap, events: ProcessEvent[]) => { diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index dfd34a5d10094..fb00344d5e280 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -8,6 +8,7 @@ import _ from 'lodash'; import memoizeOne from 'memoize-one'; import { useState, useEffect } from 'react'; import { + AlertStatusEventEntityIdMap, EventAction, EventKind, Process, @@ -15,13 +16,19 @@ import { ProcessMap, ProcessEventsPage, } from '../../../common/types/process_tree'; -import { processNewEvents, searchProcessTree, autoExpandProcessTree } from './helpers'; +import { + updateAlertEventStatus, + processNewEvents, + searchProcessTree, + autoExpandProcessTree, +} from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; searchQuery?: string; + updatedAlertsStatus: AlertStatusEventEntityIdMap; } export class ProcessImpl implements Process { @@ -103,6 +110,10 @@ export class ProcessImpl implements Process { return this.filterEventsByKind(this.events, EventKind.signal); } + updateAlertsStatus(updatedAlertsStatus: AlertStatusEventEntityIdMap) { + this.events = updateAlertEventStatus(this.events, updatedAlertsStatus); + } + hasExec() { return !!this.findEventByAction(this.events, EventAction.exec); } @@ -129,6 +140,7 @@ export class ProcessImpl implements Process { // only used to auto expand parts of the tree that could be of interest. isUserEntered() { const event = this.getDetails(); + const { pid, tty, @@ -181,7 +193,12 @@ export class ProcessImpl implements Process { }); } -export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProcessTreeDeps) => { +export const useProcessTree = ({ + sessionEntityId, + data, + searchQuery, + updatedAlertsStatus, +}: UseProcessTreeDeps) => { // initialize map, as well as a placeholder for session leader process // we add a fake session leader event, sourced from wide event data. // this is because we might not always have a session leader event @@ -250,5 +267,13 @@ export const useProcessTree = ({ sessionEntityId, data, searchQuery }: UseProces sessionLeader.orphans = orphans; + // update alert status in processMap for alerts in updatedAlertsStatus + Object.keys(updatedAlertsStatus).forEach((alertUuid) => { + const process = processMap[updatedAlertsStatus[alertUuid].processEntityId]; + if (process) { + process.updateAlertsStatus(updatedAlertsStatus); + } + }); + return { sessionLeader: processMap[sessionEntityId], processMap, searchResults }; }; diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx index bdaeb0cdce2b4..9fa7900d04b0d 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -10,7 +10,7 @@ import { mockData } from '../../../common/mocks/constants/session_view_process.m import { Process } from '../../../common/types/process_tree'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { ProcessImpl } from './hooks'; -import { ProcessTree } from './index'; +import { ProcessTreeDeps, ProcessTree } from './index'; describe('ProcessTree component', () => { let render: () => ReturnType; @@ -18,6 +18,18 @@ describe('ProcessTree component', () => { let mockedContext: AppContextTestRender; const sessionLeader = mockData[0].events[0]; const sessionLeaderVerboseTest = mockData[0].events[3]; + const props: ProcessTreeDeps = { + sessionEntityId: sessionLeader.process.entity_id, + data: mockData, + isFetching: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + fetchPreviousPage: jest.fn(), + hasPreviousPage: false, + onProcessSelected: jest.fn(), + updatedAlertsStatus: {}, + handleOnAlertDetailsClosed: jest.fn(), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -25,18 +37,7 @@ describe('ProcessTree component', () => { describe('When ProcessTree is mounted', () => { it('should render given a valid sessionEntityId and data', () => { - renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={jest.fn()} - /> - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); }); @@ -47,17 +48,7 @@ describe('ProcessTree component', () => { expect(process?.id).toBe(jumpToEvent.process.entity_id); }); renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - jumpToEvent={jumpToEvent} - onProcessSelected={onProcessSelected} - /> + ); expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); @@ -70,16 +61,7 @@ describe('ProcessTree component', () => { expect(process?.id).toBe(sessionLeader.process.entity_id); }); renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={onProcessSelected} - /> + ); expect(renderResult.queryByTestId('sessionView:sessionViewProcessTree')).toBeTruthy(); expect(renderResult.queryAllByTestId('sessionView:processTreeNode')).toBeTruthy(); @@ -88,20 +70,7 @@ describe('ProcessTree component', () => { }); it('When Verbose mode is OFF, it should not show all childrens', () => { - renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={jest.fn()} - timeStampOn={true} - verboseModeOn={false} - /> - ); + renderResult = mockedContext.render(); expect(renderResult.queryByText('cat')).toBeFalsy(); const selectionArea = renderResult.queryAllByTestId('sessionView:processTreeNode'); @@ -112,20 +81,7 @@ describe('ProcessTree component', () => { }); it('When Verbose mode is ON, it should show all childrens', () => { - renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - onProcessSelected={jest.fn()} - timeStampOn={true} - verboseModeOn={true} - /> - ); + renderResult = mockedContext.render(); expect(renderResult.queryByText('cat')).toBeTruthy(); const selectionArea = renderResult.queryAllByTestId('sessionView:processTreeNode'); @@ -139,18 +95,7 @@ describe('ProcessTree component', () => { const mockSelectedProcess = new ProcessImpl(mockData[0].events[0].process.entity_id); renderResult = mockedContext.render( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - selectedProcess={mockSelectedProcess} - onProcessSelected={jest.fn()} - verboseModeOn={true} - /> + ); expect( @@ -162,19 +107,7 @@ describe('ProcessTree component', () => { // change the selected process const mockSelectedProcess2 = new ProcessImpl(mockData[0].events[1].process.entity_id); - renderResult.rerender( - true} - hasNextPage={false} - fetchPreviousPage={() => true} - hasPreviousPage={false} - selectedProcess={mockSelectedProcess2} - onProcessSelected={jest.fn()} - /> - ); + renderResult.rerender(); expect( renderResult diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 06942498aa967..4b489797c7e26 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -10,13 +10,18 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { ProcessTreeNode } from '../process_tree_node'; import { BackToInvestigatedAlert } from '../back_to_investigated_alert'; import { useProcessTree } from './hooks'; -import { Process, ProcessEventsPage, ProcessEvent } from '../../../common/types/process_tree'; +import { + AlertStatusEventEntityIdMap, + Process, + ProcessEventsPage, + ProcessEvent, +} from '../../../common/types/process_tree'; import { useScroll } from '../../hooks/use_scroll'; import { useStyles } from './styles'; type FetchFunction = () => void; -interface ProcessTreeDeps { +export interface ProcessTreeDeps { // process.entity_id to act as root node (typically a session (or entry session) leader). sessionEntityId: string; @@ -36,6 +41,11 @@ interface ProcessTreeDeps { selectedProcess?: Process | null; onProcessSelected: (process: Process | null) => void; setSearchResults?: (results: Process[]) => void; + + // a map for alerts with updated status and process.entity_id + updatedAlertsStatus: AlertStatusEventEntityIdMap; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string) => void; timeStampOn?: boolean; verboseModeOn?: boolean; } @@ -53,6 +63,9 @@ export const ProcessTree = ({ selectedProcess, onProcessSelected, setSearchResults, + updatedAlertsStatus, + loadAlertDetails, + handleOnAlertDetailsClosed, timeStampOn, verboseModeOn, }: ProcessTreeDeps) => { @@ -64,6 +77,7 @@ export const ProcessTree = ({ sessionEntityId, data, searchQuery, + updatedAlertsStatus, }); const scrollerRef = useRef(null); @@ -189,6 +203,8 @@ export const ProcessTree = ({ selectedProcessId={selectedProcess?.id} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} timeStampOn={timeStampOn} verboseModeOn={verboseModeOn} /> diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx index 635ac09682eae..2a56a0ae2be67 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -8,17 +8,26 @@ import React from 'react'; import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; -import { ProcessTreeAlert } from './index'; +import { ProcessTreeAlertDeps, ProcessTreeAlert } from './index'; const mockAlert = mockAlerts[0]; const TEST_ID = `sessionView:sessionViewAlertDetail-${mockAlert.kibana?.alert.uuid}`; const ALERT_RULE_NAME = mockAlert.kibana?.alert.rule.name; const ALERT_STATUS = mockAlert.kibana?.alert.workflow_status; +const EXPAND_BUTTON_TEST_ID = `sessionView:sessionViewAlertDetailExpand-${mockAlert.kibana?.alert.uuid}`; describe('ProcessTreeAlerts component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + const props: ProcessTreeAlertDeps = { + alert: mockAlert, + isInvestigated: false, + isSelected: false, + onClick: jest.fn(), + selectAlert: jest.fn(), + handleOnAlertDetailsClosed: jest.fn(), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -26,15 +35,7 @@ describe('ProcessTreeAlerts component', () => { describe('When ProcessTreeAlert is mounted', () => { it('should render alert row correctly', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId(TEST_ID)).toBeTruthy(); expect(renderResult.queryByText(ALERT_RULE_NAME!)).toBeTruthy(); @@ -42,21 +43,34 @@ describe('ProcessTreeAlerts component', () => { }); it('should execute onClick callback', async () => { - const mockFn = jest.fn(); - renderResult = mockedContext.render( - - ); + const onClick = jest.fn(); + renderResult = mockedContext.render(); const alertRow = renderResult.queryByTestId(TEST_ID); expect(alertRow).toBeTruthy(); alertRow?.click(); - expect(mockFn).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should automatically call selectAlert when isInvestigated is true', async () => { + const selectAlert = jest.fn(); + renderResult = mockedContext.render( + + ); + + expect(selectAlert).toHaveBeenCalledTimes(1); + }); + + it('should execute loadAlertDetails callback when clicking on expand button', async () => { + const loadAlertDetails = jest.fn(); + renderResult = mockedContext.render( + + ); + + const expandButton = renderResult.queryByTestId(EXPAND_BUTTON_TEST_ID); + expect(expandButton).toBeTruthy(); + expandButton?.click(); + expect(loadAlertDetails).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx index d0d4c84252513..5ec1c4a7693c3 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -5,18 +5,20 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui'; import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree'; import { getBadgeColorFromAlertStatus } from './helpers'; import { useStyles } from './styles'; -interface ProcessTreeAlertDeps { +export interface ProcessTreeAlertDeps { alert: ProcessEvent; isInvestigated: boolean; isSelected: boolean; onClick: (alert: ProcessEventAlert | null) => void; selectAlert: (alertUuid: string) => void; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string, status?: string) => void; } export const ProcessTreeAlert = ({ @@ -25,16 +27,30 @@ export const ProcessTreeAlert = ({ isSelected, onClick, selectAlert, + loadAlertDetails, + handleOnAlertDetailsClosed, }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {}; useEffect(() => { - if (isInvestigated && isSelected && uuid) { + if (isInvestigated && uuid) { selectAlert(uuid); } - }, [isInvestigated, isSelected, uuid, selectAlert]); + }, [isInvestigated, uuid, selectAlert]); + + const handleExpandClick = useCallback(() => { + if (loadAlertDetails && uuid) { + loadAlertDetails(uuid, () => handleOnAlertDetailsClosed(uuid)); + } + }, [handleOnAlertDetailsClosed, loadAlertDetails, uuid]); + + const handleClick = useCallback(() => { + if (alert.kibana?.alert) { + onClick(alert.kibana.alert); + } + }, [alert.kibana?.alert, onClick]); if (!(alert.kibana && rule)) { return null; @@ -42,10 +58,6 @@ export const ProcessTreeAlert = ({ const { name } = rule; - const handleClick = () => { - onClick(alert.kibana?.alert ?? null); - }; - return ( - + {name} diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx index c4dbaf817cff2..2333c71d36a51 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -8,12 +8,17 @@ import React from 'react'; import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; -import { ProcessTreeAlerts } from './index'; +import { ProcessTreeAlertsDeps, ProcessTreeAlerts } from './index'; describe('ProcessTreeAlerts component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + const props: ProcessTreeAlertsDeps = { + alerts: mockAlerts, + onAlertSelected: jest.fn(), + handleOnAlertDetailsClosed: jest.fn(), + }; beforeEach(() => { mockedContext = createAppRootMockRenderer(); @@ -21,17 +26,13 @@ describe('ProcessTreeAlerts component', () => { describe('When ProcessTreeAlerts is mounted', () => { it('should return null if no alerts', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull(); }); it('should return an array of alert details', async () => { - renderResult = mockedContext.render( - - ); + renderResult = mockedContext.render(); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); mockAlerts.forEach((alert) => { @@ -49,7 +50,7 @@ describe('ProcessTreeAlerts component', () => { it('should execute onAlertSelected when clicking on an alert', async () => { const mockFn = jest.fn(); renderResult = mockedContext.render( - + ); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx index dcca29dcf4f84..c97ccfe253605 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -11,11 +11,13 @@ import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_t import { ProcessTreeAlert } from '../process_tree_alert'; import { MOUSE_EVENT_PLACEHOLDER } from '../../../common/constants'; -interface ProcessTreeAlertsDeps { +export interface ProcessTreeAlertsDeps { alerts: ProcessEvent[]; jumpToAlertID?: string; isProcessSelected?: boolean; onAlertSelected: (e: MouseEvent) => void; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string) => void; } export function ProcessTreeAlerts({ @@ -23,6 +25,8 @@ export function ProcessTreeAlerts({ jumpToAlertID, isProcessSelected = false, onAlertSelected, + loadAlertDetails, + handleOnAlertDetailsClosed, }: ProcessTreeAlertsDeps) { const [selectedAlert, setSelectedAlert] = useState(null); const styles = useStyles(); @@ -57,15 +61,18 @@ export function ProcessTreeAlerts({ } }, []); + const handleAlertClick = useCallback( + (alert: ProcessEventAlert | null) => { + onAlertSelected(MOUSE_EVENT_PLACEHOLDER); + setSelectedAlert(alert); + }, + [onAlertSelected] + ); + if (alerts.length === 0) { return null; } - const handleAlertClick = (alert: ProcessEventAlert | null) => { - onAlertSelected(MOUSE_EVENT_PLACEHOLDER); - setSelectedAlert(alert); - }; - return (
); })} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 0791f21e81846..2e82e822f0c82 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -36,6 +36,7 @@ describe('ProcessTreeNode component', () => { }, } as unknown as RefObject, onChangeJumpToEventVisibility: jest.fn(), + handleOnAlertDetailsClosed: (_alertUuid: string) => {}, }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index bc2eb4706c73d..b1c42dd95efb9 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -43,6 +43,8 @@ export interface ProcessDeps { verboseModeOn?: boolean; scrollerRef: RefObject; onChangeJumpToEventVisibility: (isVisible: boolean, isAbove: boolean) => void; + loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; + handleOnAlertDetailsClosed: (alertUuid: string) => void; } /** @@ -60,6 +62,8 @@ export function ProcessTreeNode({ verboseModeOn = true, scrollerRef, onChangeJumpToEventVisibility, + loadAlertDetails, + handleOnAlertDetailsClosed, }: ProcessDeps) { const textRef = useRef(null); @@ -123,18 +127,21 @@ export function ProcessTreeNode({ setAlertsExpanded(!alertsExpanded); }, [alertsExpanded]); - const onProcessClicked = (e: MouseEvent) => { - e.stopPropagation(); + const onProcessClicked = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); - const selection = window.getSelection(); + const selection = window.getSelection(); - // do not select the command if the user was just selecting text for copy. - if (selection && selection.type === 'Range') { - return; - } + // do not select the command if the user was just selecting text for copy. + if (selection && selection.type === 'Range') { + return; + } - onProcessSelected?.(process); - }; + onProcessSelected?.(process); + }, + [onProcessSelected, process] + ); const processDetails = process.getDetails(); @@ -248,6 +255,8 @@ export function ProcessTreeNode({ jumpToAlertID={jumpToAlertID} isProcessSelected={selectedProcessId === process.id} onAlertSelected={onProcessClicked} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} /> )} @@ -267,6 +276,8 @@ export function ProcessTreeNode({ verboseModeOn={verboseModeOn} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index 17574cfd28074..a134a366c4168 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -5,12 +5,21 @@ * 2.0. */ import { useEffect, useState } from 'react'; -import { useInfiniteQuery } from 'react-query'; +import { useQuery, useInfiniteQuery } from 'react-query'; import { EuiSearchBarOnChangeArgs } from '@elastic/eui'; import { CoreStart } from 'kibana/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { ProcessEvent, ProcessEventResults } from '../../../common/types/process_tree'; -import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE } from '../../../common/constants'; +import { + AlertStatusEventEntityIdMap, + ProcessEvent, + ProcessEventResults, +} from '../../../common/types/process_tree'; +import { + PROCESS_EVENTS_ROUTE, + PROCESS_EVENTS_PER_PAGE, + ALERT_STATUS_ROUTE, + QUERY_KEY_ALERTS, +} from '../../../common/constants'; export const useFetchSessionViewProcessEvents = ( sessionEntityId: string, @@ -75,6 +84,46 @@ export const useFetchSessionViewProcessEvents = ( return query; }; +export const useFetchAlertStatus = ( + updatedAlertsStatus: AlertStatusEventEntityIdMap, + alertUuid: string +) => { + const { http } = useKibana().services; + const cachingKeys = [QUERY_KEY_ALERTS, alertUuid]; + const query = useQuery( + cachingKeys, + async () => { + if (!alertUuid) { + return updatedAlertsStatus; + } + + const res = await http.get(ALERT_STATUS_ROUTE, { + query: { + alertUuid, + }, + }); + + // TODO: add error handling + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return { + ...updatedAlertsStatus, + [alertUuid]: { + status: events[0]?.kibana?.alert.workflow_status ?? '', + processEntityId: events[0]?.process?.entity_id ?? '', + }, + }; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + return query; +}; + export const useSearchQuery = () => { const [searchQuery, setSearchQuery] = useState(''); const onSearch = ({ query }: EuiSearchBarOnChangeArgs) => { diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index 4b8881a88d7b0..af4eb6114a0a2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { EuiEmptyPrompt, EuiButton, @@ -16,34 +16,40 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { SectionLoading } from '../../shared_imports'; import { ProcessTree } from '../process_tree'; -import { Process } from '../../../common/types/process_tree'; +import { AlertStatusEventEntityIdMap, Process } from '../../../common/types/process_tree'; import { DisplayOptionsState } from '../../../common/types/session_view'; import { SessionViewDeps } from '../../types'; import { SessionViewDetailPanel } from '../session_view_detail_panel'; import { SessionViewSearchBar } from '../session_view_search_bar'; import { SessionViewDisplayOptions } from '../session_view_display_options'; import { useStyles } from './styles'; -import { useFetchSessionViewProcessEvents } from './hooks'; +import { useFetchAlertStatus, useFetchSessionViewProcessEvents } from './hooks'; /** * The main wrapper component for the session view. */ -export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { +export const SessionView = ({ + sessionEntityId, + height, + jumpToEvent, + loadAlertDetails, +}: SessionViewDeps) => { const [isDetailOpen, setIsDetailOpen] = useState(false); const [selectedProcess, setSelectedProcess] = useState(null); - - const styles = useStyles({ height }); - - const onProcessSelected = useCallback((process: Process | null) => { - setSelectedProcess(process); - }, []); - const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [displayOptions, setDisplayOptions] = useState({ timestamp: true, verboseMode: true, }); + const [fetchAlertStatus, setFetchAlertStatus] = useState([]); + const [updatedAlertsStatus, setUpdatedAlertsStatus] = useState({}); + + const styles = useStyles({ height }); + + const onProcessSelected = useCallback((process: Process | null) => { + setSelectedProcess(process); + }, []); const { data, @@ -58,10 +64,25 @@ export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionVie const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; const renderIsLoading = isFetching && !data; const renderDetails = isDetailOpen && selectedProcess; + const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( + updatedAlertsStatus, + fetchAlertStatus[0] ?? '' + ); + + useEffect(() => { + if (fetchAlertStatus) { + setUpdatedAlertsStatus({ ...newUpdatedAlertsStatus }); + } + }, [fetchAlertStatus, newUpdatedAlertsStatus]); + + const handleOnAlertDetailsClosed = useCallback((alertUuid: string) => { + setFetchAlertStatus([alertUuid]); + }, []); const toggleDetailPanel = useCallback(() => { setIsDetailOpen(!isDetailOpen); }, [isDetailOpen]); + const handleOptionChange = useCallback((checkedOptions: DisplayOptionsState) => { setDisplayOptions(checkedOptions); }, []); @@ -182,6 +203,9 @@ export const SessionView = ({ sessionEntityId, height, jumpToEvent }: SessionVie fetchNextPage={fetchNextPage} fetchPreviousPage={fetchPreviousPage} setSearchResults={setSearchResults} + updatedAlertsStatus={updatedAlertsStatus} + loadAlertDetails={loadAlertDetails} + handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} timeStampOn={displayOptions.timestamp} verboseModeOn={displayOptions.verboseMode} /> diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx index 1eecdcbb3e50e..3654e296e7412 100644 --- a/x-pack/plugins/session_view/public/methods/index.tsx +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -15,15 +15,11 @@ const queryClient = new QueryClient(); const SessionViewLazy = lazy(() => import('../components/session_view')); -export const getSessionViewLazy = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { +export const getSessionViewLazy = (props: SessionViewDeps) => { return ( }> - + ); diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts index d84623af7c0ed..3a7ef376bd426 100644 --- a/x-pack/plugins/session_view/public/types.ts +++ b/x-pack/plugins/session_view/public/types.ts @@ -20,6 +20,12 @@ export interface SessionViewDeps { // if provided, the session view will jump to and select the provided event if it belongs to the session leader // session view will fetch a page worth of events starting from jumpToEvent as well as a page backwards. jumpToEvent?: ProcessEvent; + // Callback to open the alerts flyout + loadAlertDetails?: ( + alertUuid: string, + // Callback used when alert flyout panel is closed + handleOnAlertDetailsClosed: () => void + ) => void; } export interface EuiTabProps { diff --git a/x-pack/plugins/session_view/server/routes/alert_status_route.ts b/x-pack/plugins/session_view/server/routes/alert_status_route.ts new file mode 100644 index 0000000000000..70ce32ee72020 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alert_status_route.ts @@ -0,0 +1,56 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { ElasticsearchClient } from 'kibana/server'; +import { IRouter } from '../../../../../src/core/server'; +import { ALERT_STATUS_ROUTE, ALERTS_INDEX, ALERT_UUID_PROPERTY } from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; + +export const registerAlertStatusRoute = (router: IRouter) => { + router.get( + { + path: ALERT_STATUS_ROUTE, + validate: { + query: schema.object({ + alertUuid: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.elasticsearch.client.asCurrentUser; + const { alertUuid } = request.query; + const body = await searchAlertByUuid(client, alertUuid); + + return response.ok({ body }); + } + ); +}; + +export const searchAlertByUuid = async (client: ElasticsearchClient, alertUuid: string) => { + const search = await client.search({ + index: [ALERTS_INDEX], + ignore_unavailable: true, // on a new installation the .siem-signals-default index might not be created yet. + body: { + query: { + match: { + [ALERT_UUID_PROPERTY]: alertUuid, + }, + }, + size: 1, + }, + }); + + const events = search.hits.hits.map((hit: any) => { + // TODO: re-eval if this is needed after updated ECS mappings are applied. + // the .siem-signals-default index flattens many properties. this util unflattens them. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + return { events }; +}; diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts index 7b9cfb45f580b..b8cb80dc1d1d4 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -6,9 +6,11 @@ */ import { IRouter } from '../../../../../src/core/server'; import { registerProcessEventsRoute } from './process_events_route'; +import { registerAlertStatusRoute } from './alert_status_route'; import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; export const registerRoutes = (router: IRouter) => { registerProcessEventsRoute(router); + registerAlertStatusRoute(router); sessionEntryLeadersRoute(router); }; From 03ce76bb70dcc916f4ccffa017f6e06dc0a10549 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 22 Mar 2022 16:09:27 -0500 Subject: [PATCH 055/132] [ci] Add artifact build pipeline (#128311) * [ci] Add artifact build pipeline * create dependencies report --- .buildkite/pipelines/artifacts.yml | 6 ++++++ .buildkite/scripts/steps/artifacts/build.sh | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 .buildkite/pipelines/artifacts.yml create mode 100644 .buildkite/scripts/steps/artifacts/build.sh diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml new file mode 100644 index 0000000000000..9ec56ff44c63e --- /dev/null +++ b/.buildkite/pipelines/artifacts.yml @@ -0,0 +1,6 @@ +steps: + - command: .buildkite/scripts/steps/artifacts/build.sh + label: Build Kibana Artifacts + agents: + queue: c2-16 + timeout_in_minutes: 60 diff --git a/.buildkite/scripts/steps/artifacts/build.sh b/.buildkite/scripts/steps/artifacts/build.sh new file mode 100644 index 0000000000000..211bfddecd010 --- /dev/null +++ b/.buildkite/scripts/steps/artifacts/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +.buildkite/scripts/bootstrap.sh + +echo "--- Build Kibana Distribution" +node scripts/build --all-platforms --debug --skip-docker-cloud + +echo "--- Build dependencies report" +node scripts/licenses_csv_report --csv=target/dependencies_report.csv From 4cd7f879a8d6aa2bcb581ea802bf5fd2c625a2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Tue, 22 Mar 2022 22:09:55 +0100 Subject: [PATCH 056/132] Bind create engine button to the backend (#128253) --- .../__mocks__/engine_creation_logic.mock.ts | 87 +++++++++ .../engine_creation_logic.test.ts | 180 ++++++++---------- .../engine_creation/engine_creation_logic.ts | 19 +- .../server/routes/app_search/engines.test.ts | 62 ++++++ .../server/routes/app_search/engines.ts | 18 ++ 5 files changed, 258 insertions(+), 108 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_creation_logic.mock.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_creation_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_creation_logic.mock.ts new file mode 100644 index 0000000000000..b78b936de127b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/__mocks__/engine_creation_logic.mock.ts @@ -0,0 +1,87 @@ +/* + * 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 { SearchIndexSelectableOption } from '../components/engine_creation/search_index_selectable'; + +export const DEFAULT_VALUES = { + ingestionMethod: '', + isLoading: false, + name: '', + rawName: '', + language: 'Universal', + isLoadingIndices: false, + indices: [], + indicesFormatted: [], + selectedIndex: '', + engineType: 'appSearch', + isSubmitDisabled: true, +}; + +export const mockElasticsearchIndices = [ + { + health: 'yellow', + status: 'open', + name: 'search-my-index-1', + uuid: 'ydlR_QQJTeyZP66tzQSmMQ', + total: { + docs: { + count: 0, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + }, + { + health: 'green', + status: 'open', + name: 'search-my-index-2', + uuid: '4dlR_QQJTe2ZP6qtzQSmMQ', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + aliases: ['search-index-123'], + }, +]; + +export const mockSearchIndexOptions: SearchIndexSelectableOption[] = [ + { + label: 'search-my-index-1', + health: 'yellow', + status: 'open', + total: { + docs: { + count: 0, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + }, + { + label: 'search-my-index-2', + health: 'green', + status: 'open', + total: { + docs: { + count: 100, + deleted: 0, + }, + store: { + size_in_bytes: '225b', + }, + }, + }, +]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts index 2232d471893b6..ec60bf5ae8a8e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts @@ -12,10 +12,15 @@ import { mockFlashMessageHelpers, } from '../../../__mocks__/kea_logic'; +import { + DEFAULT_VALUES, + mockElasticsearchIndices, + mockSearchIndexOptions, +} from '../../__mocks__/engine_creation_logic.mock'; + import { nextTick } from '@kbn/test-jest-helpers'; import { EngineCreationLogic } from './engine_creation_logic'; -import { SearchIndexSelectableOption } from './search_index_selectable'; describe('EngineCreationLogic', () => { const { mount } = new LogicMounter(EngineCreationLogic); @@ -23,85 +28,6 @@ describe('EngineCreationLogic', () => { const { navigateToUrl } = mockKibanaValues; const { flashSuccessToast, flashAPIErrors } = mockFlashMessageHelpers; - const DEFAULT_VALUES = { - ingestionMethod: '', - isLoading: false, - name: '', - rawName: '', - language: 'Universal', - isLoadingIndices: false, - indices: [], - indicesFormatted: [], - selectedIndex: '', - engineType: 'appSearch', - isSubmitDisabled: true, - }; - - const mockElasticsearchIndices = [ - { - health: 'yellow', - status: 'open', - name: 'search-my-index-1', - uuid: 'ydlR_QQJTeyZP66tzQSmMQ', - total: { - docs: { - count: 0, - deleted: 0, - }, - store: { - size_in_bytes: '225b', - }, - }, - }, - { - health: 'green', - status: 'open', - name: 'search-my-index-2', - uuid: '4dlR_QQJTe2ZP6qtzQSmMQ', - total: { - docs: { - count: 100, - deleted: 0, - }, - store: { - size_in_bytes: '225b', - }, - }, - aliases: ['search-index-123'], - }, - ]; - - const mockSearchIndexOptions: SearchIndexSelectableOption[] = [ - { - label: 'search-my-index-1', - health: 'yellow', - status: 'open', - total: { - docs: { - count: 0, - deleted: 0, - }, - store: { - size_in_bytes: '225b', - }, - }, - }, - { - label: 'search-my-index-2', - health: 'green', - status: 'open', - total: { - docs: { - count: 100, - deleted: 0, - }, - store: { - size_in_bytes: '225b', - }, - }, - }, - ]; - it('has expected default values', () => { mount(); expect(EngineCreationLogic.values).toEqual(DEFAULT_VALUES); @@ -333,36 +259,82 @@ describe('EngineCreationLogic', () => { }); describe('submitEngine', () => { - beforeAll(() => { - mount({ language: 'English', rawName: 'test' }); - }); + describe('Indexed engine', () => { + beforeAll(() => { + mount({ language: 'English', rawName: 'test' }); + }); - afterAll(() => { - jest.clearAllMocks(); - }); + afterAll(() => { + jest.clearAllMocks(); + }); - it('POSTS to /internal/app_search/engines', () => { - const body = JSON.stringify({ - name: EngineCreationLogic.values.name, - language: EngineCreationLogic.values.language, + it('POSTS to /internal/app_search/engines', () => { + const body = JSON.stringify({ + name: EngineCreationLogic.values.name, + language: EngineCreationLogic.values.language, + }); + EngineCreationLogic.actions.submitEngine(); + expect(http.post).toHaveBeenCalledWith('/internal/app_search/engines', { body }); }); - EngineCreationLogic.actions.submitEngine(); - expect(http.post).toHaveBeenCalledWith('/internal/app_search/engines', { body }); - }); - it('calls onEngineCreationSuccess on valid submission', async () => { - jest.spyOn(EngineCreationLogic.actions, 'onEngineCreationSuccess'); - http.post.mockReturnValueOnce(Promise.resolve({})); - EngineCreationLogic.actions.submitEngine(); - await nextTick(); - expect(EngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1); + it('calls onEngineCreationSuccess on valid submission', async () => { + jest.spyOn(EngineCreationLogic.actions, 'onEngineCreationSuccess'); + http.post.mockReturnValueOnce(Promise.resolve({})); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(EngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1); + }); + + it('calls flashAPIErrors on API Error', async () => { + http.post.mockReturnValueOnce(Promise.reject()); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); }); - it('calls flashAPIErrors on API Error', async () => { - http.post.mockReturnValueOnce(Promise.reject()); - EngineCreationLogic.actions.submitEngine(); - await nextTick(); - expect(flashAPIErrors).toHaveBeenCalledTimes(1); + describe('Elasticsearch index based engine', () => { + beforeEach(() => { + mount({ + engineType: 'elasticsearch', + name: 'engine-name', + selectedIndex: 'search-selected-index', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('POSTS to /internal/app_search/elasticsearch/engines', () => { + const body = JSON.stringify({ + name: EngineCreationLogic.values.name, + search_index: { + type: 'elasticsearch', + index_name: EngineCreationLogic.values.selectedIndex, + }, + }); + EngineCreationLogic.actions.submitEngine(); + + expect(http.post).toHaveBeenCalledWith('/internal/app_search/elasticsearch/engines', { + body, + }); + }); + + it('calls onEngineCreationSuccess on valid submission', async () => { + jest.spyOn(EngineCreationLogic.actions, 'onEngineCreationSuccess'); + http.post.mockReturnValueOnce(Promise.resolve({})); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(EngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1); + }); + + it('calls flashAPIErrors on API Error', async () => { + http.post.mockReturnValueOnce(Promise.reject()); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts index f972993b32ca4..2bc7f1f977129 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts @@ -130,12 +130,23 @@ export const EngineCreationLogic = kea ({ submitEngine: async () => { const { http } = HttpLogic.values; - const { name, language } = values; - - const body = JSON.stringify({ name, language }); + const { name, language, engineType, selectedIndex } = values; try { - await http.post('/internal/app_search/engines', { body }); + if (engineType === 'appSearch') { + const body = JSON.stringify({ name, language }); + + await http.post('/internal/app_search/engines', { body }); + } else { + const body = JSON.stringify({ + name, + search_index: { + type: 'elasticsearch', + index_name: selectedIndex, + }, + }); + await http.post('/internal/app_search/elasticsearch/engines', { body }); + } actions.onEngineCreationSuccess(); } catch (e) { flashAPIErrors(e); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index 7c8a611cebb3e..cd1221ec52b80 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -176,6 +176,68 @@ describe('engine routes', () => { }); }); + describe('POST /internal/app_search/elasticsearch/engines', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/internal/app_search/elasticsearch/engines', + }); + + registerEnginesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({ + body: { + name: 'some-elasticindexed-engine', + search_index: { type: 'elasticsearch', index_name: 'search-elastic-index' }, + }, + }); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines', + }); + }); + + describe('validates', () => { + describe('indexed engines', () => { + it('correctly', () => { + const request = { + body: { + name: 'some-engine', + search_index: { type: 'elasticsearch', index_name: 'search-elastic-index' }, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing name', () => { + const request = { + body: { + search_index: { type: 'elasticsearch', index_name: 'search-elastic-index' }, + }, + }; + mockRouter.shouldThrow(request); + }); + + it('missing index_name', () => { + const request = { + name: 'some-engine', + body: { + search_index: { type: 'elasticsearch' }, + }, + }; + mockRouter.shouldThrow(request); + }); + }); + }); + }); + describe('GET /internal/app_search/engines/{name}', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index a53379ef44c67..99314d6da8112 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -55,6 +55,24 @@ export function registerEnginesRoutes({ }) ); + router.post( + { + path: '/internal/app_search/elasticsearch/engines', + validate: { + body: schema.object({ + name: schema.string(), + search_index: schema.object({ + type: schema.string(), + index_name: schema.string(), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines', + }) + ); + // Single engine endpoints router.get( { From a28b25a9b9d14110ba91e467c4efacd26aec0a23 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 22 Mar 2022 14:55:37 -0700 Subject: [PATCH 057/132] [reporting/upgrade/tests] rewrite waitForJobToFinish to handle errors (#128309) --- .../lib/config/schema.ts | 5 ++- .../apps/reporting/reporting_smoke_tests.ts | 8 ++-- x-pack/test/upgrade/reporting_services.ts | 39 ++++++++++--------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index cf1afbb810c71..17c3af046f92f 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -114,6 +114,7 @@ export const schema = Joi.object() try: Joi.number().default(120000), waitFor: Joi.number().default(20000), esRequestTimeout: Joi.number().default(30000), + kibanaReportCompletion: Joi.number().default(60_000), kibanaStabilize: Joi.number().default(15000), navigateStatusPageCheck: Joi.number().default(250), @@ -166,7 +167,9 @@ export const schema = Joi.object() mochaReporter: Joi.object() .keys({ - captureLogOutput: Joi.boolean().default(!!process.env.CI), + captureLogOutput: Joi.boolean().default( + !!process.env.CI && !process.env.DISABLE_CI_LOG_OUTPUT_CAPTURE + ), sendToCiStats: Joi.boolean().default(!!process.env.CI), }) .default(), diff --git a/x-pack/test/upgrade/apps/reporting/reporting_smoke_tests.ts b/x-pack/test/upgrade/apps/reporting/reporting_smoke_tests.ts index e7769f2761f3f..14136b23abfd5 100644 --- a/x-pack/test/upgrade/apps/reporting/reporting_smoke_tests.ts +++ b/x-pack/test/upgrade/apps/reporting/reporting_smoke_tests.ts @@ -89,11 +89,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const postUrl = await find.byXPath(`//button[descendant::*[text()='Copy POST URL']]`); await postUrl.click(); const url = await browser.getClipboardValue(); - await reportingAPI.expectAllJobsToFinishSuccessfully( - await Promise.all([ - reportingAPI.postJob(parse(url).pathname + '?' + parse(url).query), - ]) - ); + await reportingAPI.expectAllJobsToFinishSuccessfully([ + await reportingAPI.postJob(parse(url).pathname + '?' + parse(url).query), + ]); usage = (await usageAPI.getUsageStats()) as UsageStats; reportingAPI.expectCompletedReportCount(usage, completedReportCount + 1); }); diff --git a/x-pack/test/upgrade/reporting_services.ts b/x-pack/test/upgrade/reporting_services.ts index 13186cb9b2a75..2de3b72bc9a47 100644 --- a/x-pack/test/upgrade/reporting_services.ts +++ b/x-pack/test/upgrade/reporting_services.ts @@ -47,33 +47,34 @@ export function ReportingAPIProvider({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esSupertest = getService('esSupertest'); const retry = getService('retry'); + const config = getService('config'); return { - async waitForJobToFinish(downloadReportPath: string) { - log.debug(`Waiting for job to finish: ${downloadReportPath}`); - const JOB_IS_PENDING_CODE = 503; - - const statusCode = await new Promise((resolve) => { - const intervalId = setInterval(async () => { - const response = (await supertest + async waitForJobToFinish(downloadReportPath: string, options?: { timeout?: number }) { + await retry.waitForWithTimeout( + `job ${downloadReportPath} finished`, + options?.timeout ?? config.get('timeouts.kibanaReportCompletion'), + async () => { + const response = await supertest .get(downloadReportPath) .responseType('blob') - .set('kbn-xsrf', 'xxx')) as any; - if (response.statusCode === 503) { + .set('kbn-xsrf', 'xxx'); + + if (response.status === 503) { log.debug(`Report at path ${downloadReportPath} is pending`); - } else if (response.statusCode === 200) { - log.debug(`Report at path ${downloadReportPath} is complete`); - } else { - log.debug(`Report at path ${downloadReportPath} returned code ${response.statusCode}`); + return false; } - if (response.statusCode !== JOB_IS_PENDING_CODE) { - clearInterval(intervalId); - resolve(response.statusCode); + + log.debug(`Report at path ${downloadReportPath} returned code ${response.status}`); + + if (response.status === 200) { + log.debug(`Report at path ${downloadReportPath} is complete`); + return true; } - }, 1500); - }); - expect(statusCode).to.be(200); + throw new Error(`unexpected status code ${response.status}`); + } + ); }, async expectAllJobsToFinishSuccessfully(jobPaths: string[]) { From 2e33dd4084951c6d692b7933387cbebda607b5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Tue, 22 Mar 2022 18:05:31 -0400 Subject: [PATCH 058/132] updates CTI card table title size (#128273) Co-authored-by: Ece Ozalp --- .../components/overview_cti_links/threat_intel_panel_view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx index 0f80185750261..2709c193caffd 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx @@ -25,7 +25,7 @@ const columns: Array> = [ render: shortenCountIntoString, sortable: true, truncateText: true, - width: '20%', + width: '70px', align: 'right', }, { From ae91c5bb7d5322dc72ce7df2e9768618c05ef3c0 Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 22 Mar 2022 15:28:49 -0700 Subject: [PATCH 059/132] [type-summarizer] enable @kbn/analytics, @kbn/apm-config-loader and @kbn/apm-utils (#128206) --- packages/kbn-analytics/BUILD.bazel | 3 +++ packages/kbn-analytics/tsconfig.json | 1 + packages/kbn-apm-config-loader/BUILD.bazel | 1 + packages/kbn-apm-config-loader/tsconfig.json | 1 + packages/kbn-apm-utils/BUILD.bazel | 1 + packages/kbn-apm-utils/tsconfig.json | 1 + packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts | 3 +++ 7 files changed, 11 insertions(+) diff --git a/packages/kbn-analytics/BUILD.bazel b/packages/kbn-analytics/BUILD.bazel index d144ab186a6a1..e2cc4b1f58f24 100644 --- a/packages/kbn-analytics/BUILD.bazel +++ b/packages/kbn-analytics/BUILD.bazel @@ -23,11 +23,13 @@ NPM_MODULE_EXTRA_FILES = [ ] RUNTIME_DEPS = [ + "@npm//moment", "@npm//moment-timezone", "@npm//tslib", ] TYPES_DEPS = [ + "@npm//moment", "@npm//@types/moment-timezone", "@npm//@types/node", ] @@ -70,6 +72,7 @@ ts_project( srcs = SRCS, deps = TYPES_DEPS, declaration = True, + declaration_map = True, emit_declaration_only = True, out_dir = "target_types", root_dir = "src", diff --git a/packages/kbn-analytics/tsconfig.json b/packages/kbn-analytics/tsconfig.json index de4301e2a2ac0..afdacfb1d1ae8 100644 --- a/packages/kbn-analytics/tsconfig.json +++ b/packages/kbn-analytics/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.bazel.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "emitDeclarationOnly": true, "isolatedModules": true, "outDir": "./target_types", diff --git a/packages/kbn-apm-config-loader/BUILD.bazel b/packages/kbn-apm-config-loader/BUILD.bazel index bcdbefb132aa6..e5542391a3c37 100644 --- a/packages/kbn-apm-config-loader/BUILD.bazel +++ b/packages/kbn-apm-config-loader/BUILD.bazel @@ -65,6 +65,7 @@ ts_project( srcs = SRCS, deps = TYPES_DEPS, declaration = True, + declaration_map = True, emit_declaration_only = True, out_dir = "target_types", root_dir = "src", diff --git a/packages/kbn-apm-config-loader/tsconfig.json b/packages/kbn-apm-config-loader/tsconfig.json index 7d2597d318b31..35e9c12eb90ea 100644 --- a/packages/kbn-apm-config-loader/tsconfig.json +++ b/packages/kbn-apm-config-loader/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.bazel.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "emitDeclarationOnly": true, "outDir": "./target_types", "rootDir": "./src", diff --git a/packages/kbn-apm-utils/BUILD.bazel b/packages/kbn-apm-utils/BUILD.bazel index 9ca9009bb7186..a2505e0556b9b 100644 --- a/packages/kbn-apm-utils/BUILD.bazel +++ b/packages/kbn-apm-utils/BUILD.bazel @@ -50,6 +50,7 @@ ts_project( srcs = SRCS, deps = TYPES_DEPS, declaration = True, + declaration_map = True, emit_declaration_only = True, out_dir = "target_types", root_dir = "src", diff --git a/packages/kbn-apm-utils/tsconfig.json b/packages/kbn-apm-utils/tsconfig.json index 9c8c443436ce5..f4c8a0de0e603 100644 --- a/packages/kbn-apm-utils/tsconfig.json +++ b/packages/kbn-apm-utils/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.bazel.json", "compilerOptions": { "declaration": true, + "declarationMap": true, "emitDeclarationOnly": true, "outDir": "target_types", "rootDir": "src", diff --git a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts index 160e33174d9f7..6f02160a1cb3f 100644 --- a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts +++ b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts @@ -19,6 +19,9 @@ const TYPE_SUMMARIZER_PACKAGES = [ '@kbn/mapbox-gl', '@kbn/ace', '@kbn/alerts', + '@kbn/analytics', + '@kbn/apm-config-loader', + '@kbn/apm-utils', ]; type TypeSummarizerType = 'api-extractor' | 'type-summarizer'; From 88d64dc3bb77861ef6c6f0db08f0a2dd72f1c6ca Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 22 Mar 2022 18:56:41 -0400 Subject: [PATCH 060/132] Add "phase 2" dev docs for sharing saved objects (#128037) --- .../sharing-saved-objects-dev-flowchart.png | Bin 176397 -> 0 bytes ...ng-saved-objects-phase-1-dev-flowchart.png | Bin 0 -> 175119 bytes ...ng-saved-objects-phase-2-dev-flowchart.png | Bin 0 -> 142909 bytes .../images/sharing-saved-objects-step-6.png | Bin 0 -> 77857 bytes .../images/sharing-saved-objects-step-7.png | Bin 0 -> 28445 bytes .../advanced/sharing-saved-objects.asciidoc | 180 +++++++++++++++--- .../core/saved-objects-service.asciidoc | 1 + 7 files changed, 158 insertions(+), 23 deletions(-) delete mode 100644 docs/developer/advanced/images/sharing-saved-objects-dev-flowchart.png create mode 100644 docs/developer/advanced/images/sharing-saved-objects-phase-1-dev-flowchart.png create mode 100644 docs/developer/advanced/images/sharing-saved-objects-phase-2-dev-flowchart.png create mode 100644 docs/developer/advanced/images/sharing-saved-objects-step-6.png create mode 100644 docs/developer/advanced/images/sharing-saved-objects-step-7.png diff --git a/docs/developer/advanced/images/sharing-saved-objects-dev-flowchart.png b/docs/developer/advanced/images/sharing-saved-objects-dev-flowchart.png deleted file mode 100644 index bc829059988db1fc4393b94430e272fc777a8e8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176397 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

)@J;N6BrJ#6j?PitHL= zsL`5DbZX)!siIJZbl^Ksv(2%CtuJ%?i|9x40NJqWnY>)nVw>)Av1!vYpIX%INA9_@ zxy*(yTV4@=>dPxzcOF0|t$OV_7vxWhgC3t(kOzvX5=ZuG-9Vyfa1^#3u6in=Xl!t0 z0yEkGpJFtfF9`b9V%XturG~@Y+%jgZ(RLn7XRS@s>5s<+{g22i@th9>KSwQ#&C92* z6??&gBJgH1Zl7BN?{+f(vkQ8JNO}g!5(kg|-pCwp?`Um|NE0Nw$68n`2L{H6~XrpN*Dc9P5mI$g)k} zV@SBLx?eb;wXya{WwU_|^PeF;B>i%pj*HF(*Yh=@>*4Ribe+W_A3rt9CD5;$>cI{F z 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�ULH6h< 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�H}sr@FA#7#f_@i4kjPx~v-d2f60`qgN$>L) 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

xbJ?oGfm& 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 GJorCOcHmI} literal 0 HcmV?d00001 diff --git a/docs/developer/advanced/images/sharing-saved-objects-step-6.png b/docs/developer/advanced/images/sharing-saved-objects-step-6.png new file mode 100644 index 0000000000000000000000000000000000000000..b0fe40d926e27db7af7f53dc44a4a19f606f69b1 GIT binary patch literal 77857 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 M5S0}v7XtbGAMT&3761SM literal 0 HcmV?d00001 diff --git a/docs/developer/advanced/sharing-saved-objects.asciidoc b/docs/developer/advanced/sharing-saved-objects.asciidoc index 5dd93adf19123..59bab55724089 100644 --- a/docs/developer/advanced/sharing-saved-objects.asciidoc +++ b/docs/developer/advanced/sharing-saved-objects.asciidoc @@ -1,8 +1,8 @@ [[sharing-saved-objects]] -== Sharing Saved Objects +== Sharing saved objects -This guide describes the Sharing Saved Objects effort, and the breaking changes that plugin developers need to be aware of for the planned -8.0 release of {kib}. +This guide describes the "Sharing saved objects" effort, and the breaking changes that plugin developers need to be aware of for the planned +8.0 release of {kib}. It also describes how developers can take advantage of this feature. [[sharing-saved-objects-overview]] === Overview @@ -28,6 +28,12 @@ Ideally, most types of objects in {kib} will eventually be _shareable_; however, <> as a stepping stone for plugin developers to fully support this feature. +Implementing a shareable saved object type is done in two phases: + +- **Phase 1**: Convert an existing isolated object type into a share-capable one. Keep reading! +- **Phase 2**: Switch an existing share-capable object type into a shareable one, _or_ create a new shareable object type. Jump to the + <>! + [[sharing-saved-objects-breaking-changes]] === Breaking changes @@ -49,21 +55,21 @@ change the IDs of any existing objects that are not in the Default space. Changi TIP: External plugins can also convert their objects, but <>. -[[sharing-saved-objects-dev-flowchart]] -=== Developer Flowchart +[[sharing-saved-objects-phase-1]] +=== Phase 1 developer flowchart If you're still reading this page, you're probably developing a {kib} plugin that registers an object type, and you want to know what steps you need to take to prepare for the 8.0 release and mitigate any breaking changes! Depending on how you are using saved objects, you may need to take up to 5 steps, which are detailed in separate sections below. Refer to this flowchart: -image::images/sharing-saved-objects-dev-flowchart.png["Sharing Saved Objects developer flowchart"] +image::images/sharing-saved-objects-phase-1-dev-flowchart.png["Sharing Saved Objects phase 1 - developer flowchart"] TIP: There is a proof-of-concept (POC) pull request to demonstrate these changes. It first adds a simple test plugin that allows users to create and view notes. Then, it goes through the steps of the flowchart to convert the isolated "note" objects to become share-capable. As you read this guide, you can https://github.com/elastic/kibana/pull/107256[follow along in the POC] to see exactly how to take these steps. [[sharing-saved-objects-q1]] -=== Question 1 +==== Question 1 > *Do these objects contain links to other objects?* @@ -71,7 +77,7 @@ If your objects store _any_ links to other objects (with an object type/ID), you continue functioning after the 8.0 upgrade. [[sharing-saved-objects-step-1]] -=== Step 1 +==== Step 1 ⚠️ This step *must* be completed no later than the 7.16 release. ⚠️ @@ -117,7 +123,7 @@ migrations: { NOTE: Reminder, don't forget to add unit tests and integration tests! [[sharing-saved-objects-q2]] -=== Question 2 +==== Question 2 > *Are there any "deep links" to these objects?* @@ -130,7 +136,7 @@ Note that some URLs may contain <>! [[sharing-saved-objects-step-3]] -=== Step 3 +==== Step 3 ⚠️ This step will preferably be completed in the 7.16 release; it *must* be completed no later than the 8.0 release. ⚠️ @@ -206,7 +212,7 @@ TIP: See an example of this in https://github.com/elastic/kibana/pull/107256#use ] ``` -3. Update your Plugin class implementation to depend on the Core HTTP service and Spaces plugin API: +3. Update your Plugin class implementation to depend on the Spaces plugin API: + ```ts interface PluginStartDeps { @@ -218,11 +224,10 @@ export class MyPlugin implements Plugin<{}, {}, {}, PluginStartDeps> { core.application.register({ ... async mount(appMountParams: AppMountParameters) { - const [coreStart, pluginStartDeps] = await core.getStartServices(); - const { http } = coreStart; + const [, pluginStartDeps] = await core.getStartServices(); const { spaces: spacesApi } = pluginStartDeps; ... - // pass `http` and `spacesApi` to your app when you render it + // pass `spacesApi` to your app when you render it }, }); ... @@ -247,8 +252,8 @@ if (spacesApi && resolveResult.outcome === 'aliasMatch') { ``` <1> The `aliasPurpose` field is required as of 8.2, because the API response now includes the reason the alias was created to inform the client whether a toast should be shown or not. -<2> The `objectNoun` field is optional, it just changes "object" in the toast to whatever you specify -- you may want the toast to say - "dashboard" or "index pattern" instead! +<2> The `objectNoun` field is optional. It just changes "object" in the toast to whatever you specify -- you may want the toast to say + "dashboard" or "data view" instead. 5. And finally, in your deep link page, add a function that will create a callout in the case of a `'conflict'` outcome: + @@ -293,7 +298,7 @@ different outcomes.] NOTE: Reminder, don't forget to add unit tests and functional tests! [[sharing-saved-objects-step-4]] -=== Step 4 +==== Step 4 ⚠️ This step *must* be completed in the 8.0 release (no earlier and no later). ⚠️ @@ -315,7 +320,7 @@ TIP: See an example of this in https://github.com/elastic/kibana/pull/107256#use NOTE: Reminder, don't forget to add integration tests! [[sharing-saved-objects-q3]] -=== Question 3 +==== Question 3 > *Are these objects encrypted?* @@ -323,7 +328,7 @@ Saved objects can optionally be < object types are encrypted, so most plugin developers will not be affected. [[sharing-saved-objects-step-5]] -=== Step 5 +==== Step 5 ⚠️ This step *must* be completed in the 8.0 release (no earlier and no later). ⚠️ @@ -341,12 +346,141 @@ image::images/sharing-saved-objects-step-5.png["Sharing Saved Objects ESO migrat NOTE: Reminder, don't forget to add unit tests and integration tests! +[[sharing-saved-objects-phase-2]] +=== Phase 2 developer flowchart + +This section covers switching a share-capable object type into a shareable one _or_ creating a new shareable saved object type. Refer to +this flowchart: + +image::images/sharing-saved-objects-phase-2-dev-flowchart.png["Sharing Saved Objects phase 2 - developer flowchart"] + [[sharing-saved-objects-step-6]] -=== Step 6 +==== Step 6 + +> *Update your _server-side code_ to mark these objects as "shareable"* + +When you register your object, you need to set the proper `namespaceType`. If you have an existing object type that is "share-capable", you +can simply change it: + +image::images/sharing-saved-objects-step-6.png["Sharing Saved Objects registration (shareable)"] + +[[sharing-saved-objects-step-7]] +==== Step 7 + +> *Update saved object delete API usage to handle multiple spaces* + +If an object is shared to multiple spaces, it cannot be deleted without using the +https://github.com/elastic/kibana/blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md[`force` +delete option]. You should always be aware when a saved object exists in multiple spaces, and you should warn users in that case. + +If your UI allows users to delete your objects, you can define a warning message like this: + +```tsx +const { namespaces, id } = savedObject; +const warningMessage = + namespaces.length > 1 || namespaces.includes('*') ? ( + + ) : null; +``` + +The <> in <> uses a +https://github.com/elastic/kibana/blob/{branch}/src/plugins/data_view_management/public/components/edit_index_pattern/edit_index_pattern.tsx[similar +approach] to show a warning in its delete confirmation modal: + +image::images/sharing-saved-objects-step-7.png["Sharing Saved Objects deletion warning"] + +[[sharing-saved-objects-step-8]] +==== Step 8 + +> *Allow users to view and change assigned spaces for your objects* + +Users will need a way to view what spaces your objects are currently assigned to and share them to additional spaces. You can accomplish +this in two ways, and many consumers will want to implement both: + +1. (Highly recommended) Add reusable components to your application, making it "space-aware". The space-related components are exported by + the spaces plugin, and you can use them in your own application. ++ +First, make sure your page contents are wrapped in a +https://github.com/elastic/kibana/blob/{branch}/x-pack/plugins/spaces/public/spaces_context/types.ts[spaces context provider]: ++ +```tsx +const ContextWrapper = useMemo( + () => + spacesApi ? spacesApi.ui.components.getSpacesContextProvider : getEmptyFunctionComponent, + [spacesApi] +); -> *Update your code to make your objects shareable* +... -_This is not required for the 8.0 release; this additional information will be added in the near future!_ +return ( + + + +); +``` ++ +Second, display a https://github.com/elastic/kibana/blob/{branch}/x-pack/plugins/spaces/public/space_list/types.ts[list of spaces] for an +object, and third, show a +https://github.com/elastic/kibana/blob/{branch}/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts[flyout] for the user to +edit the object's assigned spaces. You may want to follow the example of the <> and +https://github.com/elastic/kibana/blob/{branch}/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx[combine +these into a single component] so that the space list can be clicked to show the flyout: ++ +```tsx +const [showFlyout, setShowFlyout] = useState(false); +const LazySpaceList = useCallback(spacesApi.ui.components.getSpaceList, [spacesApi]); +const LazyShareToSpaceFlyout = useCallback(spacesApi.ui.components.getShareToSpaceFlyout, [spacesApi]); + +const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { + savedObjectTarget: { + type: myObject.type, + namespaces: myObject.namespaces, + id: myObject.id, + icon: 'beaker', <1> + title: myObject.attributes.title, <2> + noun: OBJECT_NOUN, <3> + }, + onUpdate: () => { /* callback when the object is updated */ }, + onClose: () => setShowFlyout(false), +}; + +return ( + <> + + listOnClick={() => setShowFlyout(true)} + /> + {showFlyout && } + +); +``` +<1> The `icon` field is optional. It specifies an https://elastic.github.io/eui/#/display/icons[EUI icon] type that will be displayed in the + flyout header. +<2> The `title` field is optional. It specifies a human-readable identifier for your object that will be displayed in the flyout header. +<3> The `noun` field is optional. It just changes "object" in the flyout to whatever you specify -- you may want the flyout to say + "dashboard" or "data view" instead. +<4> The `behaviorContext` field is optional. It controls how the space list is displayed. When using an `"outside-space"` behavior context, + the space list is rendered outside of any particular space, so the active space is included in the list. On the other hand, when using a + `"within-space"` behavior context, the space list is rendered within the active space, so the active space is excluded from the list. + +2. Allow users to access your objects in the <> in <>. You can do this by + ensuring that your objects are marked as + https://github.com/elastic/kibana/blob/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectstypemanagementdefinition.md[importable and exportable] in your <>: ++ +```ts +name: 'my-object-type', +management: { + isImportableAndExportable: true, +}, +... +``` +If you do this, then your objects will be visible in the <>, where users can assign +them to multiple spaces. [[sharing-saved-objects-faq]] === Frequently asked questions (FAQ) diff --git a/docs/developer/architecture/core/saved-objects-service.asciidoc b/docs/developer/architecture/core/saved-objects-service.asciidoc index 54a5c319c6222..cc669be8ec9fa 100644 --- a/docs/developer/architecture/core/saved-objects-service.asciidoc +++ b/docs/developer/architecture/core/saved-objects-service.asciidoc @@ -32,6 +32,7 @@ wanting to use Saved Objects. === Server side usage +[[saved-objects-type-registration]] ==== Registering a Saved Object type Saved object type definitions should be defined in their own `my_plugin/server/saved_objects` directory. From 130823ac27126f1eca689a186a088f1993fa848a Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 22 Mar 2022 15:58:57 -0700 Subject: [PATCH 061/132] [DOCS] Add find case APIs (#127686) --- docs/api/cases.asciidoc | 9 +- docs/api/cases/cases-api-find-cases.asciidoc | 193 ++++++++++++++++++ .../cases/cases-api-find-connectors.asciidoc | 60 ++++++ 3 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 docs/api/cases/cases-api-find-cases.asciidoc create mode 100644 docs/api/cases/cases-api-find-connectors.asciidoc diff --git a/docs/api/cases.asciidoc b/docs/api/cases.asciidoc index 00fbedc2d1299..45186a4e7d489 100644 --- a/docs/api/cases.asciidoc +++ b/docs/api/cases.asciidoc @@ -10,9 +10,9 @@ these APIs: * {security-guide}/cases-api-delete-all-comments.html[Delete all comments] * {security-guide}/cases-api-delete-comment.html[Delete comment] * {security-guide}/cases-api-find-alert.html[Find all alerts attached to a case] -* {security-guide}/cases-api-find-cases.html[Find cases] +* <> * {security-guide}/cases-api-find-cases-by-alert.html[Find cases by alert] -* {security-guide}/cases-api-find-connectors.html[Find connectors] +* <> * {security-guide}/cases-api-get-case-activity.html[Get all case activity] * {security-guide}/cases-api-get-all-case-comments.html[Get all case comments] * {security-guide}/cases-api-get-case.html[Get case] @@ -27,5 +27,10 @@ these APIs: * <> * {security-guide}/cases-api-update-comment.html[Update comment] +//CREATE include::cases/cases-api-create.asciidoc[leveloffset=+1] +//FIND +include::cases/cases-api-find-cases.asciidoc[leveloffset=+1] +include::cases/cases-api-find-connectors.asciidoc[leveloffset=+1] +//UPDATE include::cases/cases-api-update.asciidoc[leveloffset=+1] \ No newline at end of file diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc new file mode 100644 index 0000000000000..334f45fee526d --- /dev/null +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -0,0 +1,193 @@ +[[cases-api-find-cases]] +== Find cases API +++++ +Find cases +++++ + +Retrieves a paginated subset of cases. + +=== Request + +`GET :/api/cases/_find` + +`GET :/s//api/cases/_find` + +=== Prerequisite + +You must have `read` privileges for the *Cases* feature in the *Management*, +*{observability}*, or *Security* section of the +<>, depending on the +`owner` of the cases you're seeking. + +=== Path parameters + +``:: +(Optional, string) An identifier for the space. If it is not specified, the +default space is used. + +=== Query parameters + +`defaultSearchOperator`:: +(Optional, string) The default operator to use for the `simple_query_string`. +Defaults to `OR`. + +//// +`fields`:: +(Optional, array of strings) The fields in the entity to return in the response. +//// +`owner`:: +(Optional, string or array of strings) A filter to limit the retrieved cases to +a specific set of applications. Valid values are: `cases`, `observability`, +and `securitySolution`. If this parameter is omitted, the response contains all +cases that the user has access to read. + +`page`:: +(Optional, integer) The page number to return. Defaults to `1`. + +`perPage`:: +(Optional, integer) The number of rules to return per page. Defaults to `20`. + +`reporters`:: +(Optional, string or array of strings) Filters the returned cases by the +reporter's `username`. + +`search`:: +(Optional, string) An {es} +{ref}/query-dsl-simple-query-string-query.html[simple_query_string] query that +filters the objects in the response. + +`searchFields`:: +(Optional, string or array of strings) The fields to perform the +`simple_query_string` parsed query against. + +`sortField`:: +(Optional, string) Determines which field is used to sort the results, +`createdAt` or `updatedAt`. Defaults to `createdAt`. ++ +NOTE: Even though the JSON case object uses `created_at` and `updated_at` +fields, you must use `createdAt` and `updatedAt` fields in the URL +query. + +`sortOrder`:: +(Optional, string) Determines the sort order, which can be `desc` or `asc`. +Defaults to `desc`. + +`status`:: +(Optional, string) Filters the returned cases by state, which can be `open`, +`in-progress`, or `closed`. + +`tags`:: +(Optional, string or array of strings) Filters the returned cases by tags. + +=== Response code + +`200`:: + Indicates a successful call. + +=== Example + +Retrieve the first five cases with the `phishing` tag, in ascending order by +last update time: + +[source,sh] +-------------------------------------------------- +GET api/cases/_find?page=1&perPage=5&sortField=updatedAt&sortOrder=asc&tags=phishing +-------------------------------------------------- +// KIBANA + +The API returns a JSON object listing the retrieved cases. For example: + +[source,json] +-------------------------------------------------- +{ + "page": 1, + "per_page": 5, + "total": 2, + "cases": [ + { + "id": "abed3a70-71bd-11ea-a0b2-c51ea50a58e2", + "version": "WzExMCwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "The Long Game", + "tags": [ + "windows", + "phishing" + ], + "description": "Windows 95", + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution", + "closed_at": null, + "closed_by": null, + "created_at": "2022-03-29T13:03:23.533Z", + "created_by": { + "email": "rhustler@email.com", + "full_name": "Rat Hustler", + "username": "rhustler" + }, + "status": "open", + "updated_at": null, + "updated_by": null, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": null, + } + } + "external_service": null, + }, + { + "id": "a18b38a0-71b0-11ea-a0b2-c51ea50a58e2", + "version": "Wzk4LDFd", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", + "settings": { + "syncAlerts": false + }, + "owner": "cases", + "closed_at": null, + "closed_by": null, + "created_at": "2022-03-29T11:30:02.658Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": "2022-03-29T12:01:50.244Z", + "updated_by": { + "full_name": "Classified", + "email": "classified@hms.oo.gov.uk", + "username": "M" + }, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".resilient", + "fields": { + "issueTypes": [13], + "severityCode": 6, + } + }, + "external_service": null, + } + ], + "count_open_cases": 2, + "count_in_progress_cases":0, + "count_closed_cases": 0 +} +-------------------------------------------------- diff --git a/docs/api/cases/cases-api-find-connectors.asciidoc b/docs/api/cases/cases-api-find-connectors.asciidoc new file mode 100644 index 0000000000000..8643d569c980b --- /dev/null +++ b/docs/api/cases/cases-api-find-connectors.asciidoc @@ -0,0 +1,60 @@ +[[cases-api-find-connectors]] +== Find connectors API +++++ +Find connectors +++++ + +Retrieves information about <>. + +In particular, only the connectors that are supported for use in cases are +returned. Refer to the list of supported external incident management systems in +<>. + +=== Request + +`GET :/api/cases/configure/connectors/_find` + +`GET :/s//api/cases/configure/connectors/_find` + +=== Prerequisite + +You must have `read` privileges for the *Actions and Connectors* feature in the +*Management* section of the +<>. + +=== Path parameters + +``:: +(Optional, string) An identifier for the space. If it is not specified, the +default space is used. + +=== Response code + +`200`:: + Indicates a successful call. + +=== Example + +[source,sh] +-------------------------------------------------- +GET api/cases/configure/connectors/_find +-------------------------------------------------- +// KIBANA + +The API returns a JSON object describing the connectors and their settings: + +[source,json] +-------------------------------------------------- +[{ + "id":"61787f53-4eee-4741-8df6-8fe84fa616f7", + "actionTypeId": ".jira", + "name":"my-Jira", + "isMissingSecrets":false, + "config": { + "apiUrl":"https://elastic.atlassian.net/", + "projectKey":"ES" + }, + "isPreconfigured":false, + "referencedByCount":0 +}] +-------------------------------------------------- \ No newline at end of file From 156ce283a87a652001d800abf070a996eb6e8543 Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Tue, 22 Mar 2022 20:33:10 -0400 Subject: [PATCH 062/132] Unskip spaces a11y tests with a retry (#128204) --- x-pack/test/accessibility/apps/spaces.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/test/accessibility/apps/spaces.ts b/x-pack/test/accessibility/apps/spaces.ts index 78e5dd1f2f2c3..567f958f5f8a4 100644 --- a/x-pack/test/accessibility/apps/spaces.ts +++ b/x-pack/test/accessibility/apps/spaces.ts @@ -18,8 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const toasts = getService('toasts'); - // FLAKY: https://github.com/elastic/kibana/issues/100968 - describe.skip('Kibana spaces page meets a11y validations', () => { + describe('Kibana spaces page meets a11y validations', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); await PageObjects.common.navigateToApp('home'); @@ -98,7 +97,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // test starts with deleting space b so we can get the space selection page instead of logging out in the test it('a11y test for space selection page', async () => { await PageObjects.spaceSelector.confirmDeletingSpace(); - await a11y.testAppSnapshot(); + await retry.try(async () => { + await a11y.testAppSnapshot(); + }); await PageObjects.spaceSelector.clickSpaceCard('default'); }); }); From 829517bb64c6e499ddfd7c1f0c3d5d4f0bcbeb19 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 23 Mar 2022 07:16:58 +0000 Subject: [PATCH 063/132] [ML] Fixing get_filter endpoint (#128238) * [ML] Fixing get_filter endpoint * unskipping tests --- .../ml/server/models/filter/filter_manager.ts | 46 ++++++------------- .../apis/ml/filters/create_filters.ts | 3 +- .../apis/ml/filters/get_filters.ts | 3 +- .../apis/ml/filters/update_filters.ts | 3 +- 4 files changed, 18 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/ml/server/models/filter/filter_manager.ts b/x-pack/plugins/ml/server/models/filter/filter_manager.ts index baad35de6e590..a4e902ff44994 100644 --- a/x-pack/plugins/ml/server/models/filter/filter_manager.ts +++ b/x-pack/plugins/ml/server/models/filter/filter_manager.ts @@ -41,44 +41,28 @@ interface FiltersInUse { [id: string]: FilterUsage; } -// interface PartialDetector { -// detector_description: string; -// custom_rules: DetectorRule[]; -// } - -// interface PartialJob { -// job_id: string; -// analysis_config: { -// detectors: PartialDetector[]; -// }; -// } - export class FilterManager { constructor(private _mlClient: MlClient) {} async getFilter(filterId: string) { try { - const [JOBS, FILTERS] = [0, 1]; - const results = await Promise.all([ - this._mlClient.getJobs(), - this._mlClient.getFilters({ filter_id: filterId }), - ]); - - if (results[FILTERS] && (results[FILTERS] as estypes.MlGetFiltersResponse).filters.length) { - let filtersInUse: FiltersInUse = {}; - if (results[JOBS] && (results[JOBS] as estypes.MlGetJobsResponse).jobs) { - filtersInUse = this.buildFiltersInUse((results[JOBS] as estypes.MlGetJobsResponse).jobs); - } - - const filter = (results[FILTERS] as estypes.MlGetFiltersResponse).filters[0]; - return { - ...filter, - used_by: filtersInUse[filter.filter_id], - item_count: 0, - } as FilterStats; - } else { + const { + filters: [filter], + } = await this._mlClient.getFilters({ filter_id: filterId }); + if (filter === undefined) { + // could be an empty list rather than a 404 if a wildcard was used, + // so throw our own 404 throw Boom.notFound(`Filter with the id "${filterId}" not found`); } + + const { jobs } = await this._mlClient.getJobs(); + const filtersInUse = this.buildFiltersInUse(jobs); + + return { + ...filter, + used_by: filtersInUse[filter.filter_id], + item_count: 0, + } as FilterStats; } catch (error) { throw Boom.badRequest(error); } diff --git a/x-pack/test/api_integration/apis/ml/filters/create_filters.ts b/x-pack/test/api_integration/apis/ml/filters/create_filters.ts index 86b607337dc4a..6eec47456fb51 100644 --- a/x-pack/test/api_integration/apis/ml/filters/create_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/create_filters.ts @@ -92,8 +92,7 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - // Failing: See https://github.com/elastic/kibana/issues/126642 - describe.skip('create_filters', function () { + describe('create_filters', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); }); diff --git a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts index b111a97fdbba9..8d99650f6d509 100644 --- a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts @@ -26,8 +26,7 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - // FLAKY: https://github.com/elastic/kibana/issues/126870 - describe.skip('get_filters', function () { + describe('get_filters', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); for (const filter of validFilters) { diff --git a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts index f943378201dfd..737e2c21cf0f6 100644 --- a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts @@ -31,8 +31,7 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - // FLAKY: https://github.com/elastic/kibana/issues/127678 - describe.skip('update_filters', function () { + describe('update_filters', function () { const updateFilterRequestBody = { description: 'Updated filter #1', removeItems: items, From fb71a2d66e7b1509e547579640f74dab27e5d149 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Wed, 23 Mar 2022 08:19:14 +0100 Subject: [PATCH 064/132] [Osquery] Add live query to alerts (#128142) [Osquery] Add osquery to alerts and timeline --- .../node_details/tabs/osquery/index.tsx | 2 +- .../fixtures/saved_objects/pack.ndjson | 28 ++++ .../fixtures/saved_objects/rule.ndjson | 99 ++++++++++++++ .../integration/superuser/alerts.spec.ts | 65 +++++++++ .../integration/superuser/metrics.spec.ts | 1 - .../integration/superuser/packs.spec.ts | 14 +- .../superuser/saved_queries.spec.ts | 35 ++--- .../osquery/cypress/screens/live_query.ts | 1 + .../osquery/cypress/tasks/integrations.ts | 2 +- .../osquery/cypress/tasks/live_query.ts | 5 +- x-pack/plugins/osquery/cypress/tasks/packs.ts | 2 +- x-pack/plugins/osquery/public/packs/types.ts | 2 +- x-pack/plugins/osquery/public/plugin.ts | 3 +- .../public/shared_components/index.tsx | 1 + .../osquery_action/index.tsx | 128 +++++++++--------- .../use_is_osquery_available.ts | 46 +++++++ .../use_is_osquery_available_simple.test.ts | 55 ++++++++ .../use_is_osquery_available_simple.tsx | 43 ++++++ x-pack/plugins/osquery/public/types.ts | 1 + x-pack/plugins/security_solution/kibana.json | 3 +- .../osquery/osquery_action_item.tsx | 26 ++++ .../components/osquery/osquery_flyout.tsx | 58 ++++++++ .../osquery/osquery_flyout_footer.tsx | 28 ++++ .../osquery/osquery_flyout_header.tsx | 30 ++++ .../components/osquery/translations.ts | 22 +++ .../take_action_dropdown/index.test.tsx | 13 +- .../components/take_action_dropdown/index.tsx | 37 ++++- .../side_panel/event_details/footer.tsx | 15 +- .../plugins/security_solution/public/types.ts | 2 + .../plugins/security_solution/tsconfig.json | 1 + .../test/osquery_cypress/artifact_manager.ts | 9 +- 31 files changed, 671 insertions(+), 106 deletions(-) create mode 100644 x-pack/plugins/osquery/cypress/fixtures/saved_objects/pack.ndjson create mode 100644 x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson create mode 100644 x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts create mode 100644 x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available.ts create mode 100644 x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.test.ts create mode 100644 x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_footer.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_header.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/osquery/translations.ts diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx index 9932903ef9f19..1bd6cfd353140 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/osquery/index.tsx @@ -48,7 +48,7 @@ const TabComponent = (props: TabProps) => { return ( - + ); }, [OsqueryAction, loading, metadata]); diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/pack.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/pack.ndjson new file mode 100644 index 0000000000000..d36a9ccb8cabd --- /dev/null +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/pack.ndjson @@ -0,0 +1,28 @@ +{ + "attributes": { + "created_at": "2022-01-28T09:01:46.147Z", + "created_by": "elastic", + "description": "gfd", + "enabled": true, + "name": "testpack", + "queries": [ + { + "id": "fds", + "interval": 10, + "query": "select * from uptime;" + } + ], + "updated_at": "2022-01-28T09:01:46.147Z", + "updated_by": "elastic" + }, + "coreMigrationVersion": "8.1.0", + "id": "eb92a730-8018-11ec-88ce-bd5b5e3a7526", + "references": [], + "sort": [ + 1643360506152, + 9062 + ], + "type": "osquery-pack", + "updated_at": "2022-01-28T09:01:46.152Z", + "version": "WzgzOTksMV0=" +} \ No newline at end of file diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson new file mode 100644 index 0000000000000..75bdecb5be428 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson @@ -0,0 +1,99 @@ +{ + "id": "c8ca6100-802e-11ec-952d-cf6018da8e2b", + "type": "alert", + "namespaces": [ + "default" + ], + "updated_at": "2022-01-28T11:38:23.009Z", + "version": "WzE5MjksMV0=", + "attributes": { + "name": "Test-rule", + "tags": [ + "__internal_rule_id:22308402-5e0e-421b-8d22-a47ddc4b0188", + "__internal_immutable:false" + ], + "alertTypeId": "siem.queryRule", + "consumer": "siem", + "params": { + "author": [], + "description": "asd", + "ruleId": "22308402-5e0e-421b-8d22-a47ddc4b0188", + "falsePositives": [], + "from": "now-360s", + "immutable": false, + "license": "", + "outputIndex": ".siem-signals-default", + "meta": { + "from": "1m", + "kibana_siem_app_url": "http://localhost:5601/app/security" + }, + "maxSignals": 100, + "riskScore": 21, + "riskScoreMapping": [], + "severity": "low", + "severityMapping": [], + "threat": [], + "to": "now", + "references": [], + "version": 1, + "exceptionsList": [], + "type": "query", + "language": "kuery", + "index": [ + "apm-*-transaction*", + "traces-apm*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], + "query": "_id:*", + "filters": [] + }, + "schedule": { + "interval": "5m" + }, + "enabled": true, + "actions": [], + "throttle": null, + "notifyWhen": "onActiveAlert", + "apiKeyOwner": "elastic", + "legacyId": null, + "createdBy": "elastic", + "updatedBy": "elastic", + "createdAt": "2022-01-28T11:38:17.540Z", + "updatedAt": "2022-01-28T11:38:19.894Z", + "muteAll": true, + "mutedInstanceIds": [], + "executionStatus": { + "status": "ok", + "lastExecutionDate": "2022-01-28T11:38:21.638Z", + "error": null, + "lastDuration": 1369 + }, + "monitoring": { + "execution": { + "history": [ + { + "success": true, + "timestamp": 1643369903007 + } + ], + "calculated_metrics": { + "success_ratio": 1 + } + } + }, + "meta": { + "versionApiKeyLastmodified": "8.1.0" + }, + "scheduledTaskId": "c8ca6100-802e-11ec-952d-cf6018da8e2b" + }, + "references": [], + "migrationVersion": { + "alert": "8.0.0" + }, + "coreMigrationVersion": "8.1.0" +} \ No newline at end of file diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts new file mode 100644 index 0000000000000..153fd5d58791e --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts @@ -0,0 +1,65 @@ +/* + * 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 { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { login } from '../../tasks/login'; +import { + checkResults, + findAndClickButton, + findFormFieldByRowsLabelAndType, + inputQuery, + submitQuery, +} from '../../tasks/live_query'; +import { preparePack } from '../../tasks/packs'; +import { closeModalIfVisible } from '../../tasks/integrations'; +import { navigateTo } from '../../tasks/navigation'; + +describe('Alert Event Details', () => { + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'pack'); + runKbnArchiverScript(ArchiverMethod.LOAD, 'rule'); + }); + beforeEach(() => { + login(); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'pack'); + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'rule'); + }); + + it('should be able to run live query', () => { + const PACK_NAME = 'testpack'; + const RULE_NAME = 'Test-rule'; + navigateTo('/app/osquery/packs'); + preparePack(PACK_NAME); + findAndClickButton('Edit'); + cy.contains(`Edit ${PACK_NAME}`); + findFormFieldByRowsLabelAndType( + 'Scheduled agent policies (optional)', + 'fleet server {downArrow}{enter}' + ); + findAndClickButton('Update pack'); + closeModalIfVisible(); + cy.contains(PACK_NAME); + cy.visit('/app/security/rules'); + cy.contains(RULE_NAME).click(); + cy.wait(2000); + cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); + cy.getBySel('ruleSwitch').click(); + cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'false'); + cy.getBySel('ruleSwitch').click(); + cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); + cy.visit('/app/security/alerts'); + cy.getBySel('expand-event').first().click(); + cy.getBySel('take-action-dropdown-btn').click(); + cy.getBySel('osquery-action-item').click(); + inputQuery('select * from uptime;'); + submitQuery(); + checkResults(); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts index a9524a509c0a1..f64e6b31ae7a5 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts @@ -45,7 +45,6 @@ describe('Super User - Metrics', () => { cy.getBySel('comboBoxInput').first().click(); cy.wait(500); - cy.get('div[role=listBox]').should('have.lengthOf.above', 0); cy.getBySel('comboBoxInput').first().type('{downArrow}{enter}'); submitQuery(); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts index 4c72a871b5b58..fd04d0a62b160 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts @@ -71,7 +71,7 @@ describe('SuperUser - Packs', () => { }); it('to click the edit button and edit pack', () => { - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); findAndClickButton('Edit'); cy.contains(`Edit ${PACK_NAME}`); findAndClickButton('Add query'); @@ -89,7 +89,7 @@ describe('SuperUser - Packs', () => { }); it('should trigger validation when saved query is being chosen', () => { - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); findAndClickButton('Edit'); findAndClickButton('Add query'); cy.contains('Attach next query'); @@ -103,7 +103,7 @@ describe('SuperUser - Packs', () => { }); // THIS TESTS TAKES TOO LONG FOR NOW - LET ME THINK IT THROUGH it.skip('to click the icon and visit discover', () => { - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); cy.react('CustomItemAction', { props: { index: 0, item: { id: SAVED_QUERY_ID } }, }).click(); @@ -124,7 +124,7 @@ describe('SuperUser - Packs', () => { lensUrl = url; }); }); - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); cy.react('CustomItemAction', { props: { index: 1, item: { id: SAVED_QUERY_ID } }, }).click(); @@ -154,7 +154,7 @@ describe('SuperUser - Packs', () => { }); it('delete all queries in the pack', () => { - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); cy.contains(/^Edit$/).click(); cy.getBySel('checkboxSelectAll').click(); @@ -170,7 +170,7 @@ describe('SuperUser - Packs', () => { }); it('enable changing saved queries and ecs_mappings', () => { - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); cy.contains(/^Edit$/).click(); findAndClickButton('Add query'); @@ -210,7 +210,7 @@ describe('SuperUser - Packs', () => { }); it('to click delete button', () => { - preparePack(PACK_NAME, SAVED_QUERY_ID); + preparePack(PACK_NAME); findAndClickButton('Edit'); deleteAndConfirm('pack'); }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts index bfeb5adc11f6e..bc8417d5facf5 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts @@ -6,9 +6,10 @@ */ import { navigateTo } from '../../tasks/navigation'; +import { RESULTS_TABLE_BUTTON } from '../../screens/live_query'; import { checkResults, - DEFAULT_QUERY, + BIG_QUERY, deleteAndConfirm, findFormFieldByRowsLabelAndType, inputQuery, @@ -34,18 +35,18 @@ describe('Super User - Saved queries', () => { () => { cy.contains('New live query').click(); selectAllAgents(); - inputQuery(DEFAULT_QUERY); + inputQuery(BIG_QUERY); submitQuery(); checkResults(); // enter fullscreen - cy.getBySel('dataGridFullScreenButton').trigger('mouseover'); - cy.contains(/Full screen$/).should('exist'); - cy.contains('Exit full screen').should('not.exist'); - cy.getBySel('dataGridFullScreenButton').click(); + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter fullscreen$/).should('exist'); + cy.contains('Exit fullscreen').should('not.exist'); + cy.getBySel(RESULTS_TABLE_BUTTON).click(); - cy.getBySel('dataGridFullScreenButton').trigger('mouseover'); - cy.contains(/Full screen$/).should('not.exist'); - cy.contains('Exit full screen').should('exist'); + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter Fullscreen$/).should('not.exist'); + cy.contains('Exit fullscreen').should('exist'); // hidden columns cy.react('EuiDataGridHeaderCellWrapper', { props: { id: 'osquery.cmdline' } }).click(); @@ -59,10 +60,10 @@ describe('Super User - Saved queries', () => { cy.getBySel('pagination-button-next').click().wait(500).click(); cy.contains('2 columns hidden').should('exist'); - cy.getBySel('dataGridFullScreenButton').trigger('mouseover'); - cy.contains(/Full screen$/).should('not.exist'); - cy.contains('Exit full screen').should('exist'); - cy.getBySel('dataGridFullScreenButton').click(); + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter fullscreen$/).should('not.exist'); + cy.contains('Exit fullscreen').should('exist'); + cy.getBySel(RESULTS_TABLE_BUTTON).click(); // sorting cy.react('EuiDataGridHeaderCellWrapper', { @@ -70,8 +71,8 @@ describe('Super User - Saved queries', () => { }).click(); cy.contains(/Sort A-Z$/).click(); cy.contains('2 columns hidden').should('exist'); - cy.getBySel('dataGridFullScreenButton').trigger('mouseover'); - cy.contains(/Full screen$/).should('exist'); + cy.getBySel(RESULTS_TABLE_BUTTON).trigger('mouseover'); + cy.contains(/Enter fullscreen$/).should('exist'); // save new query cy.contains('Exit full screen').should('not.exist'); @@ -111,8 +112,8 @@ describe('Super User - Saved queries', () => { props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, }).click(); deleteAndConfirm('query'); - cy.contains(SAVED_QUERY_ID); - cy.contains(/^No items found/); + cy.contains(SAVED_QUERY_ID).should('exist'); + cy.contains(SAVED_QUERY_ID).should('not.exist'); } ); }); diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts index 1a521fe1cd651..cba4a35c05719 100644 --- a/x-pack/plugins/osquery/cypress/screens/live_query.ts +++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts @@ -9,3 +9,4 @@ export const AGENT_FIELD = '[data-test-subj="comboBoxInput"]'; export const ALL_AGENTS_OPTION = '[title="All agents"]'; export const LIVE_QUERY_EDITOR = '#osquery_editor'; export const SUBMIT_BUTTON = '#submit-button'; +export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton'; diff --git a/x-pack/plugins/osquery/cypress/tasks/integrations.ts b/x-pack/plugins/osquery/cypress/tasks/integrations.ts index ebf8668483d1c..9cd9cbd8d4db6 100644 --- a/x-pack/plugins/osquery/cypress/tasks/integrations.ts +++ b/x-pack/plugins/osquery/cypress/tasks/integrations.ts @@ -16,7 +16,7 @@ import { export const addIntegration = (agentPolicy = 'Default Fleet Server policy') => { cy.getBySel(ADD_POLICY_BTN).click(); cy.getBySel(DATA_COLLECTION_SETUP_STEP).find('.euiLoadingSpinner').should('not.exist'); - cy.getBySel('agentPolicySelect').select(agentPolicy); + cy.getBySel('agentPolicySelect').should('have.text', agentPolicy); cy.getBySel(CREATE_PACKAGE_POLICY_SAVE_BTN).click(); // sometimes agent is assigned to default policy, sometimes not closeModalIfVisible(); diff --git a/x-pack/plugins/osquery/cypress/tasks/live_query.ts b/x-pack/plugins/osquery/cypress/tasks/live_query.ts index 4e7bfc63c35ac..2e199ae453f1b 100644 --- a/x-pack/plugins/osquery/cypress/tasks/live_query.ts +++ b/x-pack/plugins/osquery/cypress/tasks/live_query.ts @@ -7,13 +7,14 @@ import { LIVE_QUERY_EDITOR } from '../screens/live_query'; -export const DEFAULT_QUERY = 'select * from processes, users;'; +export const DEFAULT_QUERY = 'select * from processes;'; +export const BIG_QUERY = 'select * from processes, users;'; export const selectAllAgents = () => { cy.react('EuiComboBox', { props: { placeholder: 'Select agents or groups' } }).type('All agents'); cy.react('EuiFilterSelectItem').contains('All agents').should('exist'); cy.react('EuiComboBox', { props: { placeholder: 'Select agents or groups' } }).type( - '{downArrow}{enter}' + '{downArrow}{enter}{esc}' ); }; diff --git a/x-pack/plugins/osquery/cypress/tasks/packs.ts b/x-pack/plugins/osquery/cypress/tasks/packs.ts index 3218c792772ba..5f9ace1157a41 100644 --- a/x-pack/plugins/osquery/cypress/tasks/packs.ts +++ b/x-pack/plugins/osquery/cypress/tasks/packs.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const preparePack = (packName: string, savedQueryId: string) => { +export const preparePack = (packName: string) => { cy.contains('Packs').click(); const createdPack = cy.contains(packName); createdPack.click(); diff --git a/x-pack/plugins/osquery/public/packs/types.ts b/x-pack/plugins/osquery/public/packs/types.ts index 30cae97b006bb..95e488b8cc698 100644 --- a/x-pack/plugins/osquery/public/packs/types.ts +++ b/x-pack/plugins/osquery/public/packs/types.ts @@ -16,7 +16,7 @@ export interface IQueryPayload { export type PackSavedObject = SavedObject<{ name: string; description: string | undefined; - queries: Array>; + queries: Array>; enabled: boolean | undefined; created_at: string; created_by: string | undefined; diff --git a/x-pack/plugins/osquery/public/plugin.ts b/x-pack/plugins/osquery/public/plugin.ts index 86a1f89f738b6..8a0b61d7aaba6 100644 --- a/x-pack/plugins/osquery/public/plugin.ts +++ b/x-pack/plugins/osquery/public/plugin.ts @@ -26,7 +26,7 @@ import { LazyOsqueryManagedPolicyEditExtension, LazyOsqueryManagedCustomButtonExtension, } from './fleet_integration'; -import { getLazyOsqueryAction } from './shared_components'; +import { getLazyOsqueryAction, useIsOsqueryAvailableSimple } from './shared_components'; export class OsqueryPlugin implements Plugin { private kibanaVersion: string; @@ -95,6 +95,7 @@ export class OsqueryPlugin implements Plugin = ({ metadata }) => { +const OsqueryActionComponent: React.FC = ({ agentId, formType = 'simple' }) => { const permissions = useKibana().services.application.capabilities.osquery; - const agentId = metadata?.info?.agent?.id ?? undefined; - const { - data: agentData, - isFetched: agentFetched, - isLoading, - } = useAgentDetails({ - agentId, - silent: true, - skip: !agentId, - }); - const { - data: agentPolicyData, - isFetched: policyFetched, - isError: policyError, - isLoading: policyLoading, - } = useAgentPolicy({ - policyId: agentData?.policy_id, - skip: !agentData, - silent: true, - }); - - const osqueryAvailable = useMemo(() => { - if (policyError) return false; - - const osqueryPackageInstalled = find(agentPolicyData?.package_policies, [ - 'package.name', - OSQUERY_INTEGRATION_NAME, - ]); - return osqueryPackageInstalled?.enabled; - }, [agentPolicyData?.package_policies, policyError]); - if (!(permissions.runSavedQueries || permissions.writeLiveQueries)) { - return ( + const emptyPrompt = useMemo( + () => ( } - title={

Permissions denied

} + title={ +

+ {i18n.translate('xpack.osquery.action.shortEmptyTitle', { + defaultMessage: 'Osquery is not available', + })} +

+ } titleSize="xs" body={

- To access this page, ask your administrator for osquery Kibana - privileges. + {i18n.translate('xpack.osquery.action.empty', { + defaultMessage: + 'An Elastic Agent is not installed on this host. To run queries, install Elastic Agent on the host, and then add the Osquery Manager integration to the agent policy in Fleet.', + })}

} /> - ); - } + ), + [] + ); + const { osqueryAvailable, agentFetched, isLoading, policyFetched, policyLoading, agentData } = + useIsOsqueryAvailable(agentId); - if (isLoading) { - return ; + if (!agentId || (agentFetched && !agentData)) { + return emptyPrompt; } - if (!agentId || (agentFetched && !agentData)) { + if (!(permissions.runSavedQueries || permissions.writeLiveQueries)) { return ( } - title={

Osquery is not available

} + title={ +

+ {i18n.translate('xpack.osquery.action.permissionDenied', { + defaultMessage: 'Permission denied', + })} +

+ } titleSize="xs" body={

- An Elastic Agent is not installed on this host. To run queries, install Elastic Agent on - the host, and then add the Osquery Manager integration to the agent policy in Fleet. + To access this page, ask your administrator for osquery Kibana + privileges.

} /> ); } + if (isLoading) { + return ; + } + if (!policyFetched && policyLoading) { return ; } @@ -104,12 +90,20 @@ const OsqueryActionComponent: React.FC = ({ metadata }) => { return ( } - title={

Osquery is not available

} + title={ +

+ {i18n.translate('xpack.osquery.action.shortEmptyTitle', { + defaultMessage: 'Osquery is not available', + })} +

+ } titleSize="xs" body={

- The Osquery Manager integration is not added to the agent policy. To run queries on the - host, add the Osquery Manager integration to the agent policy in Fleet. + {i18n.translate('xpack.osquery.action.unavailable', { + defaultMessage: + 'The Osquery Manager integration is not added to the agent policy. To run queries on the host, add the Osquery Manager integration to the agent policy in Fleet.', + })}

} /> @@ -120,30 +114,38 @@ const OsqueryActionComponent: React.FC = ({ metadata }) => { return ( } - title={

Osquery is not available

} + title={ +

+ {i18n.translate('xpack.osquery.action.shortEmptyTitle', { + defaultMessage: 'Osquery is not available', + })} +

+ } titleSize="xs" body={

- To run queries on this host, the Elastic Agent must be active. Check the status of this - agent in Fleet. + {i18n.translate('xpack.osquery.action.agentStatus', { + defaultMessage: + 'To run queries on this host, the Elastic Agent must be active. Check the status of this agent in Fleet.', + })}

} /> ); } - return ; + return ; }; -export const OsqueryAction = React.memo(OsqueryActionComponent); +const OsqueryAction = React.memo(OsqueryActionComponent); // @ts-expect-error update types -const OsqueryActionWrapperComponent = ({ services, ...props }) => ( +const OsqueryActionWrapperComponent = ({ services, agentId, formType }) => ( - + diff --git a/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available.ts b/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available.ts new file mode 100644 index 0000000000000..595296e4d7b60 --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { find } from 'lodash'; +import { useAgentDetails } from '../../agents/use_agent_details'; +import { useAgentPolicy } from '../../agent_policies'; +import { OSQUERY_INTEGRATION_NAME } from '../../../common'; + +export const useIsOsqueryAvailable = (agentId?: string) => { + const { + data: agentData, + isFetched: agentFetched, + isLoading, + } = useAgentDetails({ + agentId, + silent: true, + skip: !agentId, + }); + const { + data: agentPolicyData, + isFetched: policyFetched, + isError: policyError, + isLoading: policyLoading, + } = useAgentPolicy({ + policyId: agentData?.policy_id, + skip: !agentData, + silent: true, + }); + + const osqueryAvailable = useMemo(() => { + if (policyError) return false; + + const osqueryPackageInstalled = find(agentPolicyData?.package_policies, [ + 'package.name', + OSQUERY_INTEGRATION_NAME, + ]); + return osqueryPackageInstalled?.enabled; + }, [agentPolicyData?.package_policies, policyError]); + + return { osqueryAvailable, agentFetched, isLoading, policyFetched, policyLoading, agentData }; +}; diff --git a/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.test.ts b/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.test.ts new file mode 100644 index 0000000000000..c293e4c75a910 --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { useKibana } from '../../common/lib/kibana'; +import { useIsOsqueryAvailableSimple } from './use_is_osquery_available_simple'; +import { renderHook } from '@testing-library/react-hooks'; +import { createStartServicesMock } from '../../../../triggers_actions_ui/public/common/lib/kibana/kibana_react.mock'; +import { OSQUERY_INTEGRATION_NAME } from '../../../common'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; + +jest.mock('../../common/lib/kibana'); + +const response = { + item: { + policy_id: '4234234234', + package_policies: [ + { + package: { name: OSQUERY_INTEGRATION_NAME }, + enabled: true, + }, + ], + }, +}; + +describe('UseIsOsqueryAvailableSimple', () => { + const mockedHttp = httpServiceMock.createStartContract(); + mockedHttp.get.mockResolvedValue(response); + beforeAll(() => { + (useKibana as jest.Mock).mockImplementation(() => { + const mockStartServicesMock = createStartServicesMock(); + + return { + services: { + ...mockStartServicesMock, + http: mockedHttp, + }, + }; + }); + }); + it('should expect response from API and return enabled flag', async () => { + const { result, waitForValueToChange } = renderHook(() => + useIsOsqueryAvailableSimple({ + agentId: '3242332', + }) + ); + + expect(result.current).toBe(false); + await waitForValueToChange(() => result.current); + + expect(result.current).toBe(true); + }); +}); diff --git a/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.tsx b/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.tsx new file mode 100644 index 0000000000000..efe34b51ea0a3 --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_components/osquery_action/use_is_osquery_available_simple.tsx @@ -0,0 +1,43 @@ +/* + * 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 { useEffect, useState } from 'react'; + +import { find } from 'lodash'; +import { useKibana } from '../../common/lib/kibana'; +import { OSQUERY_INTEGRATION_NAME } from '../../../common'; +import { AgentPolicy, FleetServerAgent, NewPackagePolicy } from '../../../../fleet/common'; + +interface IProps { + agentId: string; +} + +export const useIsOsqueryAvailableSimple = ({ agentId }: IProps) => { + const { http } = useKibana().services; + const [isAvailable, setIsAvailable] = useState(false); + useEffect(() => { + (async () => { + try { + const { item: agentInfo }: { item: FleetServerAgent } = await http.get( + `/internal/osquery/fleet_wrapper/agents/${agentId}` + ); + const { item: packageInfo }: { item: AgentPolicy } = await http.get( + `/internal/osquery/fleet_wrapper/agent_policies/${agentInfo.policy_id}/` + ); + const osqueryPackageInstalled = find(packageInfo?.package_policies, [ + 'package.name', + OSQUERY_INTEGRATION_NAME, + ]) as NewPackagePolicy; + setIsAvailable(osqueryPackageInstalled.enabled); + } catch (err) { + return; + } + })(); + }, [agentId, http]); + + return isAvailable; +}; diff --git a/x-pack/plugins/osquery/public/types.ts b/x-pack/plugins/osquery/public/types.ts index fd21b39d25504..91095b6f169c1 100644 --- a/x-pack/plugins/osquery/public/types.ts +++ b/x-pack/plugins/osquery/public/types.ts @@ -22,6 +22,7 @@ import { getLazyOsqueryAction } from './shared_components'; export interface OsqueryPluginSetup {} export interface OsqueryPluginStart { OsqueryAction?: ReturnType; + isOsqueryAvailable: (props: { agentId: string }) => boolean; } export interface AppPluginStartDependencies { diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index ba289e48fd6a2..36edfd43d5ea5 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -39,7 +39,8 @@ "lists", "home", "telemetry", - "dataViewFieldEditor" + "dataViewFieldEditor", + "osquery" ], "server": true, "ui": true, diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx new file mode 100644 index 0000000000000..ca61e2f3ebf6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { ACTION_OSQUERY } from './translations'; + +interface IProps { + handleClick: () => void; +} + +export const OsqueryActionItem = ({ handleClick }: IProps) => { + return ( + + {ACTION_OSQUERY} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout.tsx new file mode 100644 index 0000000000000..3262fc36abf75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiFlyout, EuiFlyoutFooter, EuiFlyoutBody, EuiFlyoutHeader } from '@elastic/eui'; +import { useKibana } from '../../../common/lib/kibana'; +import { OsqueryEventDetailsFooter } from './osquery_flyout_footer'; +import { OsqueryEventDetailsHeader } from './osquery_flyout_header'; +import { ACTION_OSQUERY } from './translations'; + +const OsqueryActionWrapper = styled.div` + padding: 8px; +`; + +export interface OsqueryFlyoutProps { + agentId: string; + onClose: () => void; +} + +export const OsqueryFlyout: React.FC = ({ agentId, onClose }) => { + const { + services: { osquery }, + } = useKibana(); + + // @ts-expect-error + const { OsqueryAction } = osquery; + return ( + + + {ACTION_OSQUERY}
} + handleClick={onClose} + data-test-subj="flyout-header-osquery" + /> + + + + + + + + + + + ); +}; + +OsqueryFlyout.displayName = 'OsqueryFlyout'; diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_footer.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_footer.tsx new file mode 100644 index 0000000000000..77cade0e04042 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_footer.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +interface EventDetailsFooterProps { + handleClick: () => void; +} + +export const OsqueryEventDetailsFooterComponent = ({ handleClick }: EventDetailsFooterProps) => { + return ( + + + + + + + + ); +}; + +export const OsqueryEventDetailsFooter = React.memo(OsqueryEventDetailsFooterComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_header.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_header.tsx new file mode 100644 index 0000000000000..7a0f7f15f3e74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_flyout_header.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiText, EuiTitle } from '@elastic/eui'; +import { BACK_TO_ALERT_DETAILS } from './translations'; + +interface IProps { + primaryText: React.ReactElement; + handleClick: () => void; +} + +const OsqueryEventDetailsHeaderComponent: React.FC = ({ primaryText, handleClick }) => { + return ( + <> + + +

{BACK_TO_ALERT_DETAILS}

+
+
+ {primaryText} + + ); +}; + +export const OsqueryEventDetailsHeader = React.memo(OsqueryEventDetailsHeaderComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/translations.ts b/x-pack/plugins/security_solution/public/detections/components/osquery/translations.ts new file mode 100644 index 0000000000000..d3c92ebdf44e2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const BACK_TO_ALERT_DETAILS = i18n.translate( + 'xpack.securitySolution.alertsView.osqueryBackToAlertDetails', + { + defaultMessage: 'Alert Details', + } +); + +export const ACTION_OSQUERY = i18n.translate( + 'xpack.securitySolution.alertsView.osqueryAlertTitle', + { + defaultMessage: 'Run Osquery', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx index 938022b5aac5e..8aa8986d3e563 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx @@ -78,6 +78,7 @@ describe('take action dropdown', () => { refetch: jest.fn(), refetchFlyoutData: jest.fn(), timelineId: TimelineId.active, + onOsqueryClick: jest.fn(), }; beforeAll(() => { @@ -89,8 +90,11 @@ describe('take action dropdown', () => { ...mockStartServicesMock, timelines: { ...mockTimelines }, cases: mockCasesContract(), + osquery: { + isOsqueryAvailable: jest.fn().mockReturnValue(true), + }, application: { - capabilities: { siem: { crud_alerts: true, read_alerts: true } }, + capabilities: { siem: { crud_alerts: true, read_alerts: true }, osquery: true }, }, }, }; @@ -190,6 +194,13 @@ describe('take action dropdown', () => { ).toEqual('Investigate in timeline'); }); }); + test('should render "Run Osquery"', async () => { + await waitFor(() => { + expect(wrapper.find('[data-test-subj="osquery-action-item"]').first().text()).toEqual( + 'Run Osquery' + ); + }); + }); }); describe('should correctly enable/disable the "Add Endpoint event filter" button', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index 4a35fdd6a1381..94b9327bb439a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useState, useCallback, useMemo } from 'react'; -import { EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; @@ -23,6 +23,8 @@ import { isAlertFromEndpointAlert } from '../../../common/utils/endpoint_alert_c import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useUserPrivileges } from '../../../common/components/user_privileges'; import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions'; +import { useKibana } from '../../../common/lib/kibana'; +import { OsqueryActionItem } from '../osquery/osquery_action_item'; interface ActionsData { alertStatus: Status; @@ -45,6 +47,7 @@ export interface TakeActionDropdownProps { refetch: (() => void) | undefined; refetchFlyoutData: () => Promise; timelineId: string; + onOsqueryClick: (id: string) => void; } export const TakeActionDropdown = React.memo( @@ -61,6 +64,7 @@ export const TakeActionDropdown = React.memo( refetch, refetchFlyoutData, timelineId, + onOsqueryClick, }: TakeActionDropdownProps) => { const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); const { loading: canAccessEndpointManagementLoading, canAccessEndpointManagement } = @@ -70,6 +74,7 @@ export const TakeActionDropdown = React.memo( () => !canAccessEndpointManagementLoading && canAccessEndpointManagement, [canAccessEndpointManagement, canAccessEndpointManagementLoading] ); + const { osquery } = useKibana().services; const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -97,6 +102,11 @@ export const TakeActionDropdown = React.memo( const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]); + const agentId = useMemo( + () => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData), + [detailsData] + ); + const togglePopoverHandler = useCallback(() => { setIsPopoverOpen(!isPopoverOpen); }, [isPopoverOpen]); @@ -166,6 +176,23 @@ export const TakeActionDropdown = React.memo( onInvestigateInTimelineAlertClick: closePopoverHandler, }); + const osqueryAvailable = osquery?.isOsqueryAvailable({ + agentId, + }); + + const handleOnOsqueryClick = useCallback(() => { + onOsqueryClick(agentId); + setIsPopoverOpen(false); + }, [onOsqueryClick, setIsPopoverOpen, agentId]); + + const osqueryActionItem = useMemo( + () => + OsqueryActionItem({ + handleClick: handleOnOsqueryClick, + }), + [handleOnOsqueryClick] + ); + const alertsActionItems = useMemo( () => !isEvent && actionsData.ruleId @@ -196,13 +223,16 @@ export const TakeActionDropdown = React.memo( ...(tGridEnabled ? addToCaseActionItems : []), ...alertsActionItems, ...hostIsolationActionItems, + ...(osqueryAvailable ? [osqueryActionItem] : []), ...investigateInTimelineActionItems, ], [ tGridEnabled, - alertsActionItems, addToCaseActionItems, + alertsActionItems, hostIsolationActionItems, + osqueryAvailable, + osqueryActionItem, investigateInTimelineActionItems, ] ); @@ -220,7 +250,6 @@ export const TakeActionDropdown = React.memo( ); }, [togglePopoverHandler]); - return items.length && !loadingEventDetails && ecsData ? ( (null); + + const closeOsqueryFlyout = useCallback(() => { + setOsqueryFlyoutOpenWithAgentId(null); + }, [setOsqueryFlyoutOpenWithAgentId]); + return ( <> @@ -128,6 +137,7 @@ export const EventDetailsFooterComponent = React.memo( refetch={refetchAll} indexName={expandedEvent.indexName} timelineId={timelineId} + onOsqueryClick={setOsqueryFlyoutOpenWithAgentId} /> )} @@ -154,6 +164,9 @@ export const EventDetailsFooterComponent = React.memo( maskProps={{ style: 'z-index: 5000' }} /> )} + {isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && ( + + )} ); } diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index d43f8752c9122..0916bc73f4198 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -28,6 +28,7 @@ import type { TimelinesUIStart } from '../../timelines/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; import type { MlPluginSetup, MlPluginStart } from '../../ml/public'; +import type { OsqueryPluginStart } from '../../osquery/public'; import type { Detections } from './detections'; import type { Cases } from './cases'; @@ -69,6 +70,7 @@ export interface StartPlugins { ml?: MlPluginStart; spaces?: SpacesPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; + osquery?: OsqueryPluginStart; } export type StartServices = CoreStart & diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index d518eaf7f8243..b1cb49b737952 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -39,6 +39,7 @@ { "path": "../lists/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, { "path": "../ml/tsconfig.json" }, + { "path": "../osquery/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../timelines/tsconfig.json" } diff --git a/x-pack/test/osquery_cypress/artifact_manager.ts b/x-pack/test/osquery_cypress/artifact_manager.ts index 4eaf16a33b629..d96ef56ec87c8 100644 --- a/x-pack/test/osquery_cypress/artifact_manager.ts +++ b/x-pack/test/osquery_cypress/artifact_manager.ts @@ -5,10 +5,11 @@ * 2.0. */ -import axios from 'axios'; -import { last } from 'lodash'; +// import axios from 'axios'; +// import { last } from 'lodash'; export async function getLatestVersion(): Promise { - const response: any = await axios('https://artifacts-api.elastic.co/v1/versions'); - return last(response.data.versions as string[]) || '8.2.0-SNAPSHOT'; + return '8.1.0-SNAPSHOT'; + // const response: any = await axios('https://artifacts-api.elastic.co/v1/versions'); + // return last(response.data.versions as string[]) || '8.2.0-SNAPSHOT'; } From 043c40b6d03b7bdf55209c08b681a6bf5963d4e8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 23 Mar 2022 10:21:09 +0200 Subject: [PATCH 065/132] [Cases] Fix fields query parameter in the `_find` API (#128143) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: lcawl --- docs/api/cases/cases-api-find-cases.asciidoc | 3 +- x-pack/plugins/cases/common/api/cases/case.ts | 2 +- .../cases/server/authorization/utils.test.ts | 4 +- .../cases/server/authorization/utils.ts | 2 +- .../plugins/cases/server/client/cases/find.ts | 6 ++- .../tests/common/cases/find_cases.ts | 40 +++++++++++++++++++ 6 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc index 334f45fee526d..68e620aece7b6 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -31,10 +31,9 @@ default space is used. (Optional, string) The default operator to use for the `simple_query_string`. Defaults to `OR`. -//// `fields`:: (Optional, array of strings) The fields in the entity to return in the response. -//// + `owner`:: (Optional, string or array of strings) A filter to limit the retrieved cases to a specific set of applications. Valid values are: `cases`, `observability`, diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 6d3a0b524f890..1bc14fa8d3ab9 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -153,7 +153,7 @@ export const CasesFindRequestRt = rt.partial({ /** * The fields in the entity to return in the response */ - fields: rt.array(rt.string), + fields: rt.union([rt.array(rt.string), rt.string]), /** * The page of objects to return */ diff --git a/x-pack/plugins/cases/server/authorization/utils.test.ts b/x-pack/plugins/cases/server/authorization/utils.test.ts index 7717edfc909ef..b0b830f1a014d 100644 --- a/x-pack/plugins/cases/server/authorization/utils.test.ts +++ b/x-pack/plugins/cases/server/authorization/utils.test.ts @@ -174,8 +174,8 @@ describe('utils', () => { expect(includeFieldsRequiredForAuthentication()).toBeUndefined(); }); - it('returns an array with a single entry containing the owner field', () => { - expect(includeFieldsRequiredForAuthentication([])).toStrictEqual([OWNER_FIELD]); + it('returns undefined when the fields parameter is an empty array', () => { + expect(includeFieldsRequiredForAuthentication([])).toBeUndefined(); }); it('returns an array without duplicates and including the owner field', () => { diff --git a/x-pack/plugins/cases/server/authorization/utils.ts b/x-pack/plugins/cases/server/authorization/utils.ts index ac88f96fb4e14..d33d3dd99a47f 100644 --- a/x-pack/plugins/cases/server/authorization/utils.ts +++ b/x-pack/plugins/cases/server/authorization/utils.ts @@ -58,7 +58,7 @@ export const ensureFieldIsSafeForQuery = (field: string, value: string): boolean }; export const includeFieldsRequiredForAuthentication = (fields?: string[]): string[] | undefined => { - if (fields === undefined) { + if (fields === undefined || fields.length === 0) { return; } return uniq([...fields, OWNER_FIELD]); diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index c8bdb40b41310..26ac4603c51e5 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -38,8 +38,10 @@ export const find = async ( const { caseService, authorization, logger } = clientArgs; try { + const fields = asArray(params.fields); + const queryParams = pipe( - excess(CasesFindRequestRt).decode(params), + excess(CasesFindRequestRt).decode({ ...params, fields }), fold(throwErrors(Boom.badRequest), identity) ); @@ -67,7 +69,7 @@ export const find = async ( ...queryParams, ...caseQueryOptions, searchFields: asArray(queryParams.searchFields), - fields: includeFieldsRequiredForAuthentication(queryParams.fields), + fields: includeFieldsRequiredForAuthentication(fields), }, }), caseService.getCaseStatusStats({ diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index 48d6515d73d0d..89f6f96aeb7d1 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -195,6 +195,46 @@ export default ({ getService }: FtrProviderContext): void => { expect(cases.count_in_progress_cases).to.eql(1); }); + it('returns the correct fields', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const queryFields: Array> = [ + 'title', + ['title', 'description'], + ]; + + for (const fields of queryFields) { + const cases = await findCases({ supertest, query: { fields } }); + const fieldsAsArray = Array.isArray(fields) ? fields : [fields]; + + const expectedValues = fieldsAsArray.reduce( + (theCase, field) => ({ + ...theCase, + [field]: postedCase[field], + }), + {} + ); + + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [ + { + id: postedCase.id, + version: postedCase.version, + external_service: postedCase.external_service, + owner: postedCase.owner, + connector: postedCase.connector, + comments: [], + totalAlerts: 0, + totalComment: 0, + ...expectedValues, + }, + ], + count_open_cases: 1, + }); + } + }); + it('unhappy path - 400s when bad query supplied', async () => { await findCases({ supertest, query: { perPage: true }, expectedHttpCode: 400 }); }); From 77034b3f541626cce3e177ee957d772826d44025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Wed, 23 Mar 2022 09:21:49 +0100 Subject: [PATCH 066/132] [Unified Observability] Guided setup button on the overview page (#128172) * Add guided setup button to overview page * Update content for status vis flyout * assure boxes order * Fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../app/observability_status/content.ts | 7 +++ .../observability_status.stories.tsx | 6 ++ .../observability_status_box.test.tsx | 2 + .../observability_status_box.tsx | 1 + .../observability_status_boxes.test.tsx | 43 +++++++++++++++ .../observability_status_boxes.tsx | 12 ++-- .../pages/overview/old_overview_page.tsx | 55 ++++++++++++++++++- 7 files changed, 120 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/observability/public/components/app/observability_status/content.ts b/x-pack/plugins/observability/public/components/app/observability_status/content.ts index 084d28a554472..ea264a4387a85 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/content.ts +++ b/x-pack/plugins/observability/public/components/app/observability_status/content.ts @@ -17,6 +17,7 @@ export interface ObservabilityStatusContent { learnMoreLink: string; goToAppTitle: string; goToAppLink: string; + weight: number; } export const getContent = ( @@ -42,6 +43,7 @@ export const getContent = ( defaultMessage: 'Show log stream', }), goToAppLink: http.basePath.prepend('/app/logs/stream'), + weight: 1, }, { id: 'apm', @@ -61,6 +63,7 @@ export const getContent = ( defaultMessage: 'Show services inventory', }), goToAppLink: http.basePath.prepend('/app/apm/services'), + weight: 3, }, { id: 'infra_metrics', @@ -79,6 +82,7 @@ export const getContent = ( defaultMessage: 'Show inventory', }), goToAppLink: http.basePath.prepend('/app/metrics/inventory'), + weight: 2, }, { id: 'synthetics', @@ -97,6 +101,7 @@ export const getContent = ( defaultMessage: 'Show monitors ', }), goToAppLink: http.basePath.prepend('/app/uptime'), + weight: 4, }, { id: 'ux', @@ -116,6 +121,7 @@ export const getContent = ( defaultMessage: 'Show dashboard', }), goToAppLink: http.basePath.prepend('/app/ux'), + weight: 5, }, { id: 'alert', @@ -135,6 +141,7 @@ export const getContent = ( defaultMessage: 'Show alerts', }), goToAppLink: http.basePath.prepend('/app/observability/alerts'), + weight: 6, }, ]; }; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx index c10ffa0500db6..283d19210e45a 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status.stories.tsx @@ -27,6 +27,7 @@ const testBoxes = [ goToAppLink: '/app/logs/stream', hasData: false, modules: [], + weight: 1, }, { id: 'apm', @@ -40,6 +41,7 @@ const testBoxes = [ goToAppLink: '/app/apm/services', hasData: false, modules: [], + weight: 2, }, { id: 'infra_metrics', @@ -52,6 +54,7 @@ const testBoxes = [ goToAppLink: '/app/metrics/inventory', hasData: false, modules: [], + weight: 3, }, { id: 'synthetics', @@ -64,6 +67,7 @@ const testBoxes = [ goToAppLink: '/app/uptime', hasData: false, modules: [], + weight: 4, }, { id: 'ux', @@ -77,6 +81,7 @@ const testBoxes = [ goToAppLink: '/app/ux', hasData: true, modules: [], + weight: 5, }, { id: 'alert', @@ -90,6 +95,7 @@ const testBoxes = [ goToAppLink: '/app/observability/alerts', hasData: true, modules: [], + weight: 6, }, ]; diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx index 7bc9cb60ad349..088e0dea20bd0 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.test.tsx @@ -24,6 +24,7 @@ describe('ObservabilityStatusBox', () => { learnMoreLink: 'learnMoreUrl.com', goToAppTitle: 'go to app title', goToAppLink: 'go to app link', + weight: 1, }; render( @@ -60,6 +61,7 @@ describe('ObservabilityStatusBox', () => { learnMoreLink: 'http://example.com', goToAppTitle: 'go to app title', goToAppLink: 'go to app link', + weight: 1, }; render( diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx index a819afab0bed5..756fb995d489b 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_box.tsx @@ -31,6 +31,7 @@ export interface ObservabilityStatusBoxProps { learnMoreLink: string; goToAppTitle: string; goToAppLink: string; + weight: number; } export function ObservabilityStatusBox(props: ObservabilityStatusBoxProps) { diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx index 9ad69b2ce64f8..4c838eb6a7b2f 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.test.tsx @@ -24,6 +24,7 @@ describe('ObservabilityStatusBoxes', () => { learnMoreLink: 'http://example.com', goToAppTitle: 'go to app title', goToAppLink: 'go to app link', + weight: 2, }, { id: 'metrics', @@ -36,6 +37,7 @@ describe('ObservabilityStatusBoxes', () => { learnMoreLink: 'http://example.com', goToAppTitle: 'go to app title', goToAppLink: 'go to app link', + weight: 1, }, ]; @@ -48,4 +50,45 @@ describe('ObservabilityStatusBoxes', () => { expect(screen.getByText('Logs')).toBeInTheDocument(); expect(screen.getByText('Metrics')).toBeInTheDocument(); }); + + it('should render elements by order', () => { + const boxes = [ + { + id: 'logs', + title: 'Logs', + hasData: true, + description: 'This is the description for logs', + modules: [], + addTitle: 'logs add title', + addLink: 'http://example.com', + learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', + weight: 2, + }, + { + id: 'metrics', + title: 'Metrics', + hasData: true, + description: 'This is the description for metrics', + modules: [], + addTitle: 'metrics add title', + addLink: 'http://example.com', + learnMoreLink: 'http://example.com', + goToAppTitle: 'go to app title', + goToAppLink: 'go to app link', + weight: 1, + }, + ]; + + render( + + + + ); + + const content = screen.getAllByTestId(/box-*/); + expect(content[0]).toHaveTextContent('Metrics'); + expect(content[1]).toHaveTextContent('Logs'); + }); }); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx index 0827f7f8c768c..48779569131d6 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx @@ -17,9 +17,13 @@ export interface ObservabilityStatusProps { boxes: ObservabilityStatusBoxProps[]; } +const sortingFn = (a: ObservabilityStatusBoxProps, b: ObservabilityStatusBoxProps) => { + return a.weight - b.weight; +}; + export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { - const hasDataBoxes = boxes.filter((box) => box.hasData); - const noHasDataBoxes = boxes.filter((box) => !box.hasData); + const hasDataBoxes = boxes.filter((box) => box.hasData).sort(sortingFn); + const noHasDataBoxes = boxes.filter((box) => !box.hasData).sort(sortingFn); return ( @@ -34,7 +38,7 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { {noHasDataBoxes.map((box) => ( - + ))} @@ -52,7 +56,7 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { {hasDataBoxes.map((box) => ( - + ))} diff --git a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx index 39daf9b5aac8e..88c82d8c355ac 100644 --- a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx @@ -5,9 +5,21 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiHorizontalRule, + EuiButton, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiText, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useMemo, useRef, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo, useRef, useCallback, useState } from 'react'; import { observabilityFeatureId } from '../../../common'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { useTrackPageview } from '../..'; @@ -33,6 +45,7 @@ import { ObservabilityAppServices } from '../../application/types'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { paths } from '../../config'; import { useDatePickerContext } from '../../hooks/use_date_picker_context'; +import { ObservabilityStatus } from '../../components/app/observability_status'; interface Props { routeParams: RouteParams<'/overview'>; } @@ -53,6 +66,7 @@ export function OverviewPage({ routeParams }: Props) { }), }, ]); + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const indexNames = useAlertIndexNames(); const { cases, docLinks, http } = useKibana().services; @@ -110,6 +124,12 @@ export function OverviewPage({ routeParams }: Props) { ? { pageTitle: overviewPageTitle, rightSideItems: [ + setIsFlyoutVisible(true)}> + + , )} + {isFlyoutVisible && ( + setIsFlyoutVisible(false)} + aria-labelledby="statusVisualizationFlyoutTitle" + > + + +

+ +

+
+ + +

+ +

+
+
+ + + +
+ )} ); } From d3d36cf0c1fba9afa98b780e2efc19b0b1e9ae6b Mon Sep 17 00:00:00 2001 From: Emmanuelle Raffenne <97166868+emma-raffenne@users.noreply.github.com> Date: Wed, 23 Mar 2022 09:08:49 +0000 Subject: [PATCH 067/132] Action to add issue to AO project (#127312) * Action to add issue to AO project * Switch to using richkuz projectnext-assigner as per jasonrhodes suggestion * Add condition to run only on issues labeled as AO Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/workflows/add-to-ao-project.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/add-to-ao-project.yml diff --git a/.github/workflows/add-to-ao-project.yml b/.github/workflows/add-to-ao-project.yml new file mode 100644 index 0000000000000..c89e8fcefb712 --- /dev/null +++ b/.github/workflows/add-to-ao-project.yml @@ -0,0 +1,23 @@ +name: Add issues to Actionable Observability project +on: + issues: + types: [labeled] +jobs: + sync_issues_with_table: + runs-on: ubuntu-latest + name: Add issues to project + if: | + github.event.label.name == 'Team: Actionable Observability' + steps: + - name: Add + uses: richkuz/projectnext-label-assigner@1.0.2 + id: add_to_projects + with: + config: | + [ + {"label": "Team: Actionable Observability", "projectNumber": 669} + ] + env: + GRAPHQL_API_BASE: 'https://api.github.com' + PAT_TOKEN: ${{ secrets.PROJECT_ASSIGNER_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From daea5a8519dafccdab851f7436941ef58a974ba7 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Wed, 23 Mar 2022 12:18:50 +0300 Subject: [PATCH 068/132] [RAC][UPTIME] -126229 - Add view in app url as an action variable in the alert message for uptime app (#127478) * Expose getMonitorRouteFromMonitorId in the common folder * Remove unused import * WIP * Fix some issues * Add 5 min before when the alert is raised * Update status * Cover the autogenerated use case * Update tests * Updated tests * Use indexedStartedAt and full URL * Take into consideration the kibanaBase path * Fix URL * LINT * Fix tests * Use IBasePath for clarity and update related tests * Optim - use getViewInAppUrl * Add duration anomaly and fix tests * Fix tests * Rename server var * Remove join --- .../uptime/common/utils/get_monitor_url.ts | 40 +++++++++++++++ .../uptime/public/lib/alert_types/common.ts | 34 ------------- .../lib/alert_types/duration_anomaly.tsx | 2 +- .../public/lib/alert_types/monitor_status.tsx | 2 +- .../lib/adapters/framework/adapter_types.ts | 8 ++- .../server/lib/alerts/action_variables.ts | 11 +++++ .../uptime/server/lib/alerts/common.ts | 5 ++ .../lib/alerts/duration_anomaly.test.ts | 4 +- .../server/lib/alerts/duration_anomaly.ts | 25 +++++++--- .../server/lib/alerts/status_check.test.ts | 6 +++ .../uptime/server/lib/alerts/status_check.ts | 49 +++++++++++++++---- .../server/lib/alerts/test_utils/index.ts | 12 ++++- .../plugins/uptime/server/lib/alerts/types.ts | 1 + x-pack/plugins/uptime/server/plugin.ts | 1 + 14 files changed, 145 insertions(+), 55 deletions(-) create mode 100644 x-pack/plugins/uptime/common/utils/get_monitor_url.ts diff --git a/x-pack/plugins/uptime/common/utils/get_monitor_url.ts b/x-pack/plugins/uptime/common/utils/get_monitor_url.ts new file mode 100644 index 0000000000000..09b02150957d0 --- /dev/null +++ b/x-pack/plugins/uptime/common/utils/get_monitor_url.ts @@ -0,0 +1,40 @@ +/* + * 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 { stringify } from 'querystring'; + +export const format = ({ + pathname, + query, +}: { + pathname: string; + query: Record; +}): string => { + return `${pathname}?${stringify(query)}`; +}; + +export const getMonitorRouteFromMonitorId = ({ + monitorId, + dateRangeStart, + dateRangeEnd, + filters = {}, +}: { + monitorId: string; + dateRangeStart: string; + dateRangeEnd: string; + filters?: Record; +}) => + format({ + pathname: `/app/uptime/monitor/${btoa(monitorId)}`, + query: { + dateRangeEnd, + dateRangeStart, + ...(Object.keys(filters).length + ? { filters: JSON.stringify(Object.keys(filters).map((key) => [key, filters[key]])) } + : {}), + }, + }); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/common.ts b/x-pack/plugins/uptime/public/lib/alert_types/common.ts index 6a45f73357597..0835cc4b5202c 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/common.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/common.ts @@ -5,40 +5,6 @@ * 2.0. */ -import { stringify } from 'querystring'; - -export const format = ({ - pathname, - query, -}: { - pathname: string; - query: Record; -}): string => { - return `${pathname}?${stringify(query)}`; -}; - -export const getMonitorRouteFromMonitorId = ({ - monitorId, - dateRangeStart, - dateRangeEnd, - filters = {}, -}: { - monitorId: string; - dateRangeStart: string; - dateRangeEnd: string; - filters?: Record; -}) => - format({ - pathname: `/app/uptime/monitor/${btoa(monitorId)}`, - query: { - dateRangeEnd, - dateRangeStart, - ...(Object.keys(filters).length - ? { filters: JSON.stringify(Object.keys(filters).map((key) => [key, filters[key]])) } - : {}), - }, - }); - export const getUrlForAlert = (id: string, basePath: string) => { return basePath + '/app/management/insightsAndAlerting/triggersActions/alert/' + id; }; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx index cdd0441575b33..d6498015e41ce 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx @@ -11,7 +11,7 @@ import moment from 'moment'; import { ALERT_END, ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_REASON } from '@kbn/rule-data-utils'; import { AlertTypeInitializer } from '.'; -import { getMonitorRouteFromMonitorId } from './common'; +import { getMonitorRouteFromMonitorId } from '../../../common/utils/get_monitor_url'; import { CLIENT_ALERT_TYPES } from '../../../common/constants/alerts'; import { DurationAnomalyTranslations } from '../../../common/translations'; import { ObservabilityRuleTypeModel } from '../../../../observability/public'; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx index 9737753df0225..5d6f8f3fea333 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/uptime/public/lib/alert_types/monitor_status.tsx @@ -17,7 +17,7 @@ import { } from '@kbn/rule-data-utils'; import { AlertTypeInitializer } from '.'; -import { getMonitorRouteFromMonitorId } from './common'; +import { getMonitorRouteFromMonitorId } from '../../../common/utils/get_monitor_url'; import { MonitorStatusTranslations } from '../../../common/translations'; import { CLIENT_ALERT_TYPES } from '../../../common/constants/alerts'; import { ObservabilityRuleTypeModel } from '../../../../observability/public'; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 9efb7e36ebab9..d9dadc81397ce 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -6,7 +6,12 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import type { SavedObjectsClientContract, IScopedClusterClient, Logger } from 'src/core/server'; +import type { + SavedObjectsClientContract, + IScopedClusterClient, + Logger, + IBasePath, +} from 'src/core/server'; import type { TelemetryPluginSetup, TelemetryPluginStart } from 'src/plugins/telemetry/server'; import { ObservabilityPluginSetup } from '../../../../../observability/server'; import { @@ -56,6 +61,7 @@ export interface UptimeServerSetup { logger: Logger; telemetry: TelemetryEventsSender; uptimeEsClient: UptimeESClient; + basePath: IBasePath; } export interface UptimeCorePluginsSetup { diff --git a/x-pack/plugins/uptime/server/lib/alerts/action_variables.ts b/x-pack/plugins/uptime/server/lib/alerts/action_variables.ts index 48fa6e45f19a8..763cccb404e51 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/action_variables.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/action_variables.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; export const MESSAGE = 'message'; export const MONITOR_WITH_GEO = 'downMonitorsWithGeo'; export const ALERT_REASON_MSG = 'reason'; +export const VIEW_IN_APP_URL = 'viewInAppUrl'; export const ACTION_VARIABLES = { [MESSAGE]: { @@ -40,4 +41,14 @@ export const ACTION_VARIABLES = { } ), }, + [VIEW_IN_APP_URL]: { + name: VIEW_IN_APP_URL, + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.context.viewInAppUrl.description', + { + defaultMessage: + 'Link to the view or feature within Elastic that can be used to investigate the alert and its context further', + } + ), + }, }; diff --git a/x-pack/plugins/uptime/server/lib/alerts/common.ts b/x-pack/plugins/uptime/server/lib/alerts/common.ts index 6bf9d28c2da9e..542aaa27819a3 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/common.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/common.ts @@ -7,6 +7,7 @@ import { isRight } from 'fp-ts/lib/Either'; import Mustache from 'mustache'; +import { IBasePath } from 'kibana/server'; import { UptimeCommonState, UptimeCommonStateType } from '../../../common/runtime_types'; export type UpdateUptimeAlertState = ( @@ -60,3 +61,7 @@ export const updateState: UpdateUptimeAlertState = (state, isTriggeredNow) => { export const generateAlertMessage = (messageTemplate: string, fields: Record) => { return Mustache.render(messageTemplate, { state: { ...fields } }); }; +export const getViewInAppUrl = (relativeViewInAppUrl: string, basePath: IBasePath) => + basePath.publicBaseUrl + ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() + : relativeViewInAppUrl; diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts index 208f19354a0f3..2848df14776b5 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.test.ts @@ -16,7 +16,7 @@ import { DynamicSettings } from '../../../common/runtime_types'; import { createRuleTypeMocks, bootstrapDependencies } from './test_utils'; import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; import { Ping } from '../../../common/runtime_types/ping'; -import { ALERT_REASON_MSG } from './action_variables'; +import { ALERT_REASON_MSG, VIEW_IN_APP_URL } from './action_variables'; interface MockAnomaly { severity: AnomaliesTableRecord['severity']; @@ -219,6 +219,7 @@ Response times as high as ${slowestResponse} ms have been detected from location "xpack.uptime.alerts.actionGroups.durationAnomaly", Object { "${ALERT_REASON_MSG}": "${reasonMessages[0]}", + "${VIEW_IN_APP_URL}": "http://localhost:5601/hfe/app/uptime/monitor/eHBhY2sudXB0aW1lLmFsZXJ0cy5hY3Rpb25Hcm91cHMuZHVyYXRpb25Bbm9tYWx5MA==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z", }, ] `); @@ -227,6 +228,7 @@ Response times as high as ${slowestResponse} ms have been detected from location "xpack.uptime.alerts.actionGroups.durationAnomaly", Object { "${ALERT_REASON_MSG}": "${reasonMessages[1]}", + "${VIEW_IN_APP_URL}": "http://localhost:5601/hfe/app/uptime/monitor/eHBhY2sudXB0aW1lLmFsZXJ0cy5hY3Rpb25Hcm91cHMuZHVyYXRpb25Bbm9tYWx5MQ==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z", }, ] `); diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index 1dcb91b9e5270..d1e83c917d6f7 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -13,7 +13,7 @@ import { ALERT_REASON, } from '@kbn/rule-data-utils'; import { ActionGroupIdsOf } from '../../../../alerting/common'; -import { updateState, generateAlertMessage } from './common'; +import { updateState, generateAlertMessage, getViewInAppUrl } from './common'; import { DURATION_ANOMALY } from '../../../common/constants/alerts'; import { commonStateTranslations, durationAnomalyTranslations } from './translations'; import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; @@ -24,9 +24,10 @@ import { Ping } from '../../../common/runtime_types/ping'; import { getMLJobId } from '../../../common/lib'; import { DurationAnomalyTranslations as CommonDurationAnomalyTranslations } from '../../../common/translations'; +import { getMonitorRouteFromMonitorId } from '../../../common/utils/get_monitor_url'; import { createUptimeESClient } from '../lib'; -import { ALERT_REASON_MSG, ACTION_VARIABLES } from './action_variables'; +import { ALERT_REASON_MSG, ACTION_VARIABLES, VIEW_IN_APP_URL } from './action_variables'; export type ActionGroupIds = ActionGroupIdsOf; @@ -72,7 +73,7 @@ const getAnomalies = async ( }; export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = ( - _server, + server, libs, plugins ) => ({ @@ -93,20 +94,23 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory }, ], actionVariables: { - context: [ACTION_VARIABLES[ALERT_REASON_MSG]], + context: [ACTION_VARIABLES[ALERT_REASON_MSG], ACTION_VARIABLES[VIEW_IN_APP_URL]], state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], }, isExportable: true, minimumLicenseRequired: 'platinum', async executor({ params, - services: { alertWithLifecycle, scopedClusterClient, savedObjectsClient }, + services: { alertWithLifecycle, scopedClusterClient, savedObjectsClient, getAlertStartedDate }, state, + startedAt, }) { const uptimeEsClient = createUptimeESClient({ esClient: scopedClusterClient.asCurrentUser, savedObjectsClient, }); + const { basePath } = server; + const { anomalies } = (await getAnomalies(plugins, savedObjectsClient, params, state.lastCheckedAt as string)) ?? {}; @@ -128,8 +132,16 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory summary ); + const alertId = DURATION_ANOMALY.id + index; + const indexedStartedAt = getAlertStartedDate(alertId) ?? startedAt.toISOString(); + const relativeViewInAppUrl = getMonitorRouteFromMonitorId({ + monitorId: DURATION_ANOMALY.id + index, + dateRangeEnd: 'now', + dateRangeStart: indexedStartedAt, + }); + const alertInstance = alertWithLifecycle({ - id: DURATION_ANOMALY.id + index, + id: alertId, fields: { 'monitor.id': params.monitorId, 'url.full': summary.monitorUrl, @@ -147,6 +159,7 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory }); alertInstance.scheduleActions(DURATION_ANOMALY.id, { [ALERT_REASON_MSG]: alertReasonMessage, + [VIEW_IN_APP_URL]: getViewInAppUrl(relativeViewInAppUrl, basePath), }); }); } diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts index d2e4a8dbc044e..84e7c0d68400c 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.test.ts @@ -243,6 +243,7 @@ describe('status check alert', () => { "xpack.uptime.alerts.actionGroups.monitorStatus", Object { "reason": "First from harrisburg failed 234 times in the last 15 mins. Alert when > 5.", + "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zmlyc3Q=?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22harrisburg%22%5D%5D%5D", }, ] `); @@ -313,6 +314,7 @@ describe('status check alert', () => { "xpack.uptime.alerts.actionGroups.monitorStatus", Object { "reason": "First from harrisburg failed 234 times in the last 15m. Alert when > 5.", + "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zmlyc3Q=?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22harrisburg%22%5D%5D%5D", }, ] `); @@ -784,24 +786,28 @@ describe('status check alert', () => { "xpack.uptime.alerts.actionGroups.monitorStatus", Object { "reason": "Foo from harrisburg 35 days availability is 99.28%. Alert when < 99.34%.", + "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zm9v?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22harrisburg%22%5D%5D%5D", }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { "reason": "Foo from fairbanks 35 days availability is 98.03%. Alert when < 99.34%.", + "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zm9v?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22fairbanks%22%5D%5D%5D", }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { "reason": "Unreliable from fairbanks 35 days availability is 90.92%. Alert when < 99.34%.", + "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/dW5yZWxpYWJsZQ==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22fairbanks%22%5D%5D%5D", }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { "reason": "no-name from fairbanks 35 days availability is 90.92%. Alert when < 99.34%.", + "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/bm8tbmFtZQ==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22fairbanks%22%5D%5D%5D", }, ], ] diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index fe93928cb7e02..6d9a0d23d9d32 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -5,6 +5,7 @@ * 2.0. */ import { min } from 'lodash'; + import datemath from '@elastic/datemath'; import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; @@ -18,7 +19,7 @@ import { GetMonitorAvailabilityParams, } from '../../../common/runtime_types'; import { MONITOR_STATUS } from '../../../common/constants/alerts'; -import { updateState } from './common'; +import { updateState, getViewInAppUrl } from './common'; import { commonMonitorStateI18, commonStateTranslations, @@ -36,7 +37,14 @@ import { getUptimeIndexPattern, IndexPatternTitleAndFields } from '../requests/g import { UMServerLibs, UptimeESClient, createUptimeESClient } from '../lib'; import { ActionGroupIdsOf } from '../../../../alerting/common'; import { formatDurationFromTimeUnitChar, TimeUnitChar } from '../../../../observability/common'; -import { ALERT_REASON_MSG, MESSAGE, MONITOR_WITH_GEO, ACTION_VARIABLES } from './action_variables'; +import { + ALERT_REASON_MSG, + MESSAGE, + MONITOR_WITH_GEO, + ACTION_VARIABLES, + VIEW_IN_APP_URL, +} from './action_variables'; +import { getMonitorRouteFromMonitorId } from '../../../common/utils/get_monitor_url'; export type ActionGroupIds = ActionGroupIdsOf; /** @@ -214,7 +222,7 @@ export const getInstanceId = (monitorInfo: Ping, monIdByLoc: string) => { return `${urlText}_${monIdByLoc}`; }; -export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ id: 'xpack.uptime.alerts.monitorStatus', producer: 'uptime', name: i18n.translate('xpack.uptime.alerts.monitorStatus', { @@ -272,6 +280,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( ACTION_VARIABLES[MESSAGE], ACTION_VARIABLES[MONITOR_WITH_GEO], ACTION_VARIABLES[ALERT_REASON_MSG], + ACTION_VARIABLES[VIEW_IN_APP_URL], ], state: [...commonMonitorStateI18, ...commonStateTranslations], }, @@ -280,10 +289,11 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( async executor({ params: rawParams, state, - services: { savedObjectsClient, scopedClusterClient, alertWithLifecycle }, + services: { savedObjectsClient, scopedClusterClient, alertWithLifecycle, getAlertStartedDate }, rule: { schedule: { interval }, }, + startedAt, }) { const { filters, @@ -297,7 +307,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( isAutoGenerated, timerange: oldVersionTimeRange, } = rawParams; - + const { basePath } = server; const uptimeEsClient = createUptimeESClient({ esClient: scopedClusterClient.asCurrentUser, savedObjectsClient, @@ -336,7 +346,6 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( if (isAutoGenerated) { for (const monitorLoc of downMonitorsByLocation) { const monitorInfo = monitorLoc.monitorInfo; - const monitorStatusMessageParams = getMonitorDownStatusMessageParams( monitorInfo, monitorLoc.count, @@ -348,8 +357,10 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( const statusMessage = getStatusMessage(monitorStatusMessageParams); const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); + const alertId = getInstanceId(monitorInfo, monitorLoc.location); + const indexedStartedAt = getAlertStartedDate(alertId) ?? startedAt.toISOString(); const alert = alertWithLifecycle({ - id: getInstanceId(monitorInfo, monitorLoc.location), + id: alertId, fields: getMonitorAlertDocument(monitorSummary), }); @@ -360,8 +371,18 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( ...updateState(state, true), }); + const relativeViewInAppUrl = getMonitorRouteFromMonitorId({ + monitorId: monitorSummary.monitorId, + dateRangeEnd: 'now', + dateRangeStart: indexedStartedAt, + filters: { + 'observer.geo.name': [monitorSummary.observerLocation], + }, + }); + alert.scheduleActions(MONITOR_STATUS.id, { [ALERT_REASON_MSG]: monitorSummary.reason, + [VIEW_IN_APP_URL]: getViewInAppUrl(relativeViewInAppUrl, basePath), }); } return updateState(state, downMonitorsByLocation.length > 0); @@ -408,8 +429,10 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( availability ); const monitorSummary = getMonitorSummary(monitorInfo, statusMessage); + const alertId = getInstanceId(monitorInfo, monIdByLoc); + const indexedStartedAt = getAlertStartedDate(alertId) ?? startedAt.toISOString(); const alert = alertWithLifecycle({ - id: getInstanceId(monitorInfo, monIdByLoc), + id: alertId, fields: getMonitorAlertDocument(monitorSummary), }); @@ -418,12 +441,20 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( ...monitorSummary, statusMessage, }); + const relativeViewInAppUrl = getMonitorRouteFromMonitorId({ + monitorId: monitorSummary.monitorId, + dateRangeEnd: 'now', + dateRangeStart: indexedStartedAt, + filters: { + 'observer.geo.name': [monitorSummary.observerLocation], + }, + }); alert.scheduleActions(MONITOR_STATUS.id, { [ALERT_REASON_MSG]: monitorSummary.reason, + [VIEW_IN_APP_URL]: getViewInAppUrl(relativeViewInAppUrl, basePath), }); }); - return updateState(state, downMonitorsByLocation.length > 0); }, }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts index 826259cfa1405..374719172405f 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/test_utils/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Logger } from 'kibana/server'; +import { IBasePath, Logger } from 'kibana/server'; import { UMServerLibs } from '../../lib'; import { UptimeCorePluginsSetup, UptimeServerSetup } from '../../adapters'; import type { UptimeRouter } from '../../../types'; @@ -25,9 +25,16 @@ import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; */ export const bootstrapDependencies = (customRequests?: any, customPlugins: any = {}) => { const router = {} as UptimeRouter; + const basePath = { + prepend: (url: string) => { + return `/hfe${url}`; + }, + publicBaseUrl: 'http://localhost:5601/hfe', + serverBasePath: '/hfe', + } as IBasePath; // these server/libs parameters don't have any functionality, which is fine // because we aren't testing them here - const server = { router, config: {} } as UptimeServerSetup; + const server = { router, config: {}, basePath } as UptimeServerSetup; const plugins: UptimeCorePluginsSetup = customPlugins as any; const libs: UMServerLibs = { requests: {} } as UMServerLibs; libs.requests = { ...libs.requests, ...customRequests }; @@ -56,6 +63,7 @@ export const createRuleTypeMocks = ( ...getUptimeESMockClient(), ...alertsMock.createAlertServices(), alertWithLifecycle: jest.fn().mockReturnValue({ scheduleActions, replaceState }), + getAlertStartedDate: jest.fn().mockReturnValue('2022-03-17T13:13:33.755Z'), logger: loggerMock, }; diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts index e8e496cba997e..5275cddae9d24 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -26,6 +26,7 @@ export type DefaultUptimeAlertInstance = AlertTy AlertInstanceContext, TActionGroupIds >; + getAlertStartedDate: (alertId: string) => string | null; } >; diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index d2afb3f16fb6a..2f329aa83a5c4 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -78,6 +78,7 @@ export class Plugin implements PluginType { router: core.http.createRouter(), cloud: plugins.cloud, kibanaVersion: this.initContext.env.packageInfo.version, + basePath: core.http.basePath, logger: this.logger, telemetry: this.telemetryEventsSender, } as UptimeServerSetup; From 36c614ab0b953aeec31bdbc7653e4298fce0ab7f Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 23 Mar 2022 13:16:42 +0100 Subject: [PATCH 069/132] Bump minimist from v1.2.5 to v1.2.6 (#128348) --- package.json | 6 +++--- packages/kbn-pm/dist/index.js | 8 ++++++-- yarn.lock | 16 ++++++++-------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index a847db572fa4c..24367fa77216e 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "**/istanbul-lib-coverage": "^3.2.0", "**/json-schema": "^0.4.0", "**/minimatch": "^3.1.2", - "**/minimist": "^1.2.5", + "**/minimist": "^1.2.6", "**/node-forge": "^1.3.0", "**/pdfkit/crypto-js": "4.0.0", "**/react-syntax-highlighter": "^15.3.1", @@ -648,7 +648,7 @@ "@types/mime": "^2.0.1", "@types/mime-types": "^2.1.0", "@types/minimatch": "^2.0.29", - "@types/minimist": "^1.2.1", + "@types/minimist": "^1.2.2", "@types/mocha": "^9.1.0", "@types/mock-fs": "^4.13.1", "@types/moment-timezone": "^0.5.12", @@ -841,7 +841,7 @@ "lmdb-store": "^1.6.11", "marge": "^1.0.1", "micromatch": "3.1.10", - "minimist": "^1.2.5", + "minimist": "^1.2.6", "mkdirp": "0.5.1", "mocha": "^9.1.0", "mocha-junit-reporter": "^2.0.2", diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index d04285f60b561..99ab81d2b539f 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -16447,7 +16447,7 @@ module.exports = function (args, opts) { var o = obj; for (var i = 0; i < keys.length-1; i++) { var key = keys[i]; - if (key === '__proto__') return; + if (isConstructorOrProto(o, key)) return; if (o[key] === undefined) o[key] = {}; if (o[key] === Object.prototype || o[key] === Number.prototype || o[key] === String.prototype) o[key] = {}; @@ -16456,7 +16456,7 @@ module.exports = function (args, opts) { } var key = keys[keys.length - 1]; - if (key === '__proto__') return; + if (isConstructorOrProto(o, key)) return; if (o === Object.prototype || o === Number.prototype || o === String.prototype) o = {}; if (o === Array.prototype) o = []; @@ -16621,6 +16621,10 @@ function isNumber (x) { } +function isConstructorOrProto (obj, key) { + return key === 'constructor' && typeof obj[key] === 'function' || key === '__proto__'; +} + /***/ }), /* 229 */ diff --git a/yarn.lock b/yarn.lock index 396cda03b1235..5163a6e68be50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6284,10 +6284,10 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-2.0.29.tgz#5002e14f75e2d71e564281df0431c8c1b4a2a36a" integrity sha1-UALhT3Xi1x5WQoHfBDHIwbSio2o= -"@types/minimist@^1.2.0", "@types/minimist@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256" - integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== +"@types/minimist@^1.2.0", "@types/minimist@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" + integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== "@types/minipass@*": version "2.2.0" @@ -20059,10 +20059,10 @@ minimist-options@4.1.0, minimist-options@^4.0.2: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@0.0.8, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@~1.2.0: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minimist@0.0.8, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@~1.2.0: + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== minipass-collect@^1.0.2: version "1.0.2" From 30b507545b70e762973f676b2c33032e351a94ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 23 Mar 2022 09:24:59 -0400 Subject: [PATCH 070/132] [APM] Service groups: Add EuiTour steps for assisting users in creating their first service group and provide guidance on the navigation changes (#128068) --- .../service_group_save/create_button.tsx | 47 +++++++++++ .../service_group_save/edit_button.tsx | 47 +++++++++++ .../service_group_save/save_button.tsx | 31 +++----- .../service_group_card.tsx | 37 ++++++++- .../service_groups_list.tsx | 1 + .../service_groups/service_groups_tour.tsx | 78 +++++++++++++++++++ .../use_service_groups_tour.tsx | 31 ++++++++ 7 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/service_groups_tour.tsx create mode 100644 x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx new file mode 100644 index 0000000000000..e80fae8581271 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/create_button.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ServiceGroupsTour } from '../service_groups_tour'; +import { useServiceGroupsTour } from '../use_service_groups_tour'; + +interface Props { + onClick: () => void; +} + +export function CreateButton({ onClick }: Props) { + const { tourEnabled, dismissTour } = useServiceGroupsTour('createGroup'); + return ( + + { + dismissTour(); + onClick(); + }} + > + {i18n.translate('xpack.apm.serviceGroups.createGroupLabel', { + defaultMessage: 'Create group', + })} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx new file mode 100644 index 0000000000000..8325ffd401957 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/edit_button.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ServiceGroupsTour } from '../service_groups_tour'; +import { useServiceGroupsTour } from '../use_service_groups_tour'; + +interface Props { + onClick: () => void; +} + +export function EditButton({ onClick }: Props) { + const { tourEnabled, dismissTour } = useServiceGroupsTour('editGroup'); + return ( + + { + dismissTour(); + onClick(); + }} + > + {i18n.translate('xpack.apm.serviceGroups.editGroupLabel', { + defaultMessage: 'Edit group', + })} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx index 61828e240c20a..c0da29625d7ca 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_group_save/save_button.tsx @@ -4,11 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; +import { CreateButton } from './create_button'; +import { EditButton } from './edit_button'; import { SaveGroupModal } from './save_modal'; export function ServiceGroupSaveButton() { @@ -32,16 +32,18 @@ export function ServiceGroupSaveButton() { ); const savedServiceGroup = data?.serviceGroup; + function onClick() { + setIsModalVisible((state) => !state); + } + return ( <> - { - setIsModalVisible((state) => !state); - }} - > - {isGroupEditMode ? EDIT_GROUP_LABEL : CREATE_GROUP_LABEL} - + {isGroupEditMode ? ( + + ) : ( + + )} + {isModalVisible && ( ); } - -const CREATE_GROUP_LABEL = i18n.translate( - 'xpack.apm.serviceGroups.createGroupLabel', - { defaultMessage: 'Create group' } -); -const EDIT_GROUP_LABEL = i18n.translate( - 'xpack.apm.serviceGroups.editGroupLabel', - { defaultMessage: 'Edit group' } -); diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx index 0975bbb4ae307..a73b849ee014e 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_group_card.tsx @@ -18,12 +18,15 @@ import { ServiceGroup, SERVICE_GROUP_COLOR_DEFAULT, } from '../../../../../common/service_groups'; +import { ServiceGroupsTour } from '../service_groups_tour'; +import { useServiceGroupsTour } from '../use_service_groups_tour'; interface Props { serviceGroup: ServiceGroup; hideServiceCount?: boolean; onClick?: () => void; href?: string; + withTour?: boolean; } export function ServiceGroupsCard({ @@ -31,7 +34,10 @@ export function ServiceGroupsCard({ hideServiceCount = false, onClick, href, + withTour, }: Props) { + const { tourEnabled, dismissTour } = useServiceGroupsTour('serviceGroupCard'); + const cardProps: EuiCardProps = { style: { width: 286, height: 186 }, icon: ( @@ -69,10 +75,39 @@ export function ServiceGroupsCard({ )}
), - onClick, + onClick: () => { + dismissTour(); + if (onClick) { + onClick(); + } + }, href, }; + if (withTour) { + return ( + + + + + + ); + } + return ( diff --git a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx index 06c138f7f01cd..224e9822a8b60 100644 --- a/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx +++ b/x-pack/plugins/apm/public/components/app/service_groups/service_groups_list/service_groups_list.tsx @@ -38,6 +38,7 @@ export function ServiceGroupsListItems({ items }: Props) { /> ))} void; + children: React.ReactElement; +} + +export function ServiceGroupsTour({ + tourEnabled, + dismissTour, + title, + content, + children, +}: Props) { + return ( + + {content} + + + {i18n.translate( + 'xpack.apm.serviceGroups.tour.content.link.docs', + { + defaultMessage: 'docs', + } + )} + + ), + }} + /> + + } + isStepOpen={tourEnabled} + onFinish={() => {}} + maxWidth={300} + minWidth={300} + step={1} + stepsTotal={1} + title={title} + anchorPosition="leftUp" + footerAction={ + + {i18n.translate('xpack.apm.serviceGroups.tour.dismiss', { + defaultMessage: 'Dismiss', + })} + + } + > + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx b/x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx new file mode 100644 index 0000000000000..ba27b0e2640e8 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_groups/use_service_groups_tour.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useLocalStorage } from '../../../hooks/use_local_storage'; +import { TourType } from './service_groups_tour'; + +const INITIAL_STATE: Record = { + createGroup: true, + editGroup: true, + serviceGroupCard: true, +}; + +export function useServiceGroupsTour(type: TourType) { + const [tourEnabled, setTourEnabled] = useLocalStorage( + 'apm.serviceGroupsTour', + INITIAL_STATE + ); + + return { + tourEnabled: tourEnabled[type], + dismissTour: () => + setTourEnabled({ + ...tourEnabled, + [type]: false, + }), + }; +} From 6be9d1e79793f7d2a5995ef29689af382a872bd2 Mon Sep 17 00:00:00 2001 From: Dmitrii Shevchenko Date: Wed, 23 Mar 2022 14:36:13 +0100 Subject: [PATCH 071/132] [Security Solution][Detections] Cleanup usages of old bulk rule CRUD endpoints (#126068) * Cleanup usages of old bulk CRUD endpoints * Apply suggestions from code review Co-authored-by: Garrett Spong Co-authored-by: Garrett Spong --- .../detection_alerts/acknowledged.spec.ts | 2 +- .../detection_rules/export_rule.spec.ts | 12 +- .../security_solution/cypress/tasks/common.ts | 22 +- .../cypress/tasks/rule_details.ts | 5 +- .../cypress/tasks/rules_bulk_edit.ts | 4 +- .../common/hooks/use_app_toasts.mock.ts | 14 +- .../rule_actions_overflow/index.test.tsx | 58 ++-- .../rules/rule_actions_overflow/index.tsx | 61 ++-- .../rules/rule_switch/index.test.tsx | 40 +-- .../components/rules/rule_switch/index.tsx | 31 +- .../detection_engine/rules/api.test.ts | 83 ----- .../containers/detection_engine/rules/api.ts | 58 ---- .../detection_engine/rules/types.ts | 13 - .../detection_engine/rules/all/actions.ts | 307 ++++++++--------- .../bulk_actions/bulk_edit_confirmation.tsx | 2 +- .../all/bulk_actions/use_bulk_actions.tsx | 148 +++----- .../rules/all/helpers.test.ts | 45 +-- .../detection_engine/rules/all/helpers.ts | 26 +- .../rules/all/rules_table_actions.test.tsx | 51 +-- .../rules/all/rules_table_actions.tsx | 51 +-- .../rules/all/use_columns.tsx | 8 +- .../detection_engine/rules/translations.ts | 319 ++++++++++++------ .../routes/rules/perform_bulk_action_route.ts | 6 + .../translations/translations/fr-FR.json | 10 +- .../translations/translations/ja-JP.json | 17 +- .../translations/translations/zh-CN.json | 17 +- 26 files changed, 615 insertions(+), 795 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts index 12f5587ce0d6c..a65abae52ae7e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/acknowledged.spec.ts @@ -72,7 +72,7 @@ describe('Marking alerts as acknowledged with read only role', () => { loginAndWaitForPage(ALERTS_URL, ROLES.t2_analyst); createCustomRuleEnabled(getNewRule()); refreshPage(); - waitForAlertsToPopulate(100); + waitForAlertsToPopulate(500); }); it('Mark one alert as acknowledged when more than one open alerts are selected', () => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts index 7b84845d46323..8a6527c502b42 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/export_rule.spec.ts @@ -7,7 +7,7 @@ import { expectedExportedRule, getNewRule } from '../../objects/rule'; -import { TOASTER } from '../../screens/alerts_detection_rules'; +import { TOASTER_BODY } from '../../screens/alerts_detection_rules'; import { exportFirstRule } from '../../tasks/alerts_detection_rules'; import { createCustomRule } from '../../tasks/api_calls/rules'; @@ -19,19 +19,17 @@ import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; describe('Export rules', () => { beforeEach(() => { cleanKibana(); - cy.intercept( - 'POST', - '/api/detection_engine/rules/_export?exclude_export_details=false&file_name=rules_export.ndjson' - ).as('export'); + // Rules get exported via _bulk_action endpoint + cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action'); loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); createCustomRule(getNewRule()).as('ruleResponse'); }); it('Exports a custom rule', function () { exportFirstRule(); - cy.wait('@export').then(({ response }) => { + cy.wait('@bulk_action').then(({ response }) => { cy.wrap(response?.body).should('eql', expectedExportedRule(this.ruleResponse)); - cy.get(TOASTER).should( + cy.get(TOASTER_BODY).should( 'have.text', 'Successfully exported 1 of 1 rule. Prebuilt rules were excluded from the resulting file.' ); diff --git a/x-pack/plugins/security_solution/cypress/tasks/common.ts b/x-pack/plugins/security_solution/cypress/tasks/common.ts index 65480e52dea40..bafe429180fd1 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/common.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/common.ts @@ -6,7 +6,6 @@ */ import { esArchiverResetKibana } from './es_archiver'; -import { RuleEcs } from '../../common/ecs/rule'; import { LOADING_INDICATOR } from '../screens/security_header'; const primaryButton = 0; @@ -68,19 +67,14 @@ export const reload = () => { export const cleanKibana = () => { const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; - cy.request('GET', '/api/detection_engine/rules/_find').then((response) => { - const rules: RuleEcs[] = response.body.data; - - if (response.body.data.length > 0) { - rules.forEach((rule) => { - const jsonRule = rule; - cy.request({ - method: 'DELETE', - url: `/api/detection_engine/rules?rule_id=${jsonRule.rule_id}`, - headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, - }); - }); - } + cy.request({ + method: 'POST', + url: '/api/detection_engine/rules/_bulk_action', + body: { + query: '', + action: 'delete', + }, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, }); cy.request('POST', `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`, { diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index d42ebcf9da68e..35def6967485c 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -32,10 +32,11 @@ import { import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const enablesRule = () => { - cy.intercept('PATCH', '/api/detection_engine/rules/_bulk_update').as('bulk_update'); + // Rules get enabled via _bulk_action endpoint + cy.intercept('POST', '/api/detection_engine/rules/_bulk_action').as('bulk_action'); cy.get(RULE_SWITCH).should('be.visible'); cy.get(RULE_SWITCH).click(); - cy.wait('@bulk_update').then(({ response }) => { + cy.wait('@bulk_action').then(({ response }) => { cy.wrap(response?.statusCode).should('eql', 200); }); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts b/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts index b665762fbd0c5..387fe63cad9cb 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rules_bulk_edit.ts @@ -74,7 +74,7 @@ export const confirmBulkEditForm = () => cy.get(RULES_BULK_EDIT_FORM_CONFIRM_BTN export const waitForBulkEditActionToFinish = ({ rulesCount }: { rulesCount: number }) => { cy.get(BULK_ACTIONS_PROGRESS_BTN).should('be.disabled'); - cy.contains(TOASTER_BODY, `You’ve successfully updated ${rulesCount} rule`); + cy.contains(TOASTER_BODY, `You've successfully updated ${rulesCount} rule`); }; export const waitForElasticRulesBulkEditModal = (rulesCount: number) => { @@ -99,6 +99,6 @@ export const waitForMixedRulesBulkEditModal = ( cy.get(MODAL_CONFIRMATION_BODY).should( 'have.text', - `The update action will only be applied to ${customRulesCount} Custom rules you’ve selected.` + `The update action will only be applied to ${customRulesCount} Custom rules you've selected.` ); }; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts index 25c0f5411f25c..c0bb52b20c534 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.mock.ts @@ -5,10 +5,22 @@ * 2.0. */ -const createAppToastsMock = () => ({ +import { UseAppToasts } from './use_app_toasts'; + +const createAppToastsMock = (): jest.Mocked => ({ addError: jest.fn(), addSuccess: jest.fn(), addWarning: jest.fn(), + api: { + get$: jest.fn(), + add: jest.fn(), + remove: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + addDanger: jest.fn(), + addError: jest.fn(), + addInfo: jest.fn(), + }, }); export const useAppToastsMock = { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 6a62b05c2e319..3037a3c82f946 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -9,13 +9,13 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, + goToRuleEditPage, + executeRulesBulkAction, } from '../../../pages/detection_engine/rules/all/actions'; import { RuleActionsOverflow } from './index'; import { mockRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; +jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../../common/lib/kibana', () => { const actual = jest.requireActual('../../../../common/lib/kibana'); return { @@ -29,25 +29,9 @@ jest.mock('../../../../common/lib/kibana', () => { }), }; }); +jest.mock('../../../pages/detection_engine/rules/all/actions'); -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ - push: jest.fn(), - }), -})); - -jest.mock('../../../pages/detection_engine/rules/all/actions', () => { - const actual = jest.requireActual('../../../../common/lib/kibana'); - return { - ...actual, - exportRulesAction: jest.fn(), - deleteRulesAction: jest.fn(), - duplicateRulesAction: jest.fn(), - editRuleAction: jest.fn(), - }; -}); - -const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; +const executeRulesBulkActionMock = executeRulesBulkAction as jest.Mock; const flushPromises = () => new Promise(setImmediate); describe('RuleActionsOverflow', () => { @@ -206,7 +190,9 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-duplicate-rule"] button').simulate('click'); wrapper.update(); - expect(duplicateRulesAction).toHaveBeenCalled(); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate' }) + ); }); test('it calls duplicateRulesAction with the rule and rule.id when rules-details-duplicate-rule is clicked', () => { @@ -218,11 +204,8 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-duplicate-rule"] button').simulate('click'); wrapper.update(); - expect(duplicateRulesAction).toHaveBeenCalledWith( - [rule], - [rule.id], - expect.anything(), - expect.anything() + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate', search: { ids: ['id'] } }) ); }); }); @@ -230,7 +213,9 @@ describe('RuleActionsOverflow', () => { test('it calls editRuleAction after the rule is duplicated', async () => { const rule = mockRule('id'); const ruleDuplicate = mockRule('newRule'); - duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); + executeRulesBulkActionMock.mockImplementation(() => + Promise.resolve({ attributes: { results: { created: [ruleDuplicate] } } }) + ); const wrapper = mount( ); @@ -240,8 +225,10 @@ describe('RuleActionsOverflow', () => { wrapper.update(); await flushPromises(); - expect(duplicateRulesAction).toHaveBeenCalled(); - expect(editRuleAction).toHaveBeenCalledWith(ruleDuplicate.id, expect.anything()); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate' }) + ); + expect(goToRuleEditPage).toHaveBeenCalledWith(ruleDuplicate.id, expect.anything()); }); describe('rules details export rule', () => { @@ -340,7 +327,9 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-delete-rule"] button').simulate('click'); wrapper.update(); - expect(deleteRulesAction).toHaveBeenCalled(); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'delete' }) + ); }); test('it calls deleteRulesAction with the rule.id when rules-details-delete-rule is clicked', () => { @@ -352,11 +341,8 @@ describe('RuleActionsOverflow', () => { wrapper.update(); wrapper.find('[data-test-subj="rules-details-delete-rule"] button').simulate('click'); wrapper.update(); - expect(deleteRulesAction).toHaveBeenCalledWith( - [rule.id], - expect.anything(), - expect.anything(), - expect.anything() + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'delete', search: { ids: ['id'] } }) ); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index c97ae9d7d7756..d45159c61ce49 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -12,32 +12,30 @@ import { EuiPopover, EuiToolTip, } from '@elastic/eui'; +import { noop } from 'lodash'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; - -import { noop } from 'lodash/fp'; -import { Rule } from '../../../containers/detection_engine/rules'; -import * as i18n from './translations'; -import * as i18nActions from '../../../pages/detection_engine/rules/translations'; -import { useStateToaster } from '../../../../common/components/toasters'; -import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, - exportRulesAction, -} from '../../../pages/detection_engine/rules/all/actions'; +import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; +import { BulkAction } from '../../../../../common/detection_engine/schemas/common'; import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; -import { getToolTipContent } from '../../../../common/utils/privileges'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useBoolState } from '../../../../common/hooks/use_bool_state'; import { useKibana } from '../../../../common/lib/kibana'; -import { APP_UI_ID, SecurityPageName } from '../../../../../common/constants'; +import { getToolTipContent } from '../../../../common/utils/privileges'; +import { Rule } from '../../../containers/detection_engine/rules'; +import { + executeRulesBulkAction, + goToRuleEditPage, +} from '../../../pages/detection_engine/rules/all/actions'; +import * as i18nActions from '../../../pages/detection_engine/rules/translations'; +import * as i18n from './translations'; const MyEuiButtonIcon = styled(EuiButtonIcon)` &.euiButtonIcon { svg { transform: rotate(90deg); } - border: 1px solid  ${({ theme }) => theme.euiColorPrimary}; + border: 1px solid ${({ theme }) => theme.euiColorPrimary}; width: 40px; height: 40px; } @@ -59,7 +57,7 @@ const RuleActionsOverflowComponent = ({ }: RuleActionsOverflowComponentProps) => { const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const { navigateToApp } = useKibana().services.application; - const [, dispatchToaster] = useStateToaster(); + const toasts = useAppToasts(); const onRuleDeletedCallback = useCallback(() => { navigateToApp(APP_UI_ID, { @@ -79,14 +77,15 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-duplicate-rule" onClick={async () => { closePopover(); - const createdRules = await duplicateRulesAction( - [rule], - [rule.id], - dispatchToaster, - noop - ); + const result = await executeRulesBulkAction({ + action: BulkAction.duplicate, + onSuccess: noop, + search: { ids: [rule.id] }, + toasts, + }); + const createdRules = result?.attributes.results.created; if (createdRules?.length) { - editRuleAction(createdRules[0].id, navigateToApp); + goToRuleEditPage(createdRules[0].id, navigateToApp); } }} > @@ -104,7 +103,12 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-export-rule" onClick={async () => { closePopover(); - await exportRulesAction([rule.rule_id], dispatchToaster, noop); + await executeRulesBulkAction({ + action: BulkAction.export, + onSuccess: noop, + search: { ids: [rule.id] }, + toasts, + }); }} > {i18nActions.EXPORT_RULE} @@ -116,7 +120,12 @@ const RuleActionsOverflowComponent = ({ data-test-subj="rules-details-delete-rule" onClick={async () => { closePopover(); - await deleteRulesAction([rule.id], dispatchToaster, noop, onRuleDeletedCallback); + await executeRulesBulkAction({ + action: BulkAction.delete, + onSuccess: onRuleDeletedCallback, + search: { ids: [rule.id] }, + toasts, + }); }} > {i18nActions.DELETE_RULE} @@ -126,10 +135,10 @@ const RuleActionsOverflowComponent = ({ [ canDuplicateRuleWithActions, closePopover, - dispatchToaster, navigateToApp, onRuleDeletedCallback, rule, + toasts, userHasPermissions, ] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx index 7c10fd63b463a..ec643962d41a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.test.tsx @@ -9,22 +9,30 @@ import { mount } from 'enzyme'; import React from 'react'; import { waitFor } from '@testing-library/react'; -import { enableRules } from '../../../containers/detection_engine/rules'; +import { performBulkAction } from '../../../containers/detection_engine/rules'; import { RuleSwitchComponent } from './index'; import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { useStateToaster, displayErrorToast } from '../../../../common/components/toasters'; import { useRulesTableContextOptional } from '../../../pages/detection_engine/rules/all/rules_table/rules_table_context'; import { useRulesTableContextMock } from '../../../pages/detection_engine/rules/all/rules_table/__mocks__/rules_table_context'; import { TestProviders } from '../../../../common/mock'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -jest.mock('../../../../common/components/toasters'); +jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('../../../containers/detection_engine/rules'); jest.mock('../../../pages/detection_engine/rules/all/rules_table/rules_table_context'); +const useAppToastsValueMock = useAppToastsMock.create(); + describe('RuleSwitch', () => { beforeEach(() => { - (useStateToaster as jest.Mock).mockImplementation(() => [[], jest.fn()]); - (enableRules as jest.Mock).mockResolvedValue([getRulesSchemaMock()]); + (useAppToasts as jest.Mock).mockReturnValue(useAppToastsValueMock); + (performBulkAction as jest.Mock).mockResolvedValue({ + attributes: { + summary: { created: 0, updated: 1, deleted: 0 }, + results: { updated: [getRulesSchemaMock()] }, + }, + }); (useRulesTableContextOptional as jest.Mock).mockReturnValue(null); }); @@ -65,25 +73,7 @@ describe('RuleSwitch', () => { test('it dispatches error toaster if "enableRules" call rejects', async () => { const mockError = new Error('uh oh'); - (enableRules as jest.Mock).mockRejectedValue(mockError); - - const wrapper = mount(, { - wrappingComponent: TestProviders, - }); - wrapper.find('[data-test-subj="ruleSwitch"]').at(2).simulate('click'); - - await waitFor(() => { - wrapper.update(); - expect(displayErrorToast).toHaveBeenCalledTimes(1); - }); - }); - - test('it dispatches error toaster if "enableRules" call resolves with some errors', async () => { - (enableRules as jest.Mock).mockResolvedValue([ - getRulesSchemaMock(), - { error: { status_code: 400, message: 'error' } }, - { error: { status_code: 400, message: 'error' } }, - ]); + (performBulkAction as jest.Mock).mockRejectedValue(mockError); const wrapper = mount(, { wrappingComponent: TestProviders, @@ -92,7 +82,7 @@ describe('RuleSwitch', () => { await waitFor(() => { wrapper.update(); - expect(displayErrorToast).toHaveBeenCalledTimes(1); + expect(useAppToastsValueMock.addError).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx index 893a0d4d8de8b..a5f80de7acbdc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_switch/index.tsx @@ -12,11 +12,13 @@ import { EuiSwitch, EuiSwitchEvent, } from '@elastic/eui'; +import { noop } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { useStateToaster } from '../../../../common/components/toasters'; +import { BulkAction } from '../../../../../common/detection_engine/schemas/common'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useUpdateRulesCache } from '../../../containers/detection_engine/rules/use_find_rules_query'; -import { enableRulesAction } from '../../../pages/detection_engine/rules/all/actions'; +import { executeRulesBulkAction } from '../../../pages/detection_engine/rules/all/actions'; import { useRulesTableContextOptional } from '../../../pages/detection_engine/rules/all/rules_table/rules_table_context'; const StaticSwitch = styled(EuiSwitch)` @@ -47,26 +49,29 @@ export const RuleSwitchComponent = ({ onChange, }: RuleSwitchProps) => { const [myIsLoading, setMyIsLoading] = useState(false); - const [, dispatchToaster] = useStateToaster(); const rulesTableContext = useRulesTableContextOptional(); const updateRulesCache = useUpdateRulesCache(); + const toasts = useAppToasts(); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { setMyIsLoading(true); - const rules = await enableRulesAction( - [id], - event.target.checked, - dispatchToaster, - rulesTableContext?.actions.setLoadingRules - ); - if (rules?.[0]) { - updateRulesCache(rules); - onChange?.(rules[0].enabled); + const bulkActionResponse = await executeRulesBulkAction({ + setLoadingRules: rulesTableContext?.actions.setLoadingRules, + toasts, + onSuccess: rulesTableContext ? undefined : noop, + action: event.target.checked ? BulkAction.enable : BulkAction.disable, + search: { ids: [id] }, + visibleRuleIds: [], + }); + if (bulkActionResponse?.attributes.results.updated.length) { + // The rule was successfully updated + updateRulesCache(bulkActionResponse.attributes.results.updated); + onChange?.(bulkActionResponse.attributes.results.updated[0].enabled); } setMyIsLoading(false); }, - [dispatchToaster, id, onChange, rulesTableContext?.actions.setLoadingRules, updateRulesCache] + [id, onChange, rulesTableContext, toasts, updateRulesCache] ); const showLoader = useMemo((): boolean => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index 004d1c3b7693c..ecfa98bfa3076 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -12,9 +12,6 @@ import { patchRule, fetchRules, fetchRuleById, - enableRules, - deleteRules, - duplicateRules, createPrepackagedRules, importRules, exportRules, @@ -395,86 +392,6 @@ describe('Detections Rules API', () => { }); }); - describe('enableRules', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(rulesMock); - }); - - test('check parameter url, body when enabling rules', async () => { - await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: true }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_update', { - body: '[{"id":"mySuperRuleId","enabled":true},{"id":"mySuperRuleId_II","enabled":true}]', - method: 'PATCH', - }); - }); - test('check parameter url, body when disabling rules', async () => { - await enableRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'], enabled: false }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_update', { - body: '[{"id":"mySuperRuleId","enabled":false},{"id":"mySuperRuleId_II","enabled":false}]', - method: 'PATCH', - }); - }); - test('happy path', async () => { - const ruleResp = await enableRules({ - ids: ['mySuperRuleId', 'mySuperRuleId_II'], - enabled: true, - }); - expect(ruleResp).toEqual(rulesMock); - }); - }); - - describe('deleteRules', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(rulesMock); - }); - - test('check parameter url, body when deleting rules', async () => { - await deleteRules({ ids: ['mySuperRuleId', 'mySuperRuleId_II'] }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_delete', { - body: '[{"id":"mySuperRuleId"},{"id":"mySuperRuleId_II"}]', - method: 'POST', - }); - }); - - test('happy path', async () => { - const ruleResp = await deleteRules({ - ids: ['mySuperRuleId', 'mySuperRuleId_II'], - }); - expect(ruleResp).toEqual(rulesMock); - }); - }); - - describe('duplicateRules', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(rulesMock); - }); - - test('check parameter url, body when duplicating rules', async () => { - await duplicateRules({ rules: rulesMock.data }); - expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', { - body: '[{"actions":[],"author":[],"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"risk_score_mapping":[],"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","severity_mapping":[],"tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1},{"actions":[],"author":[],"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"risk_score_mapping":[],"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","severity_mapping":[],"tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1}]', - method: 'POST', - }); - }); - - test('check duplicated rules are disabled by default', async () => { - await duplicateRules({ rules: rulesMock.data.map((rule) => ({ ...rule, enabled: true })) }); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [path, options] = fetchMock.mock.calls[0]; - expect(path).toBe('/api/detection_engine/rules/_bulk_create'); - const rules = JSON.parse(options.body); - expect(rules).toMatchObject([{ enabled: false }, { enabled: false }]); - }); - - test('happy path', async () => { - const ruleResp = await duplicateRules({ rules: rulesMock.data }); - expect(ruleResp).toEqual(rulesMock); - }); - }); - describe('createPrepackagedRules', () => { beforeEach(() => { fetchMock.mockClear(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 427cf28ef8f2f..5b29671d05bac 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -30,9 +30,6 @@ import { import { UpdateRulesProps, CreateRulesProps, - DeleteRulesProps, - DuplicateRulesProps, - EnableRulesProps, FetchRulesProps, FetchRulesResponse, Rule, @@ -42,7 +39,6 @@ import { ExportDocumentsProps, ImportDataResponse, PrePackagedRulesStatusResponse, - BulkRuleResponse, PatchRuleProps, BulkActionProps, BulkActionResponseMap, @@ -197,60 +193,6 @@ export const pureFetchRuleById = async ({ signal, }); -/** - * Enables/Disables provided Rule ID's - * - * @param ids array of Rule ID's (not rule_id) to enable/disable - * @param enabled to enable or disable - * - * @throws An error if response is not OK - */ -export const enableRules = async ({ ids, enabled }: EnableRulesProps): Promise => - KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_bulk_update`, { - method: 'PATCH', - body: JSON.stringify(ids.map((id) => ({ id, enabled }))), - }); - -/** - * Deletes provided Rule ID's - * - * @param ids array of Rule ID's (not rule_id) to delete - * - * @throws An error if response is not OK - */ -export const deleteRules = async ({ ids }: DeleteRulesProps): Promise => - KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, { - method: 'POST', - body: JSON.stringify(ids.map((id) => ({ id }))), - }); - -/** - * Duplicates provided Rules - * - * @param rules to duplicate - * - * @throws An error if response is not OK - */ -export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise => - KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_bulk_create`, { - method: 'POST', - body: JSON.stringify( - rules.map((rule) => ({ - ...rule, - name: `${rule.name} [${i18n.DUPLICATE}]`, - created_at: undefined, - created_by: undefined, - id: undefined, - rule_id: undefined, - updated_at: undefined, - updated_by: undefined, - enabled: false, - immutable: undefined, - execution_summary: undefined, - })) - ), - }); - /** * Perform bulk action with rules selected by a filter query * diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 85df24ec0258e..797f67e1fbae5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -219,19 +219,6 @@ export interface FetchRuleProps { signal: AbortSignal; } -export interface EnableRulesProps { - ids: string[]; - enabled: boolean; -} - -export interface DeleteRulesProps { - ids: string[]; -} - -export interface DuplicateRulesProps { - rules: Rule[]; -} - export interface BulkActionProps { action: Action; query?: string; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts index 8e98d24b17246..10c099e4bfcc8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.ts @@ -5,40 +5,28 @@ * 2.0. */ -import { Dispatch } from 'react'; import type { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; - -import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { APP_UI_ID } from '../../../../../../common/constants'; import { BulkAction, BulkActionEditPayload, } from '../../../../../../common/detection_engine/schemas/common/schemas'; -import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; +import { HTTPError } from '../../../../../../common/detection_engine/types'; import { SecurityPageName } from '../../../../../app/types'; import { getEditRuleUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; -import { - ActionToaster, - displayErrorToast, - displaySuccessToast, - errorToToaster, -} from '../../../../../common/components/toasters'; +import type { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../../common/lib/telemetry'; import { downloadBlob } from '../../../../../common/utils/download_blob'; import { - deleteRules, - duplicateRules, - enableRules, - exportRules, + BulkActionResponse, + BulkActionSummary, performBulkAction, - Rule, } from '../../../../containers/detection_engine/rules'; -import { RulesTableActions } from './rules_table/rules_table_context'; -import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; import * as i18n from '../translations'; -import { bucketRulesResponse, getExportedRulesCounts } from './helpers'; +import { getExportedRulesCounts } from './helpers'; +import { RulesTableActions } from './rules_table/rules_table_context'; -export const editRuleAction = ( +export const goToRuleEditPage = ( ruleId: string, navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise ) => { @@ -48,177 +36,48 @@ export const editRuleAction = ( }); }; -export const duplicateRulesAction = async ( - rules: Rule[], - ruleIds: string[], - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'] -): Promise => { - try { - setLoadingRules({ ids: ruleIds, action: 'duplicate' }); - const response = await duplicateRules({ - // We cast this back and forth here as the front end types are not really the right io-ts ones - // and the two types conflict with each other. - rules: rules.map((rule) => transformOutput(rule as CreateRulesSchema) as Rule), - }); - const { errors, rules: createdRules } = bucketRulesResponse(response); - if (errors.length > 0) { - displayErrorToast( - i18n.DUPLICATE_RULE_ERROR, - errors.map((e) => e.error.message), - dispatchToaster - ); - } else { - displaySuccessToast(i18n.SUCCESSFULLY_DUPLICATED_RULES(ruleIds.length), dispatchToaster); - } - return createdRules; - } catch (error) { - errorToToaster({ title: i18n.DUPLICATE_RULE_ERROR, error, dispatchToaster }); - } finally { - setLoadingRules({ ids: [], action: null }); - } -}; - -export const exportRulesAction = async ( - exportRuleId: string[], - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'] -) => { - try { - setLoadingRules({ ids: exportRuleId, action: 'export' }); - const blob = await exportRules({ ids: exportRuleId }); - downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); - - const { exported } = await getExportedRulesCounts(blob); - displaySuccessToast( - i18n.SUCCESSFULLY_EXPORTED_RULES(exported, exportRuleId.length), - dispatchToaster - ); - } catch (e) { - displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); - } finally { - setLoadingRules({ ids: [], action: null }); - } -}; - -export const deleteRulesAction = async ( - ruleIds: string[], - dispatchToaster: Dispatch, - setLoadingRules: RulesTableActions['setLoadingRules'], - onRuleDeleted?: () => void -) => { - try { - setLoadingRules({ ids: ruleIds, action: 'delete' }); - const response = await deleteRules({ ids: ruleIds }); - const { errors } = bucketRulesResponse(response); - if (errors.length > 0) { - displayErrorToast( - i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length), - errors.map((e) => e.error.message), - dispatchToaster - ); - } else if (onRuleDeleted) { - onRuleDeleted(); - } - } catch (error) { - errorToToaster({ - title: i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length), - error, - dispatchToaster, - }); - } finally { - setLoadingRules({ ids: [], action: null }); - } -}; - -export const enableRulesAction = async ( - ids: string[], - enabled: boolean, - dispatchToaster: Dispatch, - setLoadingRules?: RulesTableActions['setLoadingRules'] -) => { - const errorTitle = enabled - ? i18n.BATCH_ACTION_ENABLE_SELECTED_ERROR(ids.length) - : i18n.BATCH_ACTION_DISABLE_SELECTED_ERROR(ids.length); - - try { - setLoadingRules?.({ ids, action: enabled ? 'enable' : 'disable' }); - - const response = await enableRules({ ids, enabled }); - const { rules, errors } = bucketRulesResponse(response); - - if (errors.length > 0) { - displayErrorToast( - errorTitle, - errors.map((e) => e.error.message), - dispatchToaster - ); - } - - if (rules.some((rule) => rule.immutable)) { - track( - METRIC_TYPE.COUNT, - enabled ? TELEMETRY_EVENT.SIEM_RULE_ENABLED : TELEMETRY_EVENT.SIEM_RULE_DISABLED - ); - } - if (rules.some((rule) => !rule.immutable)) { - track( - METRIC_TYPE.COUNT, - enabled ? TELEMETRY_EVENT.CUSTOM_RULE_ENABLED : TELEMETRY_EVENT.CUSTOM_RULE_DISABLED - ); - } - - return rules; - } catch (e) { - displayErrorToast(errorTitle, [e.message], dispatchToaster); - } finally { - setLoadingRules?.({ ids: [], action: null }); - } -}; - interface ExecuteRulesBulkActionArgs { - visibleRuleIds: string[]; + visibleRuleIds?: string[]; action: BulkAction; toasts: UseAppToasts; search: { query: string } | { ids: string[] }; payload?: { edit?: BulkActionEditPayload[] }; - onSuccess?: (arg: { rulesCount: number }) => void; - onError?: (error: Error) => void; - setLoadingRules: RulesTableActions['setLoadingRules']; + onSuccess?: (toasts: UseAppToasts, action: BulkAction, summary: BulkActionSummary) => void; + onError?: (toasts: UseAppToasts, action: BulkAction, error: HTTPError) => void; + onFinish?: () => void; + setLoadingRules?: RulesTableActions['setLoadingRules']; } -const executeRulesBulkAction = async ({ - visibleRuleIds, +export const executeRulesBulkAction = async ({ + visibleRuleIds = [], action, setLoadingRules, toasts, search, payload, - onSuccess, - onError, + onSuccess = defaultSuccessHandler, + onError = defaultErrorHandler, + onFinish, }: ExecuteRulesBulkActionArgs) => { try { - setLoadingRules({ ids: visibleRuleIds, action }); + setLoadingRules?.({ ids: visibleRuleIds, action }); if (action === BulkAction.export) { - const blob = await performBulkAction({ ...search, action }); - downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); - const { exported, total } = await getExportedRulesCounts(blob); - - toasts.addSuccess(i18n.SUCCESSFULLY_EXPORTED_RULES(exported, total)); + const response = await performBulkAction({ ...search, action }); + downloadBlob(response, `${i18n.EXPORT_FILENAME}.ndjson`); + onSuccess(toasts, action, await getExportedRulesCounts(response)); } else { const response = await performBulkAction({ ...search, action, edit: payload?.edit }); + sendTelemetry(action, response); + onSuccess(toasts, action, response.attributes.summary); - onSuccess?.({ rulesCount: response.attributes.summary.succeeded }); - } - } catch (e) { - if (onError) { - onError(e); - } else { - toasts.addError(e, { title: i18n.BULK_ACTION_FAILED }); + return response; } + } catch (error) { + onError(toasts, action, error); } finally { - setLoadingRules({ ids: [], action: null }); + setLoadingRules?.({ ids: [], action: null }); + onFinish?.(); } }; @@ -240,3 +99,113 @@ export const initRulesBulkAction = (params: Omit rule.immutable)) { + track( + METRIC_TYPE.COUNT, + action === BulkAction.enable + ? TELEMETRY_EVENT.SIEM_RULE_ENABLED + : TELEMETRY_EVENT.SIEM_RULE_DISABLED + ); + } + if (response.attributes.results.updated.some((rule) => !rule.immutable)) { + track( + METRIC_TYPE.COUNT, + action === BulkAction.disable + ? TELEMETRY_EVENT.CUSTOM_RULE_ENABLED + : TELEMETRY_EVENT.CUSTOM_RULE_DISABLED + ); + } + } +} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx index 445bd33860be2..f4c8e7f9bf2a0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/bulk_edit_confirmation.tsx @@ -64,7 +64,7 @@ const BulkEditConfirmationComponent = ({ > diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx index fd12f9a71bf29..491b693a442ba 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/use_bulk_actions.tsx @@ -26,29 +26,18 @@ import { BulkActionEditPayload, } from '../../../../../../../common/detection_engine/schemas/common/schemas'; import { isMlRule } from '../../../../../../../common/machine_learning/helpers'; -import { displayWarningToast, useStateToaster } from '../../../../../../common/components/toasters'; import { canEditRuleWithActions } from '../../../../../../common/utils/privileges'; import { useRulesTableContext } from '../rules_table/rules_table_context'; import * as detectionI18n from '../../../translations'; import * as i18n from '../../translations'; -import { - deleteRulesAction, - duplicateRulesAction, - enableRulesAction, - exportRulesAction, - initRulesBulkAction, -} from '../actions'; +import { executeRulesBulkAction, initRulesBulkAction } from '../actions'; import { useHasActionsPrivileges } from '../use_has_actions_privileges'; import { useHasMlPermissions } from '../use_has_ml_permissions'; import { getCustomRulesCountFromCache } from './use_custom_rules_count'; import { useAppToasts } from '../../../../../../common/hooks/use_app_toasts'; import { convertRulesFilterToKQL } from '../../../../../containers/detection_engine/rules/utils'; -import type { - BulkActionResponse, - FilterOptions, -} from '../../../../../containers/detection_engine/rules/types'; -import type { HTTPError } from '../../../../../../../common/detection_engine/types'; +import type { FilterOptions } from '../../../../../containers/detection_engine/rules/types'; import { useInvalidateRules } from '../../../../../containers/detection_engine/rules/use_find_rules_query'; interface UseBulkActionsArgs { @@ -72,7 +61,6 @@ export const useBulkActions = ({ const hasMlPermissions = useHasMlPermissions(); const rulesTableContext = useRulesTableContext(); const invalidateRules = useInvalidateRules(); - const [, dispatchToaster] = useStateToaster(); const hasActionsPrivileges = useHasActionsPrivileges(); const toasts = useAppToasts(); const getIsMounted = useIsMounted(); @@ -117,65 +105,45 @@ export const useBulkActions = ({ const mlRuleCount = disabledRules.length - disabledRulesNoML.length; if (!hasMlPermissions && mlRuleCount > 0) { - displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); + toasts.addWarning(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount)); } const ruleIds = hasMlPermissions ? disabledRules.map(({ id }) => id) : disabledRulesNoML.map(({ id }) => id); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: ruleIds, - action: BulkAction.enable, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await enableRulesAction(ruleIds, true, dispatchToaster, setLoadingRules); - } + await executeRulesBulkAction({ + visibleRuleIds: ruleIds, + action: BulkAction.enable, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: ruleIds }, + }); invalidateRules(); }; const handleDisableActions = async () => { closePopover(); const enabledIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: enabledIds, - action: BulkAction.disable, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await enableRulesAction(enabledIds, false, dispatchToaster, setLoadingRules); - } + await executeRulesBulkAction({ + visibleRuleIds: enabledIds, + action: BulkAction.disable, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: enabledIds }, + }); invalidateRules(); }; const handleDuplicateAction = async () => { closePopover(); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.duplicate, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await duplicateRulesAction( - selectedRules, - selectedRuleIds, - dispatchToaster, - setLoadingRules - ); - } + await executeRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.duplicate, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + }); invalidateRules(); }; @@ -186,39 +154,28 @@ export const useBulkActions = ({ // User has cancelled deletion return; } - - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.delete, - setLoadingRules, - toasts, - }); - - await rulesBulkAction.byQuery(filterQuery); - } else { - await deleteRulesAction(selectedRuleIds, dispatchToaster, setLoadingRules); } + + await executeRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.delete, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + }); invalidateRules(); }; const handleExportAction = async () => { closePopover(); - if (isAllSelected) { - const rulesBulkAction = initRulesBulkAction({ - visibleRuleIds: selectedRuleIds, - action: BulkAction.export, - setLoadingRules, - toasts, - }); - await rulesBulkAction.byQuery(filterQuery); - } else { - await exportRulesAction( - selectedRules.map((r) => r.rule_id), - dispatchToaster, - setLoadingRules - ); - } + await executeRulesBulkAction({ + visibleRuleIds: selectedRuleIds, + action: BulkAction.export, + setLoadingRules, + toasts, + search: isAllSelected ? { query: filterQuery } : { ids: selectedRuleIds }, + }); }; const handleBulkEdit = (bulkEditActionType: BulkActionEditType) => async () => { @@ -288,31 +245,7 @@ export const useBulkActions = ({ setLoadingRules, toasts, payload: { edit: [editPayload] }, - onSuccess: ({ rulesCount }) => { - hideWarningToast(); - toasts.addSuccess({ - title: i18n.BULK_EDIT_SUCCESS_TOAST_TITLE, - text: i18n.BULK_EDIT_SUCCESS_TOAST_DESCRIPTION(rulesCount), - iconType: undefined, - }); - }, - onError: (error: HTTPError) => { - hideWarningToast(); - // if response doesn't have number of failed rules, it means the whole bulk action failed - // and general error toast will be shown. Otherwise - error toast for partial failure - const failedRulesCount = (error?.body as BulkActionResponse)?.attributes?.summary - ?.failed; - - if (isNaN(failedRulesCount)) { - toasts.addError(error, { title: i18n.BULK_ACTION_FAILED }); - } else { - error.stack = JSON.stringify(error.body, null, 2); - toasts.addError(error, { - title: i18n.BULK_EDIT_ERROR_TOAST_TITLE, - toastMessage: i18n.BULK_EDIT_ERROR_TOAST_DESCRIPTION(failedRulesCount), - }); - } - }, + onFinish: () => hideWarningToast(), }); // only edit custom rules, as elastic rule are immutable @@ -477,7 +410,6 @@ export const useBulkActions = ({ loadingRuleIds, hasMlPermissions, invalidateRules, - dispatchToaster, setLoadingRules, toasts, filterQuery, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts index ebd059971b140..30f9db5d8f7e5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.ts @@ -5,54 +5,11 @@ * 2.0. */ -import { - bucketRulesResponse, - caseInsensitiveSort, - showRulesTable, - getSearchFilters, -} from './helpers'; -import { mockRule, mockRuleError } from './__mocks__/mock'; -import uuid from 'uuid'; -import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; import { Query } from '@elastic/eui'; import { EXCEPTIONS_SEARCH_SCHEMA } from './exceptions/exceptions_search_bar'; +import { caseInsensitiveSort, getSearchFilters, showRulesTable } from './helpers'; describe('AllRulesTable Helpers', () => { - const mockRule1: Readonly = mockRule(uuid.v4()); - const mockRule2: Readonly = mockRule(uuid.v4()); - const mockRuleError1: Readonly = mockRuleError(uuid.v4()); - const mockRuleError2: Readonly = mockRuleError(uuid.v4()); - - describe('bucketRulesResponse', () => { - test('buckets empty response', () => { - const bucketedResponse = bucketRulesResponse([]); - expect(bucketedResponse).toEqual({ rules: [], errors: [] }); - }); - - test('buckets all error response', () => { - const bucketedResponse = bucketRulesResponse([mockRuleError1, mockRuleError2]); - expect(bucketedResponse).toEqual({ rules: [], errors: [mockRuleError1, mockRuleError2] }); - }); - - test('buckets all success response', () => { - const bucketedResponse = bucketRulesResponse([mockRule1, mockRule2]); - expect(bucketedResponse).toEqual({ rules: [mockRule1, mockRule2], errors: [] }); - }); - - test('buckets mixed success/error response', () => { - const bucketedResponse = bucketRulesResponse([ - mockRule1, - mockRuleError1, - mockRule2, - mockRuleError2, - ]); - expect(bucketedResponse).toEqual({ - rules: [mockRule1, mockRule2], - errors: [mockRuleError1, mockRuleError2], - }); - }); - }); - describe('showRulesTable', () => { test('returns false when rulesCustomInstalled and rulesInstalled are null', () => { const result = showRulesTable({ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts index 7ed7d1bae60a6..301e5cbe99b50 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts @@ -7,25 +7,7 @@ import { Query } from '@elastic/eui'; import { ExportRulesDetails } from '../../../../../../common/detection_engine/schemas/response/export_rules_details_schema'; -import { - BulkRuleResponse, - RuleResponseBuckets, -} from '../../../../containers/detection_engine/rules'; - -/** - * Separates rules/errors from bulk rules API response (create/update/delete) - * - * @param response BulkRuleResponse from bulk rules API - */ -export const bucketRulesResponse = (response: BulkRuleResponse) => - response.reduce( - (acc, cv): RuleResponseBuckets => { - return 'error' in cv - ? { rules: [...acc.rules], errors: [...acc.errors, cv] } - : { rules: [...acc.rules, cv], errors: [...acc.errors] }; - }, - { rules: [], errors: [] } - ); +import { BulkActionSummary } from '../../../../containers/detection_engine/rules'; export const showRulesTable = ({ rulesCustomInstalled, @@ -93,12 +75,12 @@ export const getExportedRulesDetails = async (blob: Blob): Promise { +export const getExportedRulesCounts = async (blob: Blob): Promise => { const details = await getExportedRulesDetails(blob); return { - exported: details.exported_rules_count, - missing: details.missing_rules_count, + succeeded: details.exported_rules_count, + failed: details.missing_rules_count, total: details.exported_rules_count + details.missing_rules_count, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx index 44651172a6b26..d1e769f03bc9c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.test.tsx @@ -6,42 +6,38 @@ */ import uuid from 'uuid'; +import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock'; import '../../../../../common/mock/match_media'; -import { deleteRulesAction, duplicateRulesAction, editRuleAction } from './actions'; +import { goToRuleEditPage, executeRulesBulkAction } from './actions'; import { getRulesTableActions } from './rules_table_actions'; import { mockRule } from './__mocks__/mock'; -jest.mock('./actions', () => ({ - duplicateRulesAction: jest.fn(), - deleteRulesAction: jest.fn(), - editRuleAction: jest.fn(), -})); +jest.mock('./actions'); -const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; -const deleteRulesActionMock = deleteRulesAction as jest.Mock; -const editRuleActionMock = editRuleAction as jest.Mock; +const executeRulesBulkActionMock = executeRulesBulkAction as jest.Mock; +const goToRuleEditPageMock = goToRuleEditPage as jest.Mock; describe('getRulesTableActions', () => { const rule = mockRule(uuid.v4()); - const dispatchToaster = jest.fn(); - const reFetchRules = jest.fn(); + const toasts = useAppToastsMock.create(); + const invalidateRules = jest.fn(); const setLoadingRules = jest.fn(); beforeEach(() => { - duplicateRulesActionMock.mockClear(); - deleteRulesActionMock.mockClear(); - reFetchRules.mockClear(); + jest.clearAllMocks(); }); test('duplicate rule onClick should call rule edit after the rule is duplicated', async () => { const ruleDuplicate = mockRule('newRule'); const navigateToApp = jest.fn(); - duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); + executeRulesBulkActionMock.mockImplementation(() => + Promise.resolve({ attributes: { results: { created: [ruleDuplicate] } } }) + ); const duplicateRulesActionObject = getRulesTableActions( - dispatchToaster, + toasts, navigateToApp, - reFetchRules, + invalidateRules, true, setLoadingRules )[1]; @@ -49,17 +45,19 @@ describe('getRulesTableActions', () => { expect(duplicateRulesActionHandler).toBeDefined(); await duplicateRulesActionHandler!(rule); - expect(duplicateRulesActionMock).toHaveBeenCalled(); - expect(editRuleActionMock).toHaveBeenCalledWith(ruleDuplicate.id, navigateToApp); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'duplicate' }) + ); + expect(goToRuleEditPageMock).toHaveBeenCalledWith(ruleDuplicate.id, navigateToApp); }); test('delete rule onClick should call refetch after the rule is deleted', async () => { const navigateToApp = jest.fn(); const deleteRulesActionObject = getRulesTableActions( - dispatchToaster, + toasts, navigateToApp, - reFetchRules, + invalidateRules, true, setLoadingRules )[3]; @@ -67,10 +65,13 @@ describe('getRulesTableActions', () => { expect(deleteRuleActionHandler).toBeDefined(); await deleteRuleActionHandler!(rule); - expect(deleteRulesActionMock).toHaveBeenCalledTimes(1); - expect(reFetchRules).toHaveBeenCalledTimes(1); - expect(deleteRulesActionMock.mock.invocationCallOrder[0]).toBeLessThan( - reFetchRules.mock.invocationCallOrder[0] + expect(executeRulesBulkAction).toHaveBeenCalledTimes(1); + expect(executeRulesBulkAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'delete' }) + ); + expect(invalidateRules).toHaveBeenCalledTimes(1); + expect(executeRulesBulkActionMock.mock.invocationCallOrder[0]).toBeLessThan( + invalidateRules.mock.invocationCallOrder[0] ); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx index 3c960108fddf8..9f0c0d0cb9695 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_actions.tsx @@ -11,26 +11,22 @@ import { EuiTableActionsColumnType, EuiToolTip, } from '@elastic/eui'; -import React, { Dispatch } from 'react'; +import React from 'react'; import { NavigateToAppOptions } from '../../../../../../../../../src/core/public'; -import { ActionToaster } from '../../../../../common/components/toasters'; +import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; +import { UseAppToasts } from '../../../../../common/hooks/use_app_toasts'; import { canEditRuleWithActions } from '../../../../../common/utils/privileges'; import { Rule } from '../../../../containers/detection_engine/rules'; -import { RulesTableActions } from './rules_table/rules_table_context'; import * as i18n from '../translations'; -import { - deleteRulesAction, - duplicateRulesAction, - editRuleAction, - exportRulesAction, -} from './actions'; +import { executeRulesBulkAction, goToRuleEditPage } from './actions'; +import { RulesTableActions } from './rules_table/rules_table_context'; type NavigateToApp = (appId: string, options?: NavigateToAppOptions | undefined) => Promise; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; export const getRulesTableActions = ( - dispatchToaster: Dispatch, + toasts: UseAppToasts, navigateToApp: NavigateToApp, invalidateRules: () => void, actionsPrivileges: boolean, @@ -48,7 +44,7 @@ export const getRulesTableActions = ( i18n.EDIT_RULE_SETTINGS ), icon: 'controlsHorizontal', - onClick: (rule: Rule) => editRuleAction(rule.id, navigateToApp), + onClick: (rule: Rule) => goToRuleEditPage(rule.id, navigateToApp), enabled: (rule: Rule) => canEditRuleWithActions(rule, actionsPrivileges), }, { @@ -65,15 +61,17 @@ export const getRulesTableActions = ( ), enabled: (rule: Rule) => canEditRuleWithActions(rule, actionsPrivileges), onClick: async (rule: Rule) => { - const createdRules = await duplicateRulesAction( - [rule], - [rule.id], - dispatchToaster, - setLoadingRules - ); + const result = await executeRulesBulkAction({ + action: BulkAction.duplicate, + setLoadingRules, + visibleRuleIds: [rule.id], + toasts, + search: { ids: [rule.id] }, + }); invalidateRules(); + const createdRules = result?.attributes.results.created; if (createdRules?.length) { - editRuleAction(createdRules[0].id, navigateToApp); + goToRuleEditPage(createdRules[0].id, navigateToApp); } }, }, @@ -83,7 +81,14 @@ export const getRulesTableActions = ( description: i18n.EXPORT_RULE, icon: 'exportAction', name: i18n.EXPORT_RULE, - onClick: (rule: Rule) => exportRulesAction([rule.rule_id], dispatchToaster, setLoadingRules), + onClick: (rule: Rule) => + executeRulesBulkAction({ + action: BulkAction.export, + setLoadingRules, + visibleRuleIds: [rule.id], + toasts, + search: { ids: [rule.id] }, + }), enabled: (rule: Rule) => !rule.immutable, }, { @@ -93,7 +98,13 @@ export const getRulesTableActions = ( icon: 'trash', name: i18n.DELETE_RULE, onClick: async (rule: Rule) => { - await deleteRulesAction([rule.id], dispatchToaster, setLoadingRules); + await executeRulesBulkAction({ + action: BulkAction.delete, + setLoadingRules, + visibleRuleIds: [rule.id], + toasts, + search: { ids: [rule.id] }, + }); invalidateRules(); }, }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index 3788203008238..5882cc9a72d9a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -27,7 +27,6 @@ import { LinkAnchor } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; import { PopoverItems } from '../../../../../common/components/popover_items'; -import { useStateToaster } from '../../../../../common/components/toasters'; import { useKibana } from '../../../../../common/lib/kibana'; import { canEditRuleWithActions, getToolTipContent } from '../../../../../common/utils/privileges'; import { RuleSwitch } from '../../../../components/rules/rule_switch'; @@ -45,6 +44,7 @@ import { DurationMetric, RuleExecutionSummary, } from '../../../../../../common/detection_engine/schemas/common'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; @@ -160,13 +160,13 @@ const TAGS_COLUMN: TableColumn = { const useActionsColumn = (): EuiTableActionsColumnType => { const { navigateToApp } = useKibana().services.application; const hasActionsPrivileges = useHasActionsPrivileges(); - const [, dispatchToaster] = useStateToaster(); + const toasts = useAppToasts(); const { reFetchRules, setLoadingRules } = useRulesTableContext().actions; return useMemo( () => ({ actions: getRulesTableActions( - dispatchToaster, + toasts, navigateToApp, reFetchRules, hasActionsPrivileges, @@ -174,7 +174,7 @@ const useActionsColumn = (): EuiTableActionsColumnType => { ), width: '40px', }), - [dispatchToaster, hasActionsPrivileges, navigateToApp, reFetchRules, setLoadingRules] + [hasActionsPrivileges, navigateToApp, reFetchRules, setLoadingRules, toasts] ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index b1cc2e4f0388c..3a9f233d9bffb 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -244,23 +244,6 @@ export const BULK_ACTION_MENU_TITLE = i18n.translate( } ); -export const BULK_EDIT_SUCCESS_TOAST_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle', - { - defaultMessage: 'Rules changes updated', - } -); - -export const BULK_EDIT_SUCCESS_TOAST_DESCRIPTION = (rulesCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription', - { - values: { rulesCount }, - defaultMessage: - 'You’ve successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}.', - } - ); - export const BULK_EDIT_WARNING_TOAST_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle', { @@ -284,22 +267,6 @@ export const BULK_EDIT_WARNING_TOAST_NOTIFY = i18n.translate( } ); -export const BULK_EDIT_ERROR_TOAST_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle', - { - defaultMessage: 'Rule updates failed', - } -); - -export const BULK_EDIT_ERROR_TOAST_DESCRIPTION = (rulesCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription', - { - values: { rulesCount }, - defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to update.', - } - ); - export const BULK_EDIT_CONFIRMATION_TITLE = (elasticRulesCount: number) => i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle', @@ -454,24 +421,6 @@ export const BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_TITLE = i18n.translate( } ); -export const BATCH_ACTION_ENABLE_SELECTED_ERROR = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.enableSelectedErrorTitle', - { - values: { totalRules }, - defaultMessage: 'Error enabling {totalRules, plural, =1 {rule} other {rules}}', - } - ); - -export const BATCH_ACTION_DISABLE_SELECTED_ERROR = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.disableSelectedErrorTitle', - { - values: { totalRules }, - defaultMessage: 'Error disabling {totalRules, plural, =1 {rule} other {rules}}', - } - ); - export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle', { @@ -479,15 +428,6 @@ export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( } ); -export const BATCH_ACTION_DELETE_SELECTED_ERROR = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle', - { - values: { totalRules }, - defaultMessage: 'Error deleting {totalRules, plural, =1 {rule} other {rules}}', - } - ); - export const EXPORT_FILENAME = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.exportFilenameTitle', { @@ -495,16 +435,6 @@ export const EXPORT_FILENAME = i18n.translate( } ); -export const SUCCESSFULLY_EXPORTED_RULES = (exportedRules: number, totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle', - { - values: { totalRules, exportedRules }, - defaultMessage: - 'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.', - } - ); - export const ALL_RULES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.tableTitle', { @@ -579,30 +509,6 @@ export const DUPLICATE_RULE = i18n.translate( } ); -export const SUCCESSFULLY_DUPLICATED_RULES = (totalRules: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle', - { - values: { totalRules }, - defaultMessage: - 'Successfully duplicated {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', - } - ); - -export const DUPLICATE_RULE_ERROR = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription', - { - defaultMessage: 'Error duplicating rule', - } -); - -export const BULK_ACTION_FAILED = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription', - { - defaultMessage: 'Failed to execute bulk action', - } -); - export const EXPORT_RULE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription', { @@ -611,7 +517,7 @@ export const EXPORT_RULE = i18n.translate( ); export const DELETE_RULE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription', + 'xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription', { defaultMessage: 'Delete rule', } @@ -992,3 +898,226 @@ export const SHOWING_EXCEPTION_LISTS = (totalLists: number) => values: { totalLists }, defaultMessage: 'Showing {totalLists} {totalLists, plural, =1 {list} other {lists}}', }); + +/** + * Bulk Export + */ + +export const RULES_BULK_EXPORT_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastTitle', + { + defaultMessage: 'Rules exported', + } +); + +export const RULES_BULK_EXPORT_SUCCESS_DESCRIPTION = (exportedRules: number, totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription', + { + values: { totalRules, exportedRules }, + defaultMessage: + 'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.', + } + ); + +export const RULES_BULK_EXPORT_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.errorToastTitle', + { + defaultMessage: 'Error exporting rules', + } +); + +export const RULES_BULK_EXPORT_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to export.', + } + ); + +/** + * Bulk Duplicate + */ + +export const RULES_BULK_DUPLICATE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastTitle', + { + defaultMessage: 'Rules duplicated', + } +); + +export const RULES_BULK_DUPLICATE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully duplicated {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_DUPLICATE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle', + { + defaultMessage: 'Error duplicating rule', + } +); + +export const RULES_BULK_DUPLICATE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: + '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to duplicate.', + } + ); + +/** + * Bulk Delete + */ + +export const RULES_BULK_DELETE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.successToastTitle', + { + defaultMessage: 'Rules deleted', + } +); + +export const RULES_BULK_DELETE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully deleted {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_DELETE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.errorToastTitle', + { + defaultMessage: 'Error deleting rules', + } +); + +export const RULES_BULK_DELETE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.delete.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to delete.', + } + ); + +/** + * Bulk Enable + */ + +export const RULES_BULK_ENABLE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.enable.successToastTitle', + { + defaultMessage: 'Rules enabled', + } +); + +export const RULES_BULK_ENABLE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkAction.enable.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully enabled {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_ENABLE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.enable.errorToastTitle', + { + defaultMessage: 'Error enabling rules', + } +); + +export const RULES_BULK_ENABLE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.enable.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to enable.', + } + ); + +/** + * Bulk Disable + */ + +export const RULES_BULK_DISABLE_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastTitle', + { + defaultMessage: 'Rules disabled', + } +); + +export const RULES_BULK_DISABLE_SUCCESS_DESCRIPTION = (totalRules: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.successToastDescription', + { + values: { totalRules }, + defaultMessage: + 'Successfully disabled {totalRules, plural, =1 {{totalRules} rule} other {{totalRules} rules}}', + } + ); + +export const RULES_BULK_DISABLE_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.errorToastTitle', + { + defaultMessage: 'Error disabling rules', + } +); + +export const RULES_BULK_DISABLE_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.disable.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule is} other {# rules are}} failed to disable.', + } + ); + +/** + * Bulk Edit + */ + +export const RULES_BULK_EDIT_SUCCESS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastTitle', + { + defaultMessage: 'Rules updated', + } +); + +export const RULES_BULK_EDIT_SUCCESS_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastDescription', + { + values: { rulesCount }, + defaultMessage: + "You've successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}.", + } + ); + +export const RULES_BULK_EDIT_FAILURE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastTitle', + { + defaultMessage: 'Error updating rules', + } +); + +export const RULES_BULK_EDIT_FAILURE_DESCRIPTION = (rulesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastDescription', + { + values: { rulesCount }, + defaultMessage: '{rulesCount, plural, =1 {# rule} other {# rules}} failed to update.', + } + ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index 1e1c894ad097c..d8978cd8b11aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -95,13 +95,19 @@ const buildBulkResponse = ( total: bulkActionOutcome.results.length + errors.length, }; + // Whether rules will be updated, created or deleted depends on the bulk + // action type being processed. However, in order to avoid doing a switch-case + // by the action type, we can figure it out indirectly. const results = { + // We had a rule, now there's a rule with the same id - the existing rule was modified updated: bulkActionOutcome.results .filter(({ item, result }) => item.id === result?.id) .map(({ result }) => result && internalRuleToAPIResponse(result)), + // We had a rule, now there's a rule with a different id - a new rule was created created: bulkActionOutcome.results .filter(({ item, result }) => result != null && result.id !== item.id) .map(({ result }) => result && internalRuleToAPIResponse(result)), + // We had a rule, now it's null - the rule was deleted deleted: bulkActionOutcome.results .filter(({ result }) => result == null) .map(({ item }) => internalRuleToAPIResponse(item)), diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 4a9913fe97aba..ac95693301e54 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -20812,16 +20812,14 @@ "xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder": "Rechercher les listes d'exceptions", "xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody": "Nous n'avons trouvé aucune liste d'exceptions.", "xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle": "Exceptions", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription": "Impossible d'exécuter l'action groupée", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription": "Supprimer la règle", + "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription": "Supprimer la règle", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleDescription": "Dupliquer la règle", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription": "Erreur lors de la duplication de la règle", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle": "Erreur lors de la duplication de la règle", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle": "Dupliquer", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsDescription": "Modifier les paramètres de règles", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "Vous ne disposez pas des privilèges d'actions Kibana", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "Exporter la règle", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "active", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "Erreur lors de la suppression de {totalRules, plural, =1 {règle} other {règles}}", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "La sélection contient des règles immuables qui ne peuvent pas être supprimées", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "Actions groupées", "xpack.securitySolution.detectionEngine.rules.allRules.clearSelectionTitle": "Effacer la sélection", @@ -20852,8 +20850,8 @@ "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "Sélection de {selectedRules} {selectedRules, plural, =1 {règle} other {règles}} effectuée", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "Affichage de {totalLists} {totalLists, plural, =1 {liste} other {listes}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "Affichage de {totalRules} {totalRules, plural, =1 {règle} other {règles}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle": "Exportation réussie de {exportedRules} sur {totalRules} {totalRules, plural, =1 {règle} other {règles}}. Les règles prédéfinies ont été exclues du fichier résultant.", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "Duplication réussie de {totalRules, plural, =1 {{totalRules} règle} other {{totalRules} règles}}", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "Exportation réussie de {exportedRules} sur {totalRules} {totalRules, plural, =1 {règle} other {règles}}. Les règles prédéfinies ont été exclues du fichier résultant.", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "Toutes les règles", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "Listes d'exceptions", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "Monitoring des règles", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1dea85f7f1499..2337f25502da3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23808,27 +23808,24 @@ "xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder": "検索例外リスト", "xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody": "例外リストが見つかりませんでした。", "xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle": "例外リスト", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription": "一括アクションを実行できませんでした", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription": "ルールの削除", + "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription": "ルールの削除", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleDescription": "ルールの複製", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription": "ルールの複製エラー", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle": "ルールの複製エラー", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle": "複製", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsDescription": "ルール設定の編集", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "Kibana アクション特権がありません", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "ルールのエクスポート", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "アクティブ", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "{totalRules, plural, other {ルール}}の削除エラー", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "選択には削除できないイミュータブルルールがあります", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "一斉アクション", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addIndexPatternsTitle": "インデックスパターンを追加", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addTagsTitle": "タグを追加", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationDescription": "更新アクションは、選択した{customRulesCount, plural, other {#個のカスタムルール}}にのみ適用されます。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle": "{elasticRulesCount, plural, other {#個のElasticルール}}を編集できません", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription": "{rulesCount, plural, other {#個のルール}}を更新できませんでした。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle": "ルールの更新が失敗しました", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastDescription": "{rulesCount, plural, other {#個のルール}}を更新できませんでした。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditRejectionDescription": "Elasticルールは変更できません。更新アクションはカスタムルールにのみ適用されます。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription": "{rulesCount, plural, other {#個のルール}}を正常に更新しました。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle": "ルール変更が更新されました", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastDescription": "{rulesCount, plural, other {#個のルール}}を正常に更新しました。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastTitle": "ルール変更が更新されました", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastDescription": "{rulesCount, plural, other {#個のルール}}を更新しています。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastNotifyButtonLabel": "完了時に通知", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle": "ルールを更新しています", @@ -23874,8 +23871,8 @@ "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "{selectedRules} {selectedRules, plural, other {ルール}}を選択しました", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "{totalLists} {totalLists, plural, other {件のリスト}}を表示しています。", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "{totalRules} {totalRules, plural, other {ルール}}を表示中", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "{totalRules, plural, other {{totalRules}ルール}}を正常に複製しました", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle": "{exportedRules}/{totalRules} {totalRules, plural, other {件のルール}}を正常にエクスポートしました事前構築済みルールは結果のファイルから除外されました。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "{totalRules, plural, other {{totalRules}ルール}}を正常に複製しました", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "{exportedRules}/{totalRules} {totalRules, plural, other {件のルール}}を正常にエクスポートしました事前構築済みルールは結果のファイルから除外されました。", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "すべてのルール", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外リスト", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "ルール監視", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index fabf4d7a2d590..158534694943a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23837,27 +23837,24 @@ "xpack.securitySolution.detectionEngine.rules.allExceptionLists.search.placeholder": "搜索例外列表", "xpack.securitySolution.detectionEngine.rules.allExceptions.filters.noListsBody": "我们找不到任何例外列表。", "xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle": "例外列表", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription": "无法执行批量操作", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteeRuleDescription": "删除规则", + "xpack.securitySolution.detectionEngine.rules.allRules.actions.deleteRuleDescription": "删除规则", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleDescription": "复制规则", - "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription": "复制规则时出错", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.errorToastTitle": "复制规则时出错", "xpack.securitySolution.detectionEngine.rules.allRules.actions.duplicateTitle": "复制", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsDescription": "编辑规则设置", "xpack.securitySolution.detectionEngine.rules.allRules.actions.editRuleSettingsToolTip": "您没有 Kibana 操作权限", "xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription": "导出规则", "xpack.securitySolution.detectionEngine.rules.allRules.activeRuleDescription": "活动", - "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle": "删除{totalRules, plural, other {规则}}时出错", "xpack.securitySolution.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle": "选择内容包含无法删除的不可变规则", "xpack.securitySolution.detectionEngine.rules.allRules.batchActionsTitle": "批处理操作", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addIndexPatternsTitle": "添加索引模式", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.addTagsTitle": "添加标签", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationDescription": "将仅对您选定的 {customRulesCount, plural, other {# 个定制规则}}应用更新操作。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditConfirmationTitle": "无法编辑 {elasticRulesCount, plural, other {# 个 Elastic 规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastDescription": "无法更新 {rulesCount, plural, other {# 个规则}}。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditErrorToastTitle": "规则更新失败", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.errorToastDescription": "无法更新 {rulesCount, plural, other {# 个规则}}。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditRejectionDescription": "无法修改 Elastic 规则。将仅对定制规则应用更新操作。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastDescription": "您已成功更新 {rulesCount, plural, other {# 个规则}}。", - "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditSuccessToastTitle": "规则更改已更新", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastDescription": "您已成功更新 {rulesCount, plural, other {# 个规则}}。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.successToastTitle": "规则更改已更新", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastDescription": "{rulesCount, plural, other {# 个规则}}正在更新。", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastNotifyButtonLabel": "在完成时通知我", "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.bulkEditWarningToastTitle": "正在进行规则更新", @@ -23903,8 +23900,8 @@ "xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle": "已选择 {selectedRules} 个{selectedRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "正在显示 {totalLists} 个{totalLists, plural, other {列表}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle": "已成功导出 {exportedRules}/{totalRules} 个{totalRules, plural, other {规则}}。预置规则已从结果文件中排除。", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.duplicate.successToastDescription": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}", + "xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.export.successToastDescription": "已成功导出 {exportedRules}/{totalRules} 个{totalRules, plural, other {规则}}。预置规则已从结果文件中排除。", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "所有规则", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外列表", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "规则监测", From 46a4da2cc27ddfabfd48eb354d40f6994ba43102 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Wed, 23 Mar 2022 09:38:24 -0400 Subject: [PATCH 072/132] [Security Solution][Analyzer] Updates the selector used on the process button to match panel (#127401) --- .../public/resolver/view/process_event_dot.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 214f8eba0eec8..f6064fe54f6db 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -18,6 +18,8 @@ import { ResolverNode } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import { SideEffectContext } from './side_effect_context'; import * as nodeModel from '../../../common/endpoint/models/node'; +import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeDataModel from '../models/node_data'; import * as selectors from '../store/selectors'; import { fontSize } from './font_size'; import { useCubeAssets } from './use_cube_assets'; @@ -327,8 +329,18 @@ const UnstyledProcessEventDot = React.memo( const grandTotal: number | null = useSelector((state: ResolverState) => selectors.statsTotalForNode(state)(node) ); - const nodeName = nodeModel.nodeName(node); + const processEvent = useSelector((state: ResolverState) => + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(String(node.id))) + ); + const processName = useMemo(() => { + if (processEvent !== undefined) { + return eventModel.processNameSafeVersion(processEvent); + } else { + return nodeName; + } + }, [processEvent, nodeName]); + /* eslint-disable jsx-a11y/click-events-have-key-events */ return (
@@ -476,7 +488,7 @@ const UnstyledProcessEventDot = React.memo( defaultMessage: `{nodeState, select, error {Reload {nodeName}} other {{nodeName}}}`, values: { nodeState, - nodeName, + nodeName: processName, }, })} From cf538aaa28d93f87f183149493e96776496340c9 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 23 Mar 2022 13:40:33 +0000 Subject: [PATCH 073/132] skip flaky suite (#128332) --- x-pack/test/accessibility/apps/maps.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/maps.ts b/x-pack/test/accessibility/apps/maps.ts index 079972273c19b..1eb4ad433c661 100644 --- a/x-pack/test/accessibility/apps/maps.ts +++ b/x-pack/test/accessibility/apps/maps.ts @@ -119,7 +119,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('single cancel modal', async function () { + // FLAKY: https://github.com/elastic/kibana/issues/128332 + it.skip('single cancel modal', async function () { await testSubjects.click('confirmModalCancelButton'); await a11y.testAppSnapshot(); }); From 27e170a6649367137a241b24f5fef8940a3712bd Mon Sep 17 00:00:00 2001 From: Sandra G Date: Wed, 23 Mar 2022 09:45:31 -0400 Subject: [PATCH 074/132] [Stack Monitoring] fix sorting by node status on nodes listing page (#128323) * fix sorting by node status * fix type * code cleanup --- .../nodes/get_nodes/get_paginated_nodes.ts | 11 +++++++--- .../apps/monitoring/elasticsearch/nodes_mb.js | 22 ++++++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts index 7f250289cf3b6..541320e8499f5 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.ts @@ -14,6 +14,7 @@ import { sortNodes } from './sort_nodes'; import { paginate } from '../../../pagination/paginate'; import { getMetrics } from '../../../details/get_metrics'; import { LegacyRequest } from '../../../../types'; +import { ElasticsearchModifiedSource } from '../../../../../common/types/es'; /** * This function performs an optimization around the node listing tables in the UI. To avoid @@ -51,7 +52,8 @@ export async function getPaginatedNodes( nodesShardCount, }: { clusterStats: { - cluster_state: { nodes: Record }; + cluster_state?: { nodes: Record }; + elasticsearch?: ElasticsearchModifiedSource['elasticsearch']; }; nodesShardCount: { nodes: Record }; } @@ -61,9 +63,12 @@ export async function getPaginatedNodes( const nodes: Node[] = await getNodeIds(req, { clusterUuid }, size); // Add `isOnline` and shards from the cluster state and shard stats - const clusterState = clusterStats?.cluster_state ?? { nodes: {} }; + const clusterStateNodes = + clusterStats?.cluster_state?.nodes ?? + clusterStats?.elasticsearch?.cluster?.stats?.state?.nodes ?? + {}; for (const node of nodes) { - node.isOnline = !isUndefined(clusterState?.nodes[node.uuid]); + node.isOnline = !isUndefined(clusterStateNodes && clusterStateNodes[node.uuid]); node.shardCount = nodesShardCount?.nodes[node.uuid]?.shardCount ?? 0; } diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js index aa12619ca447c..059e18bc865ff 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js @@ -189,19 +189,21 @@ export default function ({ getService, getPageObjects }) { }); }); - // this is actually broken, see https://github.com/elastic/kibana/issues/122338 - it.skip('should sort by status', async () => { - const sortedStatusesAscending = ['Status: Offline', 'Status: Online', 'Status: Online']; - const sortedStatusesDescending = [...sortedStatusesAscending].reverse(); - + it('should sort by status', async () => { await nodesList.clickStatusCol(); - await retry.try(async () => { - expect(await nodesList.getNodeStatuses()).to.eql(sortedStatusesDescending); - }); - await nodesList.clickStatusCol(); + + // retry in case the table hasn't had time to re-render await retry.try(async () => { - expect(await nodesList.getNodeStatuses()).to.eql(sortedStatusesAscending); + const nodesAll = await nodesList.getNodesAll(); + const tableData = [ + { status: 'Status: Online' }, + { status: 'Status: Online' }, + { status: 'Status: Offline' }, + ]; + nodesAll.forEach((obj, node) => { + expect(nodesAll[node].status).to.be(tableData[node].status); + }); }); }); From 7c31c8749654ea4cfc895caeb956e6dd9f493f30 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Wed, 23 Mar 2022 15:05:37 +0100 Subject: [PATCH 075/132] [Expressions] Return total hits count in the `datatable` metadata (#127316) * Add datatable metadata support * Fix datatable-based expressions to preserve metadata * Update ES expression functions to return hits total count in the metadata --- .../datatable_utilities_service.test.ts | 12 ++++++- .../datatable_utilities_service.ts | 6 +++- .../eql_raw_response.test.ts.snap | 6 ++++ .../es_raw_response.test.ts.snap | 9 +++++ .../expressions/eql_raw_response.test.ts | 16 +++++++++ .../search/expressions/eql_raw_response.ts | 3 ++ .../search/expressions/es_raw_response.ts | 6 ++++ .../data/common/search/tabify/tabify.test.ts | 4 +++ .../data/common/search/tabify/tabify.ts | 14 ++++++-- .../expression_functions/specs/map_column.ts | 4 +-- .../expression_functions/specs/math_column.ts | 4 +-- .../expression_types/specs/datatable.ts | 33 +++++++++++++++++++ .../snapshots/baseline/combined_test2.json | 2 +- .../snapshots/baseline/combined_test3.json | 2 +- .../snapshots/baseline/final_output_test.json | 2 +- .../snapshots/baseline/metric_all_data.json | 2 +- .../snapshots/baseline/metric_empty_data.json | 2 +- .../baseline/metric_multi_metric_data.json | 2 +- .../baseline/metric_percentage_mode.json | 2 +- .../baseline/metric_single_metric_data.json | 2 +- .../snapshots/baseline/partial_test_1.json | 2 +- .../snapshots/baseline/partial_test_2.json | 2 +- .../snapshots/baseline/step_output_test2.json | 2 +- .../snapshots/baseline/step_output_test3.json | 2 +- .../snapshots/baseline/tagcloud_all_data.json | 2 +- .../baseline/tagcloud_empty_data.json | 2 +- .../snapshots/baseline/tagcloud_fontsize.json | 2 +- .../baseline/tagcloud_metric_data.json | 2 +- .../snapshots/baseline/tagcloud_options.json | 2 +- .../snapshots/session/combined_test2.json | 2 +- .../snapshots/session/combined_test3.json | 2 +- .../snapshots/session/final_output_test.json | 2 +- .../snapshots/session/metric_all_data.json | 2 +- .../snapshots/session/metric_empty_data.json | 2 +- .../session/metric_multi_metric_data.json | 2 +- .../session/metric_percentage_mode.json | 2 +- .../session/metric_single_metric_data.json | 2 +- .../snapshots/session/partial_test_1.json | 2 +- .../snapshots/session/partial_test_2.json | 2 +- .../snapshots/session/step_output_test2.json | 2 +- .../snapshots/session/step_output_test3.json | 2 +- .../snapshots/session/tagcloud_all_data.json | 2 +- .../session/tagcloud_empty_data.json | 2 +- .../snapshots/session/tagcloud_fontsize.json | 2 +- .../session/tagcloud_metric_data.json | 2 +- .../snapshots/session/tagcloud_options.json | 2 +- .../functions/common/alterColumn.ts | 2 +- .../canvas_plugin_src/functions/common/ply.ts | 2 +- .../functions/common/staticColumn.ts | 2 +- .../rename_columns/rename_columns_fn.ts | 2 +- 50 files changed, 147 insertions(+), 46 deletions(-) diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts index d626bc2226543..0a178a78f1e22 100644 --- a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts @@ -8,7 +8,7 @@ import { createStubDataView } from 'src/plugins/data_views/common/mocks'; import type { DataViewsContract } from 'src/plugins/data_views/common'; -import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { Datatable, DatatableColumn } from 'src/plugins/expressions/common'; import { FieldFormat } from 'src/plugins/field_formats/common'; import { fieldFormatsMock } from 'src/plugins/field_formats/common/mocks'; import type { AggsCommonStart } from '../search'; @@ -106,6 +106,16 @@ describe('DatatableUtilitiesService', () => { }); }); + describe('getTotalCount', () => { + it('should return a total hits count', () => { + const table = { + meta: { statistics: { totalCount: 100 } }, + } as unknown as Datatable; + + expect(datatableUtilitiesService.getTotalCount(table)).toBe(100); + }); + }); + describe('setFieldFormat', () => { it('should set new field format', () => { const column = { meta: {} } as DatatableColumn; diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts index cf4e65f31cce3..7430b3f6bc09c 100644 --- a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts @@ -7,7 +7,7 @@ */ import type { DataView, DataViewsContract, DataViewField } from 'src/plugins/data_views/common'; -import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { Datatable, DatatableColumn } from 'src/plugins/expressions/common'; import type { FieldFormatsStartCommon, FieldFormat } from 'src/plugins/field_formats/common'; import type { AggsCommonStart, AggConfig, CreateAggConfigParams, IAggType } from '../search'; @@ -77,6 +77,10 @@ export class DatatableUtilitiesService { return params?.interval; } + getTotalCount(table: Datatable): number | undefined { + return table.meta?.statistics?.totalCount; + } + isFilterable(column: DatatableColumn): boolean { if (column.meta.source !== 'esaggs') { return false; diff --git a/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap b/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap index 341a04cef373f..ef62a04c301e7 100644 --- a/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap +++ b/src/plugins/data/common/search/expressions/__snapshots__/eql_raw_response.test.ts.snap @@ -24,6 +24,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": undefined, + }, "type": "eql", }, "rows": Array [ @@ -145,6 +148,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": undefined, + }, "type": "eql", }, "rows": Array [ diff --git a/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap b/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap index c43663a50a2ba..89f26aeee7aaf 100644 --- a/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap +++ b/src/plugins/data/common/search/expressions/__snapshots__/es_raw_response.test.ts.snap @@ -42,6 +42,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": 1977, + }, "type": "esdsl", }, "rows": Array [ @@ -86,6 +89,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": 1977, + }, "type": "esdsl", }, "rows": Array [ @@ -172,6 +178,9 @@ Object { ], "meta": Object { "source": "*", + "statistics": Object { + "totalCount": 1977, + }, "type": "esdsl", }, "rows": Array [ diff --git a/src/plugins/data/common/search/expressions/eql_raw_response.test.ts b/src/plugins/data/common/search/expressions/eql_raw_response.test.ts index 80d7ca25c89df..d0fee449d2b00 100644 --- a/src/plugins/data/common/search/expressions/eql_raw_response.test.ts +++ b/src/plugins/data/common/search/expressions/eql_raw_response.test.ts @@ -43,6 +43,22 @@ describe('eqlRawResponse', () => { const result = eqlRawResponse.to!.datatable(response, {}); expect(result).toMatchSnapshot(); }); + + test('extracts total hits number', () => { + const response: EqlRawResponse = { + type: 'eql_raw_response', + body: { + hits: { + events: [], + total: { + value: 2, + }, + }, + }, + }; + const result = eqlRawResponse.to!.datatable(response, {}); + expect(result).toHaveProperty('meta.statistics.totalCount', 2); + }); }); describe('converts sequences to table', () => { diff --git a/src/plugins/data/common/search/expressions/eql_raw_response.ts b/src/plugins/data/common/search/expressions/eql_raw_response.ts index 64e41332a8c17..ccdaed47e1676 100644 --- a/src/plugins/data/common/search/expressions/eql_raw_response.ts +++ b/src/plugins/data/common/search/expressions/eql_raw_response.ts @@ -125,6 +125,9 @@ export const eqlRawResponse: EqlRawResponseExpressionTypeDefinition = { meta: { type: 'eql', source: '*', + statistics: { + totalCount: (context.body as EqlSearchResponse).hits.total?.value, + }, }, columns, rows, diff --git a/src/plugins/data/common/search/expressions/es_raw_response.ts b/src/plugins/data/common/search/expressions/es_raw_response.ts index 61d79939e8635..97c685777e4c7 100644 --- a/src/plugins/data/common/search/expressions/es_raw_response.ts +++ b/src/plugins/data/common/search/expressions/es_raw_response.ts @@ -82,6 +82,12 @@ export const esRawResponse: EsRawResponseExpressionTypeDefinition = { meta: { type: 'esdsl', source: '*', + statistics: { + totalCount: + typeof context.body.hits.total === 'number' + ? context.body.hits.total + : context.body.hits.total?.value, + }, }, columns, rows, diff --git a/src/plugins/data/common/search/tabify/tabify.test.ts b/src/plugins/data/common/search/tabify/tabify.test.ts index 3e1b856de4100..1f4d23a897c6e 100644 --- a/src/plugins/data/common/search/tabify/tabify.test.ts +++ b/src/plugins/data/common/search/tabify/tabify.test.ts @@ -52,6 +52,10 @@ describe('tabifyAggResponse Integration', () => { expect(resp.rows[0]).toEqual({ 'col-0-1': 1000 }); expect(resp.columns[0]).toHaveProperty('name', aggConfigs.aggs[0].makeLabel()); + + expect(resp).toHaveProperty('meta.type', 'esaggs'); + expect(resp).toHaveProperty('meta.source', '1234'); + expect(resp).toHaveProperty('meta.statistics.totalCount', 1000); }); describe('scaleMetricValues performance check', () => { diff --git a/src/plugins/data/common/search/tabify/tabify.ts b/src/plugins/data/common/search/tabify/tabify.ts index 7bc02ce353d53..a640e75bac3c4 100644 --- a/src/plugins/data/common/search/tabify/tabify.ts +++ b/src/plugins/data/common/search/tabify/tabify.ts @@ -7,6 +7,7 @@ */ import { get } from 'lodash'; +import type { Datatable } from 'src/plugins/expressions'; import { TabbedAggResponseWriter } from './response_writer'; import { TabifyBuckets } from './buckets'; import type { TabbedResponseWriterOptions } from './types'; @@ -20,7 +21,7 @@ export function tabifyAggResponse( aggConfigs: IAggConfigs, esResponse: Record, respOpts?: Partial -) { +): Datatable { /** * read an aggregation from a bucket, which *might* be found at key (if * the response came in object form), and will recurse down the aggregation @@ -152,5 +153,14 @@ export function tabifyAggResponse( collectBucket(aggConfigs, write, topLevelBucket, '', 1); - return write.response(); + return { + ...write.response(), + meta: { + type: 'esaggs', + source: aggConfigs.indexPattern.id, + statistics: { + totalCount: esResponse.hits?.total, + }, + }, + }; } diff --git a/src/plugins/expressions/common/expression_functions/specs/map_column.ts b/src/plugins/expressions/common/expression_functions/specs/map_column.ts index 7b2266637bfb5..c3a267d9dca6c 100644 --- a/src/plugins/expressions/common/expression_functions/specs/map_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/map_column.ts @@ -95,7 +95,7 @@ export const mapColumn: ExpressionFunctionDefinition< input.rows.map((row) => args .expression({ - type: 'datatable', + ...input, columns: [...input.columns], rows: [row], }) @@ -129,9 +129,9 @@ export const mapColumn: ExpressionFunctionDefinition< }; return { + ...input, columns, rows, - type: 'datatable', }; }) ); diff --git a/src/plugins/expressions/common/expression_functions/specs/math_column.ts b/src/plugins/expressions/common/expression_functions/specs/math_column.ts index ae6cc8b755fe1..b513ef5d27409 100644 --- a/src/plugins/expressions/common/expression_functions/specs/math_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/math_column.ts @@ -79,7 +79,7 @@ export const mathColumn: ExpressionFunctionDefinition< input.rows.map(async (row) => { const result = await math.fn( { - type: 'datatable', + ...input, columns: input.columns, rows: [row], }, @@ -128,7 +128,7 @@ export const mathColumn: ExpressionFunctionDefinition< columns.push(newColumn); return { - type: 'datatable', + ...input, columns, rows: newRows, } as Datatable; diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts index a07f103d12e06..2a4820508210d 100644 --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts @@ -91,12 +91,45 @@ export interface DatatableColumn { meta: DatatableColumnMeta; } +/** + * Metadata with statistics about the `Datatable` source. + */ +export interface DatatableMetaStatistics { + /** + * Total hits number returned for the request generated the `Datatable`. + */ + totalCount?: number; +} + +/** + * The `Datatable` meta information. + */ +export interface DatatableMeta { + /** + * Statistics about the `Datatable` source. + */ + statistics?: DatatableMetaStatistics; + + /** + * The `Datatable` type (e.g. `essql`, `eql`, `esdsl`, etc.). + */ + type?: string; + + /** + * The `Datatable` data source. + */ + source?: string; + + [key: string]: unknown; +} + /** * A `Datatable` in Canvas is a unique structure that represents tabulated data. */ export interface Datatable { type: typeof name; columns: DatatableColumn[]; + meta?: DatatableMeta; rows: DatatableRow[]; } diff --git a/test/interpreter_functional/snapshots/baseline/combined_test2.json b/test/interpreter_functional/snapshots/baseline/combined_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test2.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/combined_test3.json b/test/interpreter_functional/snapshots/baseline/combined_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/combined_test3.json +++ b/test/interpreter_functional/snapshots/baseline/combined_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/final_output_test.json b/test/interpreter_functional/snapshots/baseline/final_output_test.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/final_output_test.json +++ b/test/interpreter_functional/snapshots/baseline/final_output_test.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_all_data.json b/test/interpreter_functional/snapshots/baseline/metric_all_data.json index 939e51b619928..bd0c93b1de057 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_all_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json index 6adb4e117d2c7..e38a14fe2b57e 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_empty_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json index 4a324a133c057..306c5f40b3d25 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_multi_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json index 944820d0ed16d..01fe67d1e6a15 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json +++ b/test/interpreter_functional/snapshots/baseline/metric_percentage_mode.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json index 392649d410e15..bf2ddf5e6e184 100644 --- a/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/metric_single_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_1.json b/test/interpreter_functional/snapshots/baseline/partial_test_1.json index 8ce0ee16a0b3b..0e26456967962 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_1.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_1.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/partial_test_2.json b/test/interpreter_functional/snapshots/baseline/partial_test_2.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/partial_test_2.json +++ b/test/interpreter_functional/snapshots/baseline/partial_test_2.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test2.json b/test/interpreter_functional/snapshots/baseline/step_output_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test2.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/step_output_test3.json b/test/interpreter_functional/snapshots/baseline/step_output_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/baseline/step_output_test3.json +++ b/test/interpreter_functional/snapshots/baseline/step_output_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json index 837251a438911..d373194db261d 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_all_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json index 5c3ca14f4eab7..864aa3538477e 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json index 5e99024d6e52b..461bdae0e172c 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_fontsize.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json index e00233197bda3..4eb2297db5425 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_metric_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json index 759b2752f9328..d7892c9197b7f 100644 --- a/test/interpreter_functional/snapshots/baseline/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/baseline/tagcloud_options.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test2.json b/test/interpreter_functional/snapshots/session/combined_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/session/combined_test2.json +++ b/test/interpreter_functional/snapshots/session/combined_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/combined_test3.json b/test/interpreter_functional/snapshots/session/combined_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/combined_test3.json +++ b/test/interpreter_functional/snapshots/session/combined_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/final_output_test.json b/test/interpreter_functional/snapshots/session/final_output_test.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/final_output_test.json +++ b/test/interpreter_functional/snapshots/session/final_output_test.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_all_data.json b/test/interpreter_functional/snapshots/session/metric_all_data.json index 939e51b619928..bd0c93b1de057 100644 --- a/test/interpreter_functional/snapshots/session/metric_all_data.json +++ b/test/interpreter_functional/snapshots/session/metric_all_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":2,"format":{"id":"bytes","params":null},"type":"vis_dimension"},"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_empty_data.json b/test/interpreter_functional/snapshots/session/metric_empty_data.json index 6adb4e117d2c7..e38a14fe2b57e 100644 --- a/test/interpreter_functional/snapshots/session/metric_empty_data.json +++ b/test/interpreter_functional/snapshots/session/metric_empty_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json b/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json index 4a324a133c057..306c5f40b3d25 100644 --- a/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json +++ b/test/interpreter_functional/snapshots/session/metric_multi_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_percentage_mode.json b/test/interpreter_functional/snapshots/session/metric_percentage_mode.json index 944820d0ed16d..01fe67d1e6a15 100644 --- a/test/interpreter_functional/snapshots/session/metric_percentage_mode.json +++ b/test/interpreter_functional/snapshots/session/metric_percentage_mode.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":{"colors":["rgb(0,0,0,0)","rgb(100, 100, 100)"],"continuity":"none","gradient":false,"range":"number","rangeMax":10000,"rangeMin":0,"stops":[0,10000]},"percentageMode":true,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json b/test/interpreter_functional/snapshots/session/metric_single_metric_data.json index 392649d410e15..bf2ddf5e6e184 100644 --- a/test/interpreter_functional/snapshots/session/metric_single_metric_data.json +++ b/test/interpreter_functional/snapshots/session/metric_single_metric_data.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"metrics":[{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"},{"id":"col-2-1","meta":{"field":"bytes","index":"logstash-*","params":{"id":"bytes","params":null},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"field":"bytes"},"schema":"metric","type":"max"},"type":"number"},"name":"Max bytes"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891,"col-2-1":19986},{"col-0-2":"404","col-1-1":696,"col-2-1":19881},{"col-0-2":"503","col-1-1":417,"col-2-1":0}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_1.json b/test/interpreter_functional/snapshots/session/partial_test_1.json index 8ce0ee16a0b3b..0e26456967962 100644 --- a/test/interpreter_functional/snapshots/session/partial_test_1.json +++ b/test/interpreter_functional/snapshots/session/partial_test_1.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/partial_test_2.json b/test/interpreter_functional/snapshots/session/partial_test_2.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/partial_test_2.json +++ b/test/interpreter_functional/snapshots/session/partial_test_2.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test2.json b/test/interpreter_functional/snapshots/session/step_output_test2.json index b4129ac898eed..7e51931665a65 100644 --- a/test/interpreter_functional/snapshots/session/step_output_test2.json +++ b/test/interpreter_functional/snapshots/session/step_output_test2.json @@ -1 +1 @@ -{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file +{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/step_output_test3.json b/test/interpreter_functional/snapshots/session/step_output_test3.json index dc1c037f45e95..5d22d728c0a4a 100644 --- a/test/interpreter_functional/snapshots/session/step_output_test3.json +++ b/test/interpreter_functional/snapshots/session/step_output_test3.json @@ -1 +1 @@ -{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file +{"as":"metricVis","type":"render","value":{"visConfig":{"dimensions":{"bucket":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"metrics":[{"accessor":1,"format":{"id":"number","params":{}},"type":"vis_dimension"}]},"metric":{"autoScale":null,"colorFullBackground":false,"labels":{"position":"bottom","show":true,"style":{"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:24px;line-height:1","spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"24px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}},"metricColorMode":"None","palette":null,"percentageMode":false,"style":{"bgColor":false,"css":"font-family:'Open Sans', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:center;font-size:60px;line-height:1","labelColor":false,"spec":{"fontFamily":"'Open Sans', Helvetica, Arial, sans-serif","fontSize":"60px","fontStyle":"normal","fontWeight":"normal","lineHeight":"1","textAlign":"center","textDecoration":"none"},"type":"style"}}},"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visType":"metric"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_all_data.json b/test/interpreter_functional/snapshots/session/tagcloud_all_data.json index 837251a438911..d373194db261d 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_all_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_all_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json index 5c3ca14f4eab7..864aa3538477e 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_empty_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json b/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json index 5e99024d6e52b..461bdae0e172c 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_fontsize.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":40,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":20,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json b/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json index e00233197bda3..4eb2297db5425 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_metric_data.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"single","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"linear","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/test/interpreter_functional/snapshots/session/tagcloud_options.json b/test/interpreter_functional/snapshots/session/tagcloud_options.json index 759b2752f9328..d7892c9197b7f 100644 --- a/test/interpreter_functional/snapshots/session/tagcloud_options.json +++ b/test/interpreter_functional/snapshots/session/tagcloud_options.json @@ -1 +1 @@ -{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file +{"as":"tagcloud","type":"render","value":{"syncColors":false,"visData":{"columns":[{"id":"col-0-2","meta":{"field":"response.raw","index":"logstash-*","params":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"2","indexPatternId":"logstash-*","params":{"field":"response.raw","missingBucket":false,"missingBucketLabel":"Missing","order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","size":4},"schema":"segment","type":"terms"},"type":"string"},"name":"response.raw: Descending"},{"id":"col-1-1","meta":{"field":null,"index":"logstash-*","params":{"id":"number"},"source":"esaggs","sourceParams":{"appliedTimeRange":null,"enabled":true,"hasPrecisionError":false,"id":"1","indexPatternId":"logstash-*","params":{"emptyAsNull":false},"schema":"metric","type":"count"},"type":"number"},"name":"Count"}],"meta":{"source":"logstash-*","statistics":{"totalCount":14004},"type":"esaggs"},"rows":[{"col-0-2":"200","col-1-1":12891},{"col-0-2":"404","col-1-1":696},{"col-0-2":"503","col-1-1":417}],"type":"datatable"},"visParams":{"ariaLabel":null,"bucket":{"accessor":1,"format":{"id":"number"},"type":"vis_dimension"},"maxFontSize":72,"metric":{"accessor":0,"format":{"id":"terms","params":{"id":"string","missingBucketLabel":"Missing","otherBucketLabel":"Other"}},"type":"vis_dimension"},"minFontSize":18,"orientation":"multiple","palette":{"name":"custom","params":{"colors":["#882E72","#B178A6","#D6C1DE","#1965B0","#5289C7","#7BAFDE","#4EB265","#90C987","#CAE0AB","#F7EE55","#F6C141","#F1932D","#E8601C","#DC050C"],"continuity":"above","gradient":false,"range":"percent","rangeMax":null,"rangeMin":0,"stops":[]},"type":"palette"},"scale":"log","showLabel":true},"visType":"tagcloud"}} \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts index b495eb170b5b6..77bf11c71a35a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts @@ -101,7 +101,7 @@ export function alterColumn(): ExpressionFunctionDefinition< })); return { - type: 'datatable', + ...input, columns, rows, }; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts index 5ab7b95f0d00b..1a38dfa84fd7e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.ts @@ -85,7 +85,7 @@ export function ply(): ExpressionFunctionDefinition<'ply', Datatable, Arguments, ); return { - type: 'datatable', + ...input, rows, columns, } as Datatable; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts index 373a9504712d6..032e7298e02f1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts @@ -58,7 +58,7 @@ export function staticColumn(): ExpressionFunctionDefinition< } return { - type: 'datatable', + ...input, columns, rows, }; diff --git a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts index ee0c7ed1eebec..c201c5f8f07e1 100644 --- a/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts +++ b/x-pack/plugins/lens/common/expressions/rename_columns/rename_columns_fn.ts @@ -28,7 +28,7 @@ export const renameColumnFn: RenameColumnsExpressionFunction['fn'] = ( const idMap = JSON.parse(encodedIdMap) as Record; return { - type: 'datatable', + ...data, rows: data.rows.map((row) => { const mappedRow: Record = {}; Object.entries(idMap).forEach(([fromId, toId]) => { From 75f8ac424e5f58a3c7cdce4f4dcffdae559b10c2 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Mar 2022 08:20:10 -0600 Subject: [PATCH 076/132] [Maps] Add support for geohex_grid aggregation (#127170) * [Maps] hex bin gridding * remove console.log * disable hexbins for license and geo_shape * fix jest tests * copy cleanup * label * update clusters SVG with hexbins * show as tooltip * documenation updates * copy updates * add API test for hex * test cleanup * eslint * eslint and functional test fixes * eslint, copy updates, and more doc updates * fix i18n error * consolidate isMvt logic * copy review feedback * use 3 stop scale for hexs * jest snapshot updates * Update x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx Co-authored-by: Nick Peihl Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Nick Peihl --- docs/maps/maps-aggregations.asciidoc | 18 +- docs/maps/maps-getting-started.asciidoc | 2 +- docs/maps/vector-layer.asciidoc | 2 +- x-pack/plugins/maps/common/constants.ts | 1 + x-pack/plugins/maps/kibana.json | 1 + .../wizards/icons/clusters_layer_icon.tsx | 35 ++-- .../resolution_editor.test.tsx.snap | 154 +++++++++++++++++- .../update_source_editor.test.tsx.snap | 6 +- .../clusters_layer_wizard.tsx | 106 ++++++------ .../create_source_editor.js | 17 +- .../es_geo_grid_source.test.ts | 2 +- .../es_geo_grid_source/es_geo_grid_source.tsx | 45 +++-- .../sources/es_geo_grid_source/is_mvt.ts | 23 +++ .../es_geo_grid_source/render_as_select.tsx | 68 -------- .../render_as_select/i18n_constants.ts | 23 +++ .../render_as_select/index.ts | 8 + .../render_as_select/render_as_select.tsx | 92 +++++++++++ .../render_as_select/show_as_label.tsx | 64 ++++++++ .../resolution_editor.test.tsx | 22 ++- .../es_geo_grid_source/resolution_editor.tsx | 127 ++++++++------- .../update_source_editor.test.tsx | 1 + .../update_source_editor.tsx | 56 +++++-- .../classes/sources/es_source/es_source.ts | 2 +- x-pack/plugins/maps/public/kibana_services.ts | 6 + x-pack/plugins/maps/public/plugin.ts | 6 +- .../plugins/maps/server/mvt/get_grid_tile.ts | 7 +- x-pack/plugins/maps/server/mvt/mvt_routes.ts | 4 +- x-pack/plugins/maps/tsconfig.json | 1 + .../apis/maps/get_grid_tile.js | 135 +++++++++------ .../functional/apps/maps/mvt_geotile_grid.js | 2 +- 30 files changed, 739 insertions(+), 297 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts delete mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx create mode 100644 x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index fced15771c386..8ffd908770455 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -42,22 +42,24 @@ image::maps/images/grid_to_docs.gif[] [role="xpack"] [[maps-grid-aggregation]] -=== Grid aggregation +=== Clusters -Grid aggregation layers use {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[GeoTile grid aggregation] to group your documents into grids. You can calculate metrics for each gridded cell. +Clusters use {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] or {ref}/search-aggregations-bucket-geohexgrid-aggregation.html[Geohex grid aggregation] to group your documents into grids. You can calculate metrics for each gridded cell. -Symbolize grid aggregation metrics as: +Symbolize cluster metrics as: -*Clusters*:: Creates a <> with a cluster symbol for each gridded cell. +*Clusters*:: Uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] to group your documents into grids. Creates a <> with a cluster symbol for each gridded cell. The cluster location is the weighted centroid for all documents in the gridded cell. -*Grid rectangles*:: Creates a <> with a bounding box polygon for each gridded cell. +*Grids*:: Uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] to group your documents into grids. Creates a <> with a bounding box polygon for each gridded cell. -*Heat map*:: Creates a <> that clusters the weighted centroids for each gridded cell. +*Heat map*:: Uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[Geotile grid aggregation] to group your documents into grids. Creates a <> that clusters the weighted centroids for each gridded cell. -To enable a grid aggregation layer: +*Hexbins*:: Uses {ref}/search-aggregations-bucket-geohexgrid-aggregation.html[Geohex grid aggregation] to group your documents into H3 hexagon grids. Creates a <> with a hexagon polygon for each gridded cell. -. Click *Add layer*, then select the *Clusters and grids* or *Heat map* layer. +To enable a clusters layer: + +. Click *Add layer*, then select the *Clusters* or *Heat map* layer. To enable a blended layer that dynamically shows clusters or documents: diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index a85586fc43188..d4da7ef8aae2e 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -128,7 +128,7 @@ traffic. Larger circles will symbolize grids with more total bytes transferred, and smaller circles will symbolize grids with less bytes transferred. -. Click **Add layer**, and select **Clusters and grids**. +. Click **Add layer**, and select **Clusters**. . Set **Data view** to **kibana_sample_data_logs**. . Click **Add layer**. . In **Layer settings**, set: diff --git a/docs/maps/vector-layer.asciidoc b/docs/maps/vector-layer.asciidoc index cf6dd5334b07e..8ad2aaf4c9769 100644 --- a/docs/maps/vector-layer.asciidoc +++ b/docs/maps/vector-layer.asciidoc @@ -11,7 +11,7 @@ To add a vector layer to your map, click *Add layer*, then select one of the fol *Choropleth*:: Shaded areas to compare statistics across boundaries. -*Clusters and grids*:: Geospatial data grouped in grids with metrics for each gridded cell. +*Clusters*:: Geospatial data grouped in grids with metrics for each gridded cell. The index must contain at least one field mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. *Create index*:: Draw shapes on the map and index in Elasticsearch. diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 435b4e55b4cec..e02fead277f60 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -164,6 +164,7 @@ export enum RENDER_AS { HEATMAP = 'heatmap', POINT = 'point', GRID = 'grid', + HEX = 'hex', } export enum GRID_RESOLUTION { diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index e049a0870855a..a3264a406b759 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -27,6 +27,7 @@ "presentationUtil" ], "optionalPlugins": [ + "cloud", "customIntegrations", "home", "savedObjectsTagging", diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx index 4c54d5faca5c7..abdc3a4f61fec 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/icons/clusters_layer_icon.tsx @@ -12,27 +12,24 @@ export const ClustersLayerIcon: FunctionComponent = () => ( xmlns="http://www.w3.org/2000/svg" width="49" height="25" - fill="none" viewBox="0 0 49 25" className="mapLayersWizardIcon" > - - - - - - - - - - - - - - - - - - + + + + ); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap index 90a5bd6758bde..7ef5e39ba96f0 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/__snapshots__/resolution_editor.test.tsx.snap @@ -1,6 +1,158 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`render 1`] = ` +exports[`should render 3 tick slider when renderAs is HEX 1`] = ` + + + + + +`; + +exports[`should render 4 tick slider when renderAs is GRID 1`] = ` + + + + + +`; + +exports[`should render 4 tick slider when renderAs is HEATMAP 1`] = ` + + + + + +`; + +exports[`should render 4 tick slider when renderAs is POINT 1`] = `
- Clusters and grids + Clusters
{ @@ -48,62 +49,71 @@ export const clustersLayerWizardConfig: LayerWizard = { return; } + const sourceDescriptor = ESGeoGridSource.createDescriptor({ + ...sourceConfig, + resolution: GRID_RESOLUTION.FINE, + }); + const defaultDynamicProperties = getDefaultDynamicProperties(); - const layerDescriptor = GeoJsonVectorLayer.createDescriptor({ - sourceDescriptor: ESGeoGridSource.createDescriptor({ - ...sourceConfig, - resolution: GRID_RESOLUTION.FINE, - }), - style: VectorStyle.createDescriptor({ - // @ts-ignore - [VECTOR_STYLES.FILL_COLOR]: { - type: STYLE_TYPE.DYNAMIC, - options: { - ...(defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR]! - .options as ColorDynamicOptions), - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, - color: NUMERICAL_COLOR_PALETTES[0].value, - type: COLOR_MAP_TYPE.ORDINAL, + const style = VectorStyle.createDescriptor({ + // @ts-ignore + [VECTOR_STYLES.FILL_COLOR]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR]!.options as ColorDynamicOptions), + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, }, + color: NUMERICAL_COLOR_PALETTES[0].value, + type: COLOR_MAP_TYPE.ORDINAL, }, - [VECTOR_STYLES.LINE_COLOR]: { - type: STYLE_TYPE.STATIC, - options: { - color: '#FFF', - }, + }, + [VECTOR_STYLES.LINE_COLOR]: { + type: STYLE_TYPE.STATIC, + options: { + color: '#FFF', }, - [VECTOR_STYLES.LINE_WIDTH]: { - type: STYLE_TYPE.STATIC, - options: { - size: 0, - }, + }, + [VECTOR_STYLES.LINE_WIDTH]: { + type: STYLE_TYPE.STATIC, + options: { + size: 0, }, - [VECTOR_STYLES.ICON_SIZE]: { - type: STYLE_TYPE.DYNAMIC, - options: { - ...(defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options as SizeDynamicOptions), - maxSize: 24, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, + }, + [VECTOR_STYLES.ICON_SIZE]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...(defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options as SizeDynamicOptions), + maxSize: 24, + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, }, }, - [VECTOR_STYLES.LABEL_TEXT]: { - type: STYLE_TYPE.DYNAMIC, - options: { - ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, - field: { - name: COUNT_PROP_NAME, - origin: FIELD_ORIGIN.SOURCE, - }, + }, + [VECTOR_STYLES.LABEL_TEXT]: { + type: STYLE_TYPE.DYNAMIC, + options: { + ...defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options, + field: { + name: COUNT_PROP_NAME, + origin: FIELD_ORIGIN.SOURCE, }, }, - }), + }, }); + + const layerDescriptor = + sourceDescriptor.requestType === RENDER_AS.HEX + ? MvtVectorLayer.createDescriptor({ + sourceDescriptor, + style, + }) + : GeoJsonVectorLayer.createDescriptor({ + sourceDescriptor, + style, + }); previewLayers([layerDescriptor]); }; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js index e829400c4bbef..872c7b71c9f92 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/create_source_editor.js @@ -51,10 +51,15 @@ export class CreateSourceEditor extends Component { ); }; - _onGeoFieldSelect = (geoField) => { + _onGeoFieldSelect = (geoFieldName) => { + const geoField = + this.state.indexPattern && geoFieldName + ? this.state.indexPattern.fields.getByName(geoFieldName) + : undefined; this.setState( { - geoField, + geoField: geoFieldName, + geoFieldType: geoField ? geoField.type : undefined, }, this.previewLayer ); @@ -85,7 +90,7 @@ export class CreateSourceEditor extends Component { return ( + ); } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index a26bd341613b2..cd93ccff99a4c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -316,7 +316,7 @@ describe('ESGeoGridSource', () => { const tileUrl = await mvtGeogridSource.getTileUrl(vectorSourceRequestMeta, '1234'); expect(tileUrl).toEqual( - "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&requestType=point&token=1234" + "rootdir/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=bar&index=undefined&gridPrecision=8&requestBody=(foobar%3AES_DSL_PLACEHOLDER%2Cparams%3A('0'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'1'%3A('0'%3Asize%2C'1'%3A0)%2C'2'%3A('0'%3Afilter%2C'1'%3A!())%2C'3'%3A('0'%3Aquery)%2C'4'%3A('0'%3Aindex%2C'1'%3A(fields%3A()))%2C'5'%3A('0'%3Aquery%2C'1'%3A(language%3AKQL%2Cquery%3A''))%2C'6'%3A('0'%3Aaggs%2C'1'%3A())))&renderAs=heatmap&token=1234" ); }); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 2eff5ce712ad5..67529edf15b4c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -45,13 +45,14 @@ import { DataView } from '../../../../../../../src/plugins/data/common'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { isValidStringConfig } from '../../util/valid_string_config'; import { makePublicExecutionContext } from '../../../util'; +import { isMvt } from './is_mvt'; type ESGeoGridSourceSyncMeta = Pick; const MAX_GEOTILE_LEVEL = 29; export const clustersTitle = i18n.translate('xpack.maps.source.esGridClustersTitle', { - defaultMessage: 'Clusters and grids', + defaultMessage: 'Clusters', }); export const heatmapTitle = i18n.translate('xpack.maps.source.esGridHeatmapTitle', { @@ -87,6 +88,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements IMvtVectorSo return ( { - if (this._descriptor.requestType === RENDER_AS.GRID) { + if ( + this._descriptor.requestType === RENDER_AS.GRID || + this._descriptor.requestType === RENDER_AS.HEX + ) { return [VECTOR_SHAPE_TYPE.POLYGON]; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts new file mode 100644 index 0000000000000..98115e9dcd992 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/is_mvt.ts @@ -0,0 +1,23 @@ +/* + * 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 { GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; + +export function isMvt(renderAs: RENDER_AS, resolution: GRID_RESOLUTION): boolean { + // heatmap uses MVT regardless of resolution because heatmap only supports counting metrics + if (renderAs === RENDER_AS.HEATMAP) { + return true; + } + + // hex uses MVT regardless of resolution because hex never supported "top terms" metric + if (renderAs === RENDER_AS.HEX) { + return true; + } + + // point and grid only use mvt at high resolution because lower resolutions may contain mvt unsupported "top terms" metric + return resolution === GRID_RESOLUTION.SUPER_FINE; +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx deleted file mode 100644 index 17fec469fe4ae..0000000000000 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select.tsx +++ /dev/null @@ -1,68 +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 React from 'react'; -import { EuiFormRow, EuiButtonGroup } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { RENDER_AS } from '../../../../common/constants'; - -const options = [ - { - id: RENDER_AS.POINT, - label: i18n.translate('xpack.maps.source.esGeoGrid.pointsDropdownOption', { - defaultMessage: 'clusters', - }), - value: RENDER_AS.POINT, - }, - { - id: RENDER_AS.GRID, - label: i18n.translate('xpack.maps.source.esGeoGrid.gridRectangleDropdownOption', { - defaultMessage: 'grids', - }), - value: RENDER_AS.GRID, - }, -]; - -export function RenderAsSelect(props: { - renderAs: RENDER_AS; - onChange: (newValue: RENDER_AS) => void; - isColumnCompressed?: boolean; -}) { - const currentOption = options.find((option) => option.value === props.renderAs) || options[0]; - - if (props.renderAs === RENDER_AS.HEATMAP) { - return null; - } - - function onChange(id: string) { - const data = options.find((option) => option.id === id); - if (data) { - props.onChange(data.value as RENDER_AS); - } - } - - return ( - - - - ); -} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts new file mode 100644 index 0000000000000..3e9d79d8cd486 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/i18n_constants.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CLUSTER_LABEL = i18n.translate('xpack.maps.source.esGeoGrid.pointsDropdownOption', { + defaultMessage: 'Clusters', +}); + +export const GRID_LABEL = i18n.translate( + 'xpack.maps.source.esGeoGrid.gridRectangleDropdownOption', + { + defaultMessage: 'Grids', + } +); + +export const HEX_LABEL = i18n.translate('xpack.maps.source.esGeoGrid.hexDropdownOption', { + defaultMessage: 'Hexagons', +}); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts new file mode 100644 index 0000000000000..4930a8ebfc0a9 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { RenderAsSelect } from './render_as_select'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx new file mode 100644 index 0000000000000..e5baf65711d3f --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/render_as_select.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFormRow, EuiButtonGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ES_GEO_FIELD_TYPE, RENDER_AS } from '../../../../../common/constants'; +import { getIsCloud } from '../../../../kibana_services'; +import { getIsGoldPlus } from '../../../../licensed_features'; +import { CLUSTER_LABEL, GRID_LABEL, HEX_LABEL } from './i18n_constants'; +import { ShowAsLabel } from './show_as_label'; + +interface Props { + geoFieldType?: ES_GEO_FIELD_TYPE; + renderAs: RENDER_AS; + onChange: (newValue: RENDER_AS) => void; + isColumnCompressed?: boolean; +} + +export function RenderAsSelect(props: Props) { + if (props.renderAs === RENDER_AS.HEATMAP) { + return null; + } + + let isHexDisabled = false; + let hexDisabledReason = ''; + if (!getIsCloud() && !getIsGoldPlus()) { + isHexDisabled = true; + hexDisabledReason = i18n.translate('xpack.maps.hexbin.license.disabledReason', { + defaultMessage: '{hexLabel} is a subscription feature.', + values: { hexLabel: HEX_LABEL }, + }); + } else if (props.geoFieldType !== ES_GEO_FIELD_TYPE.GEO_POINT) { + isHexDisabled = true; + hexDisabledReason = i18n.translate('xpack.maps.hexbin.geoShape.disabledReason', { + defaultMessage: `{hexLabel} requires a 'geo_point' cluster field.`, + values: { hexLabel: HEX_LABEL }, + }); + } + + const options = [ + { + id: RENDER_AS.POINT, + label: CLUSTER_LABEL, + value: RENDER_AS.POINT, + }, + { + id: RENDER_AS.GRID, + label: GRID_LABEL, + value: RENDER_AS.GRID, + }, + { + id: RENDER_AS.HEX, + label: HEX_LABEL, + value: RENDER_AS.HEX, + isDisabled: isHexDisabled, + }, + ]; + + function onChange(id: string) { + const data = options.find((option) => option.id === id); + if (data) { + props.onChange(data.value as RENDER_AS); + } + } + + const currentOption = options.find((option) => option.value === props.renderAs) || options[0]; + + const selectLabel = ( + + ); + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx new file mode 100644 index 0000000000000..e16bc1cb8bcff --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/render_as_select/show_as_label.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CLUSTER_LABEL, GRID_LABEL, HEX_LABEL } from './i18n_constants'; + +interface Props { + isHexDisabled: boolean; + hexDisabledReason: string; +} + +export function ShowAsLabel(props: Props) { + return ( + +
+
{CLUSTER_LABEL}
+
+

+ +

+
+ +
{GRID_LABEL}
+
+

+ +

+
+ +
{HEX_LABEL}
+
+

+ +

+ {props.isHexDisabled ? {props.hexDisabledReason} : null} +
+
+ + } + > + + {' '} + + +
+ ); +} diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx index 9802b91b47cd6..bb659d13a2bb7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.test.tsx @@ -9,16 +9,30 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ResolutionEditor } from './resolution_editor'; -import { GRID_RESOLUTION } from '../../../../common/constants'; +import { GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; const defaultProps = { - isHeatmap: false, resolution: GRID_RESOLUTION.COARSE, onChange: () => {}, metrics: [], }; -test('render', () => { - const component = shallow(); +test('should render 4 tick slider when renderAs is POINT', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should render 4 tick slider when renderAs is GRID', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should render 4 tick slider when renderAs is HEATMAP', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('should render 3 tick slider when renderAs is HEX', () => { + const component = shallow(); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx index 72dec66279164..d6f3758de1c0b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/resolution_editor.tsx @@ -10,46 +10,15 @@ import { EuiConfirmModal, EuiFormRow, EuiRange } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { AggDescriptor } from '../../../../common/descriptor_types'; -import { AGG_TYPE, GRID_RESOLUTION } from '../../../../common/constants'; - -function resolutionToSliderValue(resolution: GRID_RESOLUTION) { - if (resolution === GRID_RESOLUTION.SUPER_FINE) { - return 4; - } - - if (resolution === GRID_RESOLUTION.MOST_FINE) { - return 3; - } - - if (resolution === GRID_RESOLUTION.FINE) { - return 2; - } - - return 1; -} - -function sliderValueToResolution(value: number) { - if (value === 4) { - return GRID_RESOLUTION.SUPER_FINE; - } - - if (value === 3) { - return GRID_RESOLUTION.MOST_FINE; - } - - if (value === 2) { - return GRID_RESOLUTION.FINE; - } - - return GRID_RESOLUTION.COARSE; -} +import { AGG_TYPE, GRID_RESOLUTION, RENDER_AS } from '../../../../common/constants'; +import { isMvt } from './is_mvt'; function isUnsupportedVectorTileMetric(metric: AggDescriptor) { return metric.type === AGG_TYPE.TERMS; } interface Props { - isHeatmap: boolean; + renderAs: RENDER_AS; resolution: GRID_RESOLUTION; onChange: (resolution: GRID_RESOLUTION, metrics: AggDescriptor[]) => void; metrics: AggDescriptor[]; @@ -64,9 +33,70 @@ export class ResolutionEditor extends Component { showModal: false, }; + _getScale() { + return this.props.renderAs === RENDER_AS.HEX + ? { + [GRID_RESOLUTION.SUPER_FINE]: 3, + [GRID_RESOLUTION.MOST_FINE]: 2, + [GRID_RESOLUTION.FINE]: 2, + [GRID_RESOLUTION.COARSE]: 1, + } + : { + [GRID_RESOLUTION.SUPER_FINE]: 4, + [GRID_RESOLUTION.MOST_FINE]: 3, + [GRID_RESOLUTION.FINE]: 2, + [GRID_RESOLUTION.COARSE]: 1, + }; + } + + _getTicks() { + const scale = this._getScale(); + const unlabeledTicks = [ + { + label: '', + value: scale[GRID_RESOLUTION.FINE], + }, + ]; + if (scale[GRID_RESOLUTION.FINE] !== scale[GRID_RESOLUTION.MOST_FINE]) { + unlabeledTicks.push({ + label: '', + value: scale[GRID_RESOLUTION.MOST_FINE], + }); + } + + return [ + { + label: i18n.translate('xpack.maps.source.esGrid.lowLabel', { + defaultMessage: `low`, + }), + value: scale[GRID_RESOLUTION.COARSE], + }, + ...unlabeledTicks, + { + label: i18n.translate('xpack.maps.source.esGrid.highLabel', { + defaultMessage: `high`, + }), + value: scale[GRID_RESOLUTION.SUPER_FINE], + }, + ]; + } + + _resolutionToSliderValue(resolution: GRID_RESOLUTION): number { + const scale = this._getScale(); + return scale[resolution]; + } + + _sliderValueToResolution(value: number): GRID_RESOLUTION { + const scale = this._getScale(); + const resolution = Object.keys(scale).find((key) => { + return scale[key as GRID_RESOLUTION] === value; + }); + return resolution ? (resolution as GRID_RESOLUTION) : GRID_RESOLUTION.COARSE; + } + _onResolutionChange = (event: ChangeEvent | MouseEvent) => { - const resolution = sliderValueToResolution(parseInt(event.currentTarget.value, 10)); - if (!this.props.isHeatmap && resolution === GRID_RESOLUTION.SUPER_FINE) { + const resolution = this._sliderValueToResolution(parseInt(event.currentTarget.value, 10)); + if (isMvt(this.props.renderAs, resolution)) { const hasUnsupportedMetrics = this.props.metrics.find(isUnsupportedVectorTileMetric); if (hasUnsupportedMetrics) { this.setState({ showModal: true }); @@ -129,11 +159,13 @@ export class ResolutionEditor extends Component { render() { const helpText = - !this.props.isHeatmap && this.props.resolution === GRID_RESOLUTION.SUPER_FINE + (this.props.renderAs === RENDER_AS.POINT || this.props.renderAs === RENDER_AS.GRID) && + this.props.resolution === GRID_RESOLUTION.SUPER_FINE ? i18n.translate('xpack.maps.source.esGrid.superFineHelpText', { defaultMessage: 'High resolution uses vector tiles.', }) : undefined; + const ticks = this._getTicks(); return ( <> {this._renderModal()} @@ -145,28 +177,13 @@ export class ResolutionEditor extends Component { display="columnCompressed" >
diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx index 3ddb804cac213..0df4c492940b7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.test.tsx @@ -19,6 +19,7 @@ jest.mock('uuid/v4', () => { const defaultProps = { currentLayerType: LAYER_TYPE.GEOJSON_VECTOR, + geoFieldName: 'myLocation', indexPatternId: 'foobar', onChange: async () => {}, metrics: [], diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx index 4754d26702c9c..1ef695e9dcfac 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/update_source_editor.tsx @@ -11,7 +11,13 @@ import uuid from 'uuid/v4'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPanel, EuiSpacer, EuiComboBoxOptionOption, EuiTitle } from '@elastic/eui'; import { getDataViewNotFoundMessage } from '../../../../common/i18n_getters'; -import { AGG_TYPE, GRID_RESOLUTION, LAYER_TYPE, RENDER_AS } from '../../../../common/constants'; +import { + AGG_TYPE, + ES_GEO_FIELD_TYPE, + GRID_RESOLUTION, + LAYER_TYPE, + RENDER_AS, +} from '../../../../common/constants'; import { MetricsEditor } from '../../../components/metrics_editor'; import { getIndexPatternService } from '../../../kibana_services'; import { ResolutionEditor } from './resolution_editor'; @@ -21,9 +27,11 @@ import { RenderAsSelect } from './render_as_select'; import { AggDescriptor } from '../../../../common/descriptor_types'; import { OnSourceChangeArgs } from '../source'; import { clustersTitle, heatmapTitle } from './es_geo_grid_source'; +import { isMvt } from './is_mvt'; interface Props { currentLayerType?: string; + geoFieldName: string; indexPatternId: string; onChange: (...args: OnSourceChangeArgs[]) => Promise; metrics: AggDescriptor[]; @@ -32,6 +40,7 @@ interface Props { } interface State { + geoFieldType?: ES_GEO_FIELD_TYPE; metricsEditorKey: string; fields: IndexPatternField[]; loadError?: string; @@ -70,30 +79,42 @@ export class UpdateSourceEditor extends Component { return; } + const geoField = indexPattern.fields.getByName(this.props.geoFieldName); + this.setState({ fields: indexPattern.fields.filter((field) => !indexPatterns.isNestedField(field)), + geoFieldType: geoField ? (geoField.type as ES_GEO_FIELD_TYPE) : undefined, }); } + _getNewLayerType(renderAs: RENDER_AS, resolution: GRID_RESOLUTION): LAYER_TYPE | undefined { + let nextLayerType: LAYER_TYPE | undefined; + if (renderAs === RENDER_AS.HEATMAP) { + nextLayerType = LAYER_TYPE.HEATMAP; + } else if (isMvt(renderAs, resolution)) { + nextLayerType = LAYER_TYPE.MVT_VECTOR; + } else { + nextLayerType = LAYER_TYPE.GEOJSON_VECTOR; + } + + // only return newLayerType if there is a change from current layer type + return nextLayerType !== undefined && nextLayerType !== this.props.currentLayerType + ? nextLayerType + : undefined; + } + _onMetricsChange = (metrics: AggDescriptor[]) => { this.props.onChange({ propName: 'metrics', value: metrics }); }; _onResolutionChange = async (resolution: GRID_RESOLUTION, metrics: AggDescriptor[]) => { - let newLayerType; - if ( - this.props.currentLayerType === LAYER_TYPE.GEOJSON_VECTOR || - this.props.currentLayerType === LAYER_TYPE.MVT_VECTOR - ) { - newLayerType = - resolution === GRID_RESOLUTION.SUPER_FINE - ? LAYER_TYPE.MVT_VECTOR - : LAYER_TYPE.GEOJSON_VECTOR; - } - await this.props.onChange( { propName: 'metrics', value: metrics }, - { propName: 'resolution', value: resolution, newLayerType } + { + propName: 'resolution', + value: resolution, + newLayerType: this._getNewLayerType(this.props.renderAs, resolution), + } ); // Metrics editor persists metrics in state. @@ -102,7 +123,11 @@ export class UpdateSourceEditor extends Component { }; _onRequestTypeSelect = (requestType: RENDER_AS) => { - this.props.onChange({ propName: 'requestType', value: requestType }); + this.props.onChange({ + propName: 'requestType', + value: requestType, + newLayerType: this._getNewLayerType(requestType, this.props.resolution), + }); }; _getMetricsFilter() { @@ -155,13 +180,14 @@ export class UpdateSourceEditor extends Component { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index e1090a16ec665..27c11d27673f2 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -341,7 +341,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource getGeoFieldName(): string { if (!this._descriptor.geoField) { - throw new Error('Should not call'); + throw new Error(`Required field 'geoField' not provided in '_descriptor'`); } return this._descriptor.geoField; } diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index d8197902c73ac..88338dd508eec 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -23,6 +23,12 @@ export function setStartServices(core: CoreStart, plugins: MapsPluginStartDepend emsSettings = mapsEms.createEMSSettings(); } +let isCloudEnabled = false; +export function setIsCloudEnabled(enabled: boolean) { + isCloudEnabled = enabled; +} +export const getIsCloud = () => isCloudEnabled; + export const getIndexNameFormComponent = () => pluginsStart.fileUpload.IndexNameFormComponent; export const getFileUploadComponent = () => pluginsStart.fileUpload.FileUploadComponent; export const getIndexPatternService = () => pluginsStart.data.indexPatterns; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 21c33cdcb500a..bef5cd7039f7e 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -22,7 +22,7 @@ import type { } from '../../../../src/core/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { MapInspectorView } from './inspector/map_inspector_view'; -import { setMapAppConfig, setStartServices } from './kibana_services'; +import { setIsCloudEnabled, setMapAppConfig, setStartServices } from './kibana_services'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { getMapsVisTypeAlias } from './maps_vis_type_alias'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; @@ -74,11 +74,13 @@ import { } from './legacy_visualizations'; import type { SecurityPluginStart } from '../../security/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import type { CloudSetup } from '../../cloud/public'; import type { LensPublicSetup } from '../../lens/public'; import { setupLensChoroplethChart } from './lens'; export interface MapsPluginSetupDependencies { + cloud?: CloudSetup; expressions: ReturnType; inspector: InspectorSetupContract; home?: HomePublicPluginSetup; @@ -193,6 +195,8 @@ export class MapsPlugin plugins.expressions.registerRenderer(tileMapRenderer); plugins.visualizations.createBaseVisualization(tileMapVisType); + setIsCloudEnabled(!!plugins.cloud?.isCloudEnabled); + return { registerLayerWizard: registerLayerWizardExternal, registerSource, diff --git a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts index 28effa5eabfba..754fdb9c1f4d2 100644 --- a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts @@ -23,7 +23,7 @@ export async function getEsGridTile({ y, z, requestBody = {}, - requestType = RENDER_AS.POINT, + renderAs = RENDER_AS.POINT, gridPrecision, abortController, }: { @@ -37,7 +37,7 @@ export async function getEsGridTile({ context: DataRequestHandlerContext; logger: Logger; requestBody: any; - requestType: RENDER_AS.GRID | RENDER_AS.POINT; + renderAs: RENDER_AS; gridPrecision: number; abortController: AbortController; }): Promise { @@ -49,7 +49,8 @@ export async function getEsGridTile({ exact_bounds: false, extent: 4096, // full resolution, query: requestBody.query, - grid_type: requestType === RENDER_AS.GRID ? 'grid' : 'centroid', + grid_agg: renderAs === RENDER_AS.HEX ? 'geohex' : 'geotile', + grid_type: renderAs === RENDER_AS.GRID || renderAs === RENDER_AS.HEX ? 'grid' : 'centroid', aggs: requestBody.aggs, fields: requestBody.fields, runtime_mappings: requestBody.runtime_mappings, diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index 5fdaea9ab66df..dde68bd0d1335 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -88,7 +88,7 @@ export function initMVTRoutes({ geometryFieldName: schema.string(), requestBody: schema.string(), index: schema.string(), - requestType: schema.string(), + renderAs: schema.string(), token: schema.maybe(schema.string()), gridPrecision: schema.number(), }), @@ -114,7 +114,7 @@ export function initMVTRoutes({ z: parseInt((params as any).z, 10) as number, index: query.index as string, requestBody: decodeMvtResponseBody(query.requestBody as string) as any, - requestType: query.requestType as RENDER_AS.POINT | RENDER_AS.GRID, + renderAs: query.renderAs as RENDER_AS, gridPrecision: parseInt(query.gridPrecision, 10), abortController, }); diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index ed188c609c330..fbbc9cae2e3c9 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -32,6 +32,7 @@ { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/maps/get_grid_tile.js b/x-pack/test/api_integration/apis/maps/get_grid_tile.js index a1b420755a31a..ab8c86215a3a5 100644 --- a/x-pack/test/api_integration/apis/maps/get_grid_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_grid_tile.js @@ -13,16 +13,15 @@ export default function ({ getService }) { const supertest = getService('supertest'); describe('getGridTile', () => { - it('should return vector tile containing cluster features', async () => { - const resp = await supertest - .get( - `/api/maps/mvt/getGridTile/3/2/3.pbf\ + const URL = `/api/maps/mvt/getGridTile/3/2/3.pbf\ ?geometryFieldName=geo.coordinates\ &index=logstash-*\ &gridPrecision=8\ -&requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))\ -&requestType=point` - ) +&requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))`; + + it('should return vector tile with expected headers', async () => { + const resp = await supertest + .get(URL + '&renderAs=point') .set('kbn-xsrf', 'kibana') .responseType('blob') .expect(200); @@ -31,6 +30,14 @@ export default function ({ getService }) { expect(resp.headers['content-disposition']).to.be('inline'); expect(resp.headers['content-type']).to.be('application/x-protobuf'); expect(resp.headers['cache-control']).to.be('public, max-age=3600'); + }); + + it('should return vector tile containing clusters when renderAs is "point"', async () => { + const resp = await supertest + .get(URL + '&renderAs=point') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); const jsonTile = new VectorTile(new Protobuf(resp.body)); @@ -46,59 +53,44 @@ export default function ({ getService }) { _key: '11/517/809', 'avg_of_bytes.value': 9252, }); - expect(clusterFeature.loadGeometry()).to.eql([[{ x: 87, y: 667 }]]); - // Metadata feature - const metaDataLayer = jsonTile.layers.meta; - expect(metaDataLayer.length).to.be(1); - const metadataFeature = metaDataLayer.feature(0); - expect(metadataFeature.type).to.be(3); - expect(metadataFeature.extent).to.be(4096); + // assert feature geometry is weighted centroid + expect(clusterFeature.loadGeometry()).to.eql([[{ x: 87, y: 667 }]]); + }); - expect(metadataFeature.properties['aggregations._count.avg']).to.eql(1); - expect(metadataFeature.properties['aggregations._count.count']).to.eql(1); - expect(metadataFeature.properties['aggregations._count.min']).to.eql(1); - expect(metadataFeature.properties['aggregations._count.sum']).to.eql(1); + it('should return vector tile containing clusters with renderAs is "heatmap"', async () => { + const resp = await supertest + .get(URL + '&renderAs=heatmap') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); - expect(metadataFeature.properties['aggregations.avg_of_bytes.avg']).to.eql(9252); - expect(metadataFeature.properties['aggregations.avg_of_bytes.count']).to.eql(1); - expect(metadataFeature.properties['aggregations.avg_of_bytes.max']).to.eql(9252); - expect(metadataFeature.properties['aggregations.avg_of_bytes.min']).to.eql(9252); - expect(metadataFeature.properties['aggregations.avg_of_bytes.sum']).to.eql(9252); + const jsonTile = new VectorTile(new Protobuf(resp.body)); - expect(metadataFeature.properties['hits.total.relation']).to.eql('eq'); - expect(metadataFeature.properties['hits.total.value']).to.eql(1); + // Cluster feature + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(1); + const clusterFeature = layer.feature(0); + expect(clusterFeature.type).to.be(1); + expect(clusterFeature.extent).to.be(4096); + expect(clusterFeature.id).to.be(undefined); + expect(clusterFeature.properties).to.eql({ + _count: 1, + _key: '11/517/809', + 'avg_of_bytes.value': 9252, + }); - expect(metadataFeature.loadGeometry()).to.eql([ - [ - { x: 0, y: 4096 }, - { x: 4096, y: 4096 }, - { x: 4096, y: 0 }, - { x: 0, y: 0 }, - { x: 0, y: 4096 }, - ], - ]); + // assert feature geometry is weighted centroid + expect(clusterFeature.loadGeometry()).to.eql([[{ x: 87, y: 667 }]]); }); - it('should return vector tile containing grid features', async () => { + it('should return vector tile containing grid features when renderAs is "grid"', async () => { const resp = await supertest - .get( - `/api/maps/mvt/getGridTile/3/2/3.pbf\ -?geometryFieldName=geo.coordinates\ -&index=logstash-*\ -&gridPrecision=8\ -&requestBody=(_source:(excludes:!()),aggs:(avg_of_bytes:(avg:(field:bytes))),fields:!((field:%27@timestamp%27,format:date_time),(field:%27relatedContent.article:modified_time%27,format:date_time),(field:%27relatedContent.article:published_time%27,format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((match_all:()),(range:(%27@timestamp%27:(format:strict_date_optional_time,gte:%272015-09-20T00:00:00.000Z%27,lte:%272015-09-20T01:00:00.000Z%27)))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:%27doc[!%27@timestamp!%27].value.getHour()%27))),size:0,stored_fields:!(%27*%27))\ -&requestType=grid` - ) + .get(URL + '&renderAs=grid') .set('kbn-xsrf', 'kibana') .responseType('blob') .expect(200); - expect(resp.headers['content-encoding']).to.be('gzip'); - expect(resp.headers['content-disposition']).to.be('inline'); - expect(resp.headers['content-type']).to.be('application/x-protobuf'); - expect(resp.headers['cache-control']).to.be('public, max-age=3600'); - const jsonTile = new VectorTile(new Protobuf(resp.body)); const layer = jsonTile.layers.aggs; expect(layer.length).to.be(1); @@ -112,6 +104,8 @@ export default function ({ getService }) { _key: '11/517/809', 'avg_of_bytes.value': 9252, }); + + // assert feature geometry is grid expect(gridFeature.loadGeometry()).to.eql([ [ { x: 80, y: 672 }, @@ -121,6 +115,51 @@ export default function ({ getService }) { { x: 80, y: 672 }, ], ]); + }); + + it('should return vector tile containing hexegon features when renderAs is "hex"', async () => { + const resp = await supertest + .get(URL + '&renderAs=hex') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); + const layer = jsonTile.layers.aggs; + expect(layer.length).to.be(1); + + const gridFeature = layer.feature(0); + expect(gridFeature.type).to.be(3); + expect(gridFeature.extent).to.be(4096); + expect(gridFeature.id).to.be(undefined); + expect(gridFeature.properties).to.eql({ + _count: 1, + _key: '85264a33fffffff', + 'avg_of_bytes.value': 9252, + }); + + // assert feature geometry is hex + expect(gridFeature.loadGeometry()).to.eql([ + [ + { x: 102, y: 669 }, + { x: 99, y: 659 }, + { x: 89, y: 657 }, + { x: 83, y: 664 }, + { x: 86, y: 674 }, + { x: 96, y: 676 }, + { x: 102, y: 669 }, + ], + ]); + }); + + it('should return vector tile with meta layer', async () => { + const resp = await supertest + .get(URL + '&renderAs=point') + .set('kbn-xsrf', 'kibana') + .responseType('blob') + .expect(200); + + const jsonTile = new VectorTile(new Protobuf(resp.body)); // Metadata feature const metaDataLayer = jsonTile.layers.meta; diff --git a/x-pack/test/functional/apps/maps/mvt_geotile_grid.js b/x-pack/test/functional/apps/maps/mvt_geotile_grid.js index d56b389b878f3..40dfa5ac8e571 100644 --- a/x-pack/test/functional/apps/maps/mvt_geotile_grid.js +++ b/x-pack/test/functional/apps/maps/mvt_geotile_grid.js @@ -47,7 +47,7 @@ export default function ({ getPageObjects, getService }) { geometryFieldName: 'geo.coordinates', index: 'logstash-*', gridPrecision: 8, - requestType: 'grid', + renderAs: 'grid', requestBody: `(_source:(excludes:!()),aggs:(max_of_bytes:(max:(field:bytes))),fields:!((field:'@timestamp',format:date_time),(field:'relatedContent.article:modified_time',format:date_time),(field:'relatedContent.article:published_time',format:date_time),(field:utc_time,format:date_time)),query:(bool:(filter:!((range:('@timestamp':(format:strict_date_optional_time,gte:'2015-09-20T00:00:00.000Z',lte:'2015-09-20T01:00:00.000Z')))),must:!(),must_not:!(),should:!())),runtime_mappings:(),script_fields:(hour_of_day:(script:(lang:painless,source:'doc[!'@timestamp!'].value.getHour()'))),size:0,stored_fields:!('*'))`, }); From d7e17d78ebeb653bd4b5cda59df05f370939ab04 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 23 Mar 2022 08:20:45 -0600 Subject: [PATCH 077/132] [lens] include number of values in default terms field label (#127222) * [lens] include number of values in default terms field label * remove size from rare values * revert changes to label with secondary terms * fix jest tests * i18n cleanup, fix lens smokescreen functional test * functional test expects * funcational test expect * handle single value * eslint * revert changes to expect * update functional test expect * functional test expect update * test expects * expects * expects * expects * expects * expects Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/management/_scripted_fields.ts | 4 +- .../droppable/droppable.test.ts | 10 ++-- .../operations/definitions/terms/index.tsx | 59 ++++++++++++++----- .../definitions/terms/terms.test.tsx | 19 +++--- .../operations/layer_helpers.test.ts | 6 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../functional/apps/lens/drag_and_drop.ts | 26 ++++---- .../functional/apps/lens/runtime_fields.ts | 4 +- .../test/functional/apps/lens/smokescreen.ts | 2 +- 11 files changed, 80 insertions(+), 53 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields.ts b/test/functional/apps/management/_scripted_fields.ts index c8c605ec7ed19..a6bbe798cf56b 100644 --- a/test/functional/apps/management/_scripted_fields.ts +++ b/test/functional/apps/management/_scripted_fields.ts @@ -276,7 +276,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // verify Lens opens a visualization expect(await testSubjects.getVisibleTextAll('lns-dimensionTrigger')).to.contain( - 'Top values of painString' + 'Top 5 values of painString' ); }); }); @@ -363,7 +363,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.header.waitUntilLoadingHasFinished(); // verify Lens opens a visualization expect(await testSubjects.getVisibleTextAll('lns-dimensionTrigger')).to.contain( - 'Top values of painBool' + 'Top 5 values of painBool' ); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index ea3978ce8ca94..778b589d283e1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -144,7 +144,7 @@ const multipleColumnsLayer: IndexPatternLayer = { columns: { col1: oneColumnLayer.columns.col1, col2: { - label: 'Top values of src', + label: 'Top 10 values of src', dataType: 'string', isBucketed: true, // Private @@ -157,7 +157,7 @@ const multipleColumnsLayer: IndexPatternLayer = { sourceField: 'src', } as TermsIndexPatternColumn, col3: { - label: 'Top values of dest', + label: 'Top 10 values of dest', dataType: 'string', isBucketed: true, @@ -1620,7 +1620,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: testState.layers.first.columns.col1, col2: { - label: 'Top values of src', + label: 'Top 10 values of src', dataType: 'string', isBucketed: true, @@ -2192,7 +2192,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: testState.layers.first.columns.col1, col2: { isBucketed: true, - label: 'Top values of bytes', + label: 'Top 10 values of bytes', operationType: 'terms', sourceField: 'bytes', dataType: 'number', @@ -2284,7 +2284,7 @@ describe('IndexPatternDimensionEditorPanel', () => { col1: testState.layers.first.columns.col1, col2: { isBucketed: true, - label: 'Top values of bytes', + label: 'Top 10 values of bytes', operationType: 'terms', sourceField: 'bytes', dataType: 'number', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index fbb990e1dab81..c881bc898e8ad 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -60,7 +60,12 @@ const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLa defaultMessage: 'Missing field', }); -function ofName(name?: string, count: number = 0, rare: boolean = false) { +function ofName( + name?: string, + secondaryFieldsCount: number = 0, + rare: boolean = false, + termsSize: number = 0 +) { if (rare) { return i18n.translate('xpack.lens.indexPattern.rareTermsOf', { defaultMessage: 'Rare values of {name}', @@ -69,19 +74,22 @@ function ofName(name?: string, count: number = 0, rare: boolean = false) { }, }); } - if (count) { + if (secondaryFieldsCount) { return i18n.translate('xpack.lens.indexPattern.multipleTermsOf', { defaultMessage: 'Top values of {name} + {count} {count, plural, one {other} other {others}}', values: { name: name ?? missingFieldLabel, - count, + count: secondaryFieldsCount, }, }); } return i18n.translate('xpack.lens.indexPattern.termsOf', { - defaultMessage: 'Top values of {name}', + defaultMessage: + 'Top {numberOfTermsLabel}{termsCount, plural, one {value} other {values}} of {name}', values: { name: name ?? missingFieldLabel, + termsCount: termsSize, + numberOfTermsLabel: termsSize > 1 ? `${termsSize} ` : '', }, }); } @@ -270,7 +278,8 @@ export const termsOperation: OperationDefinition { const newParams = { @@ -285,6 +294,7 @@ export const termsOperation: OperationDefinition { - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'size', - value, - }) - ); + updateLayer({ + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...currentColumn, + label: currentColumn.customLabel + ? currentColumn.label + : ofName( + indexPattern.getFieldByName(currentColumn.sourceField)?.displayName, + secondaryFieldsCount, + currentColumn.params.orderBy.type === 'rare', + value + ), + params: { + ...currentColumn.params, + size: value, + }, + }, + } as Record, + }); }} /> {currentColumn.params.orderBy.type === 'rare' && ( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 0da1f0977e4bc..a72250c2265c4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -79,7 +79,7 @@ describe('terms', () => { columnOrder: ['col1', 'col2'], columns: { col1: { - label: 'Top values of source', + label: 'Top 3 values of source', dataType: 'string', isBucketed: true, operationType: 'terms', @@ -199,7 +199,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'source', - label: 'Top values of source', + label: 'Top 5 values of source', isBucketed: true, dataType: 'string', params: { @@ -226,7 +226,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -254,7 +254,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -278,7 +278,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -304,7 +304,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -327,7 +327,7 @@ describe('terms', () => { const oldColumn: TermsIndexPatternColumn = { operationType: 'terms', sourceField: 'bytes', - label: 'Top values of bytes', + label: 'Top 5 values of bytes', isBucketed: true, dataType: 'number', params: { @@ -929,7 +929,7 @@ describe('terms', () => { createMockedIndexPattern(), {} ) - ).toBe('Top values of source'); + ).toBe('Top 3 values of source'); }); it('should return main value with single counter for two fields', () => { @@ -2083,6 +2083,7 @@ describe('terms', () => { ...layer.columns, col1: { ...layer.columns.col1, + label: 'Top 7 values of source', params: { ...(layer.columns.col1 as TermsIndexPatternColumn).params, size: 7, @@ -2101,7 +2102,7 @@ describe('terms', () => { col1: { dataType: 'boolean', isBucketed: true, - label: 'Top values of bytes', + label: 'Top 5 values of bytes', operationType: 'terms', params: { missingBucket: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index b6398970056e2..15cfcda26d917 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -767,7 +767,7 @@ describe('state_helpers', () => { }).columns.col2 ).toEqual( expect.objectContaining({ - label: 'Top values of bytes', + label: 'Top 3 values of bytes', }) ); }); @@ -1079,7 +1079,7 @@ describe('state_helpers', () => { }).columns.col1 ).toEqual( expect.objectContaining({ - label: 'Top values of source', + label: 'Top 3 values of source', }) ); }); @@ -2251,7 +2251,7 @@ describe('state_helpers', () => { it('should remove column and any incomplete state', () => { const termsColumn: TermsIndexPatternColumn = { - label: 'Top values of source', + label: 'Top 5 values of source', dataType: 'string', isBucketed: true, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ac95693301e54..8914efcf12ded 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -553,7 +553,6 @@ "xpack.lens.indexPattern.terms.otherBucketDescription": "Regrouper les autres valeurs sous \"Autre\"", "xpack.lens.indexPattern.terms.otherLabel": "Autre", "xpack.lens.indexPattern.terms.size": "Nombre de valeurs", - "xpack.lens.indexPattern.termsOf": "Valeurs les plus élevées de {name}", "xpack.lens.indexPattern.termsWithMultipleShifts": "Dans un seul calque, il est impossible de combiner des indicateurs avec des décalages temporels différents et des valeurs dynamiques les plus élevées. Utilisez la même valeur de décalage pour tous les indicateurs, ou utilisez des filtres à la place des valeurs les plus élevées.", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "Utiliser des filtres", "xpack.lens.indexPattern.timeScale.enableTimeScale": "Normaliser par unité", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2337f25502da3..48f0d74d73765 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -654,7 +654,6 @@ "xpack.lens.indexPattern.terms.size": "値の数", "xpack.lens.indexPattern.terms.sizeLimitMax": "値が最大値{max}を超えています。最大値が使用されます。", "xpack.lens.indexPattern.terms.sizeLimitMin": "値が最小値{min}未満です。最小値が使用されます。", - "xpack.lens.indexPattern.termsOf": "{name} のトップの値", "xpack.lens.indexPattern.termsWithMultipleShifts": "単一のレイヤーでは、メトリックを異なる時間シフトと動的な上位の値と組み合わせることができません。すべてのメトリックで同じ時間シフト値を使用するか、上位の値ではなくフィルターを使用します。", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "フィルターを使用", "xpack.lens.indexPattern.termsWithMultipleTermsAndScriptedFields": "複数のフィールドを使用するときには、スクリプトフィールドがサポートされていません。{fields}が見つかりました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 158534694943a..bbc00d8d205f7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -660,7 +660,6 @@ "xpack.lens.indexPattern.terms.size": "值数目", "xpack.lens.indexPattern.terms.sizeLimitMax": "值大于最大值 {max},将改为使用最大值。", "xpack.lens.indexPattern.terms.sizeLimitMin": "值小于最小值 {min},将改为使用最小值。", - "xpack.lens.indexPattern.termsOf": "{name} 排名最前值", "xpack.lens.indexPattern.termsWithMultipleShifts": "在单个图层中,无法将指标与不同时间偏移和动态排名最前值组合。将相同的时间偏移值用于所有指标或使用筛选,而非排名最前值。", "xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel": "使用筛选", "xpack.lens.indexPattern.termsWithMultipleTermsAndScriptedFields": "使用多个字段时不支持脚本字段,找到 {fields}", diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index 27e336a1cbc12..1a7b8e96d6802 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -33,7 +33,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'lnsDatatable_rows > lns-dimensionTrigger' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsDatatable_rows')).to.eql( - 'Top values of clientip' + 'Top 3 values of clientip' ); await PageObjects.lens.dragFieldToDimensionTrigger( @@ -48,7 +48,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'lnsDatatable_rows > lns-empty-dimension' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsDatatable_rows', 2)).to.eql( - 'Top values of @message.raw' + 'Top 3 values of @message.raw' ); }); @@ -56,8 +56,8 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.reorderDimensions('lnsDatatable_rows', 3, 1); await PageObjects.lens.waitForVisualization(); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsDatatable_rows')).to.eql([ - 'Top values of @message.raw', - 'Top values of clientip', + 'Top 3 values of @message.raw', + 'Top 3 values of clientip', 'bytes', ]); }); @@ -65,11 +65,11 @@ export default function ({ getPageObjects }: FtrProviderContext) { it('should move the column to compatible dimension group', async () => { await PageObjects.lens.switchToVisualization('bar'); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([ - 'Top values of @message.raw', + 'Top 3 values of @message.raw', ]); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of clientip']); + ).to.eql(['Top 3 values of clientip']); await PageObjects.lens.dragDimensionToDimension( 'lnsXY_xDimensionPanel > lns-dimensionTrigger', @@ -81,13 +81,13 @@ export default function ({ getPageObjects }: FtrProviderContext) { ); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of @message.raw']); + ).to.eql(['Top 3 values of @message.raw']); }); it('should move the column to non-compatible dimension group', async () => { expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of @message.raw']); + ).to.eql(['Top 3 values of @message.raw']); await PageObjects.lens.dragDimensionToDimension( 'lnsXY_splitDimensionPanel > lns-dimensionTrigger', @@ -129,7 +129,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Unique count of @message.raw [1]', ]); expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([ - 'Top values of @message.raw', + 'Top 5 values of @message.raw', ]); }); @@ -159,7 +159,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Unique count of @timestamp' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_splitDimensionPanel')).to.eql( - 'Top values of @message.raw' + 'Top 3 values of @message.raw' ); }); @@ -244,14 +244,14 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of @message.raw']); + ).to.eql(['Top 3 values of @message.raw']); await PageObjects.lens.assertFocusedField('@message.raw'); }); it('should drop a field to an existing dimension replacing the old one', async () => { await PageObjects.lens.dragFieldWithKeyboard('clientip', 1, true); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of clientip']); + ).to.eql(['Top 3 values of clientip']); await PageObjects.lens.assertFocusedField('clientip'); }); @@ -319,7 +319,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { await PageObjects.lens.waitForVisualization(); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') - ).to.eql(['Top values of clientip']); + ).to.eql(['Top 3 values of clientip']); await PageObjects.lens.openDimensionEditor( 'lnsXY_splitDimensionPanel > lns-dimensionTrigger' ); diff --git a/x-pack/test/functional/apps/lens/runtime_fields.ts b/x-pack/test/functional/apps/lens/runtime_fields.ts index 1353bcaea2c84..252951cba4bd0 100644 --- a/x-pack/test/functional/apps/lens/runtime_fields.ts +++ b/x-pack/test/functional/apps/lens/runtime_fields.ts @@ -33,7 +33,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); await PageObjects.lens.waitForVisualization(); expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( - 'Top values of runtimefield' + 'Top 5 values of runtimefield' ); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); }); @@ -62,7 +62,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.lens.waitForVisualization(); expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( - 'Top values of runtimefield2' + 'Top 5 values of runtimefield2' ); expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('abc'); }); diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 4887f96c6870a..f0cc3b0da7201 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -337,7 +337,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getLayerCount()).to.eql(1); expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sliceByDimensionPanel')).to.eql( - 'Top values of geo.dest' + 'Top 5 values of geo.dest' ); expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sizeByDimensionPanel')).to.eql( 'Average of bytes' From b028cf97ed9a554fef723be1f38ffabbfc41bac1 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 23 Mar 2022 10:28:43 -0400 Subject: [PATCH 078/132] [ResponseOps][task manager] log event loop delay for tasks when over configured limit (#126300) resolves https://github.com/elastic/kibana/issues/124366 Adds new task manager configuration keys. - `xpack.task_manager.event_loop_delay.monitor` - whether to monitor event loop delay or not; added in case this specific monitoring causes other issues and we'd want to disable it. We don't know of any cases where we'd need this today - `xpack.task_manager.event_loop_delay.warn_threshold` - the number of milliseconds of event loop delay before logging a warning This code uses the `perf_hooks.monitorEventLoopDelay()` API[1] to collect the event loop delay while a task is running. [1] https://nodejs.org/api/perf_hooks.html#perf_hooksmonitoreventloopdelayoptions When a significant event loop delay is encountered, it's very likely that other tasks running at the same time will be affected, and so will also end up having a long event loop delay value, and warnings will be logged on those. Over time, though, tasks which have consistently long event loop delays will outnumber those unfortunate peer tasks, and be obvious from the volume in the logs. To make it a bit easier to find these when viewing Kibana logs in Discover, tags are added to the logged messages to make it easier to find them. One tag is `event-loop-blocked`, second is the task type, and the third is a string consisting of the task type and task id. --- docs/settings/task-manager-settings.asciidoc | 5 ++ .../resources/base/bin/kibana-docker | 2 + .../task_manager/server/config.test.ts | 12 +++ x-pack/plugins/task_manager/server/config.ts | 10 +++ .../server/ephemeral_task_lifecycle.test.ts | 4 + .../managed_configuration.test.ts | 4 + .../configuration_statistics.test.ts | 4 + .../monitoring_stats_stream.test.ts | 4 + .../task_manager/server/plugin.test.ts | 12 +++ .../server/polling_lifecycle.test.ts | 4 + .../task_manager/server/polling_lifecycle.ts | 3 + .../task_manager/server/task_events.test.ts | 82 +++++++++++++++++++ .../task_manager/server/task_events.ts | 20 +++++ .../server/task_running/task_runner.test.ts | 10 ++- .../server/task_running/task_runner.ts | 21 ++++- 15 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/task_manager/server/task_events.test.ts diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index b7423d7c37b31..5f31c9adc879d 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -40,6 +40,11 @@ These non-persisted action tasks have a risk that they won't be run at all if th `xpack.task_manager.ephemeral_tasks.request_capacity`:: Sets the size of the ephemeral queue defined above. Defaults to 10. +`xpack.task_manager.event_loop_delay.monitor`:: +Enables event loop delay monitoring, which will log a warning when a task causes an event loop delay which exceeds the `warn_threshold` setting. Defaults to true. + +`xpack.task_manager.event_loop_delay.warn_threshold`:: +Sets the amount of event loop delay during a task execution which will cause a warning to be logged. Defaults to 5000 milliseconds (5 seconds). [float] [[task-manager-health-settings]] diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 01d27a345378b..83a542c93d12b 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -379,6 +379,8 @@ kibana_vars=( xpack.task_manager.poll_interval xpack.task_manager.request_capacity xpack.task_manager.version_conflict_threshold + xpack.task_manager.event_loop_delay.monitor + xpack.task_manager.event_loop_delay.warn_threshold xpack.uptime.index ) diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index 4c4db2aba7128..f5ba0a3bcee0a 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -16,6 +16,10 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, + "event_loop_delay": Object { + "monitor": true, + "warn_threshold": 5000, + }, "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, @@ -62,6 +66,10 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, + "event_loop_delay": Object { + "monitor": true, + "warn_threshold": 5000, + }, "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, @@ -106,6 +114,10 @@ describe('config validation', () => { "enabled": false, "request_capacity": 10, }, + "event_loop_delay": Object { + "monitor": true, + "warn_threshold": 5000, + }, "max_attempts": 3, "max_poll_inactivity_cycles": 10, "max_workers": 10, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index 5a58e45a70d96..f650ed093cee0 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -41,6 +41,14 @@ export const taskExecutionFailureThresholdSchema = schema.object( } ); +const eventLoopDelaySchema = schema.object({ + monitor: schema.boolean({ defaultValue: true }), + warn_threshold: schema.number({ + defaultValue: 5000, + min: 10, + }), +}); + export const configSchema = schema.object( { /* The maximum number of times a task will be attempted before being abandoned as failed */ @@ -118,6 +126,7 @@ export const configSchema = schema.object( max: DEFAULT_MAX_EPHEMERAL_REQUEST_CAPACITY, }), }), + event_loop_delay: eventLoopDelaySchema, /* These are not designed to be used by most users. Please use caution when changing these */ unsafe: schema.object({ exclude_task_types: schema.arrayOf(schema.string(), { defaultValue: [] }), @@ -138,3 +147,4 @@ export const configSchema = schema.object( export type TaskManagerConfig = TypeOf; export type TaskExecutionFailureThreshold = TypeOf; +export type EventLoopDelayConfig = TypeOf; diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts index 639bb834eeb4c..1d98e37a06a55 100644 --- a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts @@ -69,6 +69,10 @@ describe('EphemeralTaskLifecycle', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, ...config, }, elasticsearchAndSOAvailability$, diff --git a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts index 3442e69aab44a..c5f03b1769385 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts @@ -57,6 +57,10 @@ describe.skip('managed configuration', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); logger = context.logger.get('taskManager'); diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index 77fd9a8f11fab..776f5bc9388f7 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -40,6 +40,10 @@ describe('Configuration Statistics Aggregator', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }; const managedConfig = { diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index 8aa2d54d89623..a6ef665966ddd 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -44,6 +44,10 @@ describe('createMonitoringStatsStream', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }; it('returns the initial config used to configure Task Manager', async () => { diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 20e5f211a5b4e..aa91533eabadf 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -43,6 +43,10 @@ describe('TaskManagerPlugin', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); pluginInitializerContext.env.instanceUuid = ''; @@ -84,6 +88,10 @@ describe('TaskManagerPlugin', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); const taskManagerPlugin = new TaskManagerPlugin(pluginInitializerContext); @@ -154,6 +162,10 @@ describe('TaskManagerPlugin', () => { unsafe: { exclude_task_types: ['*'], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }); const logger = pluginInitializerContext.logger.get(); diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index cf29d1f475c6c..7cbaa5a165544 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -67,6 +67,10 @@ describe('TaskPollingLifecycle', () => { unsafe: { exclude_task_types: [], }, + event_loop_delay: { + monitor: true, + warn_threshold: 5000, + }, }, taskStore: mockTaskStore, logger: taskManagerLogger, diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index a452c8a3f82fb..ee7e2ec32932e 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -91,6 +91,7 @@ export class TaskPollingLifecycle { private middleware: Middleware; private usageCounter?: UsageCounter; + private config: TaskManagerConfig; /** * Initializes the task manager, preventing any further addition of middleware, @@ -117,6 +118,7 @@ export class TaskPollingLifecycle { this.store = taskStore; this.executionContext = executionContext; this.usageCounter = usageCounter; + this.config = config; const emitEvent = (event: TaskLifecycleEvent) => this.events$.next(event); @@ -240,6 +242,7 @@ export class TaskPollingLifecycle { defaultMaxAttempts: this.taskClaiming.maxAttempts, executionContext: this.executionContext, usageCounter: this.usageCounter, + eventLoopDelayConfig: { ...this.config.event_loop_delay }, }); }; diff --git a/x-pack/plugins/task_manager/server/task_events.test.ts b/x-pack/plugins/task_manager/server/task_events.test.ts new file mode 100644 index 0000000000000..5d72120da725c --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_events.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { startTaskTimer, startTaskTimerWithEventLoopMonitoring } from './task_events'; + +const DelayIterations = 4; +const DelayMillis = 250; +const DelayTotal = DelayIterations * DelayMillis; + +async function nonBlockingDelay(millis: number) { + await new Promise((resolve) => setTimeout(resolve, millis)); +} + +async function blockingDelay(millis: number) { + // get task in async queue + await nonBlockingDelay(0); + + const end = Date.now() + millis; + // eslint-disable-next-line no-empty + while (Date.now() < end) {} +} + +async function nonBlockingTask() { + for (let i = 0; i < DelayIterations; i++) { + await nonBlockingDelay(DelayMillis); + } +} + +async function blockingTask() { + for (let i = 0; i < DelayIterations; i++) { + await blockingDelay(DelayMillis); + } +} + +describe('task_events', () => { + test('startTaskTimer', async () => { + const stopTaskTimer = startTaskTimer(); + await nonBlockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).toBe(undefined); + }); + + describe('startTaskTimerWithEventLoopMonitoring', () => { + test('non-blocking', async () => { + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ + monitor: true, + warn_threshold: 5000, + }); + await nonBlockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).toBeLessThan(DelayMillis); + }); + + test('blocking', async () => { + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ + monitor: true, + warn_threshold: 5000, + }); + await blockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).not.toBeLessThan(DelayMillis); + }); + + test('not monitoring', async () => { + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ + monitor: false, + warn_threshold: 5000, + }); + await blockingTask(); + const result = stopTaskTimer(); + expect(result.stop - result.start).not.toBeLessThan(DelayTotal); + expect(result.eventLoopBlockMs).toBe(0); + }); + }); +}); diff --git a/x-pack/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts index 7c7845569a10b..de2c9dc04acd2 100644 --- a/x-pack/plugins/task_manager/server/task_events.ts +++ b/x-pack/plugins/task_manager/server/task_events.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { monitorEventLoopDelay } from 'perf_hooks'; + import { Option } from 'fp-ts/lib/Option'; import { ConcreteTaskInstance } from './task'; @@ -14,6 +16,7 @@ import { ClaimAndFillPoolResult } from './lib/fill_pool'; import { PollingError } from './polling'; import { TaskRunResult } from './task_running'; import { EphemeralTaskInstanceRequest } from './ephemeral_task_lifecycle'; +import type { EventLoopDelayConfig } from './config'; export enum TaskPersistence { Recurring = 'recurring', @@ -40,6 +43,7 @@ export enum TaskClaimErrorType { export interface TaskTiming { start: number; stop: number; + eventLoopBlockMs?: number; } export type WithTaskTiming = T & { timing: TaskTiming }; @@ -48,6 +52,22 @@ export function startTaskTimer(): () => TaskTiming { return () => ({ start, stop: Date.now() }); } +export function startTaskTimerWithEventLoopMonitoring( + eventLoopDelayConfig: EventLoopDelayConfig +): () => TaskTiming { + const stopTaskTimer = startTaskTimer(); + const eldHistogram = eventLoopDelayConfig.monitor ? monitorEventLoopDelay() : null; + eldHistogram?.enable(); + + return () => { + const { start, stop } = stopTaskTimer(); + eldHistogram?.disable(); + const eldMax = eldHistogram?.max ?? 0; + const eventLoopBlockMs = Math.round(eldMax / 1000 / 1000); // original in nanoseconds + return { start, stop, eventLoopBlockMs }; + }; +} + export interface TaskEvent { id?: ID; timing?: TaskTiming; diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index 09af125884fe9..ece82099728e3 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -1528,7 +1528,11 @@ describe('TaskManagerRunner', () => { function withAnyTiming(taskRun: TaskRun) { return { ...taskRun, - timing: { start: expect.any(Number), stop: expect.any(Number) }, + timing: { + start: expect.any(Number), + stop: expect.any(Number), + eventLoopBlockMs: expect.any(Number), + }, }; } @@ -1590,6 +1594,10 @@ describe('TaskManagerRunner', () => { onTaskEvent: opts.onTaskEvent, executionContext, usageCounter, + eventLoopDelayConfig: { + monitor: true, + warn_threshold: 5000, + }, }); if (stage === TaskRunningStage.READY_TO_RUN) { diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index 48927435c4bdf..778a834c168a1 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -38,7 +38,7 @@ import { TaskMarkRunning, asTaskRunEvent, asTaskMarkRunningEvent, - startTaskTimer, + startTaskTimerWithEventLoopMonitoring, TaskTiming, TaskPersistence, } from '../task_events'; @@ -56,6 +56,7 @@ import { } from '../task'; import { TaskTypeDictionary } from '../task_type_dictionary'; import { isUnrecoverableError } from './errors'; +import type { EventLoopDelayConfig } from '../config'; const defaultBackoffPerFailure = 5 * 60 * 1000; export const EMPTY_RUN_RESULT: SuccessfulRunResult = { state: {} }; @@ -105,6 +106,7 @@ type Opts = { defaultMaxAttempts: number; executionContext: ExecutionContextStart; usageCounter?: UsageCounter; + eventLoopDelayConfig: EventLoopDelayConfig; } & Pick; export enum TaskRunResult { @@ -152,6 +154,7 @@ export class TaskManagerRunner implements TaskRunner { private uuid: string; private readonly executionContext: ExecutionContextStart; private usageCounter?: UsageCounter; + private eventLoopDelayConfig: EventLoopDelayConfig; /** * Creates an instance of TaskManagerRunner. @@ -174,6 +177,7 @@ export class TaskManagerRunner implements TaskRunner { onTaskEvent = identity, executionContext, usageCounter, + eventLoopDelayConfig, }: Opts) { this.instance = asPending(sanitizeInstance(instance)); this.definitions = definitions; @@ -186,6 +190,7 @@ export class TaskManagerRunner implements TaskRunner { this.executionContext = executionContext; this.usageCounter = usageCounter; this.uuid = uuid.v4(); + this.eventLoopDelayConfig = eventLoopDelayConfig; } /** @@ -292,7 +297,7 @@ export class TaskManagerRunner implements TaskRunner { taskInstance: this.instance.task, }); - const stopTaskTimer = startTaskTimer(); + const stopTaskTimer = startTaskTimerWithEventLoopMonitoring(this.eventLoopDelayConfig); try { this.task = this.definition.createTaskRunner(modifiedContext); @@ -617,6 +622,18 @@ export class TaskManagerRunner implements TaskRunner { ); } ); + + const { eventLoopBlockMs = 0 } = taskTiming; + const taskLabel = `${this.taskType} ${this.instance.task.id}`; + if (eventLoopBlockMs > this.eventLoopDelayConfig.warn_threshold) { + this.logger.warn( + `event loop blocked for at least ${eventLoopBlockMs} ms while running task ${taskLabel}`, + { + tags: [this.taskType, taskLabel, 'event-loop-blocked'], + } + ); + } + return result; } From e6f225bc56890c3bfd4992549c09f92a638ffb5c Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Wed, 23 Mar 2022 14:32:39 +0000 Subject: [PATCH 079/132] Add loading state to the SparkPlot (#128196) --- .../error_group_list/index.tsx | 11 +- .../app/error_group_overview/index.tsx | 7 +- .../app/service_inventory/index.tsx | 4 + .../service_inventory/service_list/index.tsx | 9 + .../service_list/service_list.test.tsx | 10 ++ .../app/service_map/popover/stats_list.tsx | 1 + .../get_columns.tsx | 3 + .../service_overview_errors_table/index.tsx | 5 + ...ice_overview_instances_chart_and_table.tsx | 90 +++++----- .../get_columns.tsx | 7 + .../index.tsx | 3 + .../shared/charts/spark_plot/index.tsx | 161 ++++++++++++------ .../shared/dependencies_table/index.tsx | 12 ++ .../shared/transactions_table/get_columns.tsx | 5 + .../shared/transactions_table/index.tsx | 8 +- 15 files changed, 236 insertions(+), 100 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx index 7a54a633e7f15..6e86c8c951710 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/error_group_list/index.tsx @@ -59,6 +59,7 @@ type ErrorGroupDetailedStatistics = interface Props { mainStatistics: ErrorGroupItem[]; serviceName: string; + detailedStatisticsLoading: boolean; detailedStatistics: ErrorGroupDetailedStatistics; comparisonEnabled?: boolean; } @@ -66,6 +67,7 @@ interface Props { function ErrorGroupList({ mainStatistics, serviceName, + detailedStatisticsLoading, detailedStatistics, comparisonEnabled, }: Props) { @@ -210,6 +212,7 @@ function ErrorGroupList({ return ( >; - }, [serviceName, query, detailedStatistics, comparisonEnabled]); + }, [ + serviceName, + query, + detailedStatistics, + comparisonEnabled, + detailedStatisticsLoading, + ]); return ( { if (requestId && errorGroupMainStatistics.length && start && end) { @@ -189,6 +190,10 @@ export function ErrorGroupOverview() { diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index c26ae5a273b4e..807a848d649ea 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -191,6 +191,10 @@ export function ServiceInventory() { isLoading={isLoading} isFailure={isFailure} items={items} + comparisonDataLoading={ + comparisonFetch.status === FETCH_STATUS.LOADING || + comparisonFetch.status === FETCH_STATUS.NOT_INITIATED + } comparisonData={comparisonFetch?.data} noItemsMessage={noItemsMessage} /> diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index 2d01a11d92186..cc43be6a790ea 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -64,6 +64,7 @@ const SERVICE_HEALTH_STATUS_ORDER = [ export function getServiceColumns({ query, showTransactionTypeColumn, + comparisonDataLoading, comparisonData, breakpoints, showHealthStatusColumn, @@ -71,6 +72,7 @@ export function getServiceColumns({ query: TypeOf['query']; showTransactionTypeColumn: boolean; showHealthStatusColumn: boolean; + comparisonDataLoading: boolean; breakpoints: Breakpoints; comparisonData?: ServicesDetailedStatisticsAPIResponse; }): Array> { @@ -162,6 +164,7 @@ export function getServiceColumns({ ); return ( { describe('when small', () => { it('shows environment, transaction type and sparklines', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -96,6 +97,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={false} + isLoading={false} valueLabel="0 ms" /> `); @@ -105,6 +107,7 @@ describe('ServiceList', () => { describe('when Large', () => { it('hides environment, transaction type and sparklines', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -122,6 +125,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={true} + isLoading={false} valueLabel="0 ms" /> `); @@ -130,6 +134,7 @@ describe('ServiceList', () => { describe('when XL', () => { it('hides transaction type', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -156,6 +161,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={false} + isLoading={false} valueLabel="0 ms" /> `); @@ -165,6 +171,7 @@ describe('ServiceList', () => { describe('when XXL', () => { it('hides transaction type', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, @@ -192,6 +199,7 @@ describe('ServiceList', () => { color="green" comparisonSeriesColor="black" hideSeries={false} + isLoading={false} valueLabel="0 ms" /> `); @@ -203,6 +211,7 @@ describe('ServiceList', () => { describe('without ML data', () => { it('hides healthStatus column', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: false, query, showTransactionTypeColumn: true, @@ -219,6 +228,7 @@ describe('ServiceList', () => { describe('with ML data', () => { it('shows healthStatus column', () => { const renderedColumns = getServiceColumns({ + comparisonDataLoading: false, showHealthStatusColumn: true, query, showTransactionTypeColumn: true, diff --git a/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx b/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx index 7cc0e158fe52d..e5ed89571165e 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/popover/stats_list.tsx @@ -198,6 +198,7 @@ export function StatsList({ data, isLoading }: StatsListProps) { {timeseries ? ( ['query']; @@ -129,6 +131,7 @@ export function getColumns({ return ( { if (requestId && items.length && start && end) { @@ -176,8 +177,12 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { { preservePreviousData: false } ); + const errorGroupDetailedStatisticsLoading = + errorGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING; + const columns = getColumns({ serviceName, + errorGroupDetailedStatisticsLoading, errorGroupDetailedStatistics, comparisonEnabled, query, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index dfea13eaaf476..bbe94f8e8aae8 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -172,50 +172,52 @@ export function ServiceOverviewInstancesChartAndTable({ direction ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); - const { data: detailedStatsData = INITIAL_STATE_DETAILED_STATISTICS } = - useFetcher( - (callApmApi) => { - if ( - !start || - !end || - !transactionType || - !latencyAggregationType || - !currentPeriodItemsCount - ) { - return; - } + const { + data: detailedStatsData = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatsStatus, + } = useFetcher( + (callApmApi) => { + if ( + !start || + !end || + !transactionType || + !latencyAggregationType || + !currentPeriodItemsCount + ) { + return; + } - return callApmApi( - 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', - { - params: { - path: { - serviceName, - }, - query: { - environment, - kuery, - latencyAggregationType: - latencyAggregationType as LatencyAggregationType, - start, - end, - numBuckets: 20, - transactionType, - serviceNodeIds: JSON.stringify( - currentPeriodOrderedItems.map((item) => item.serviceNodeName) - ), - comparisonStart, - comparisonEnd, - }, + return callApmApi( + 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', + { + params: { + path: { + serviceName, }, - } - ); - }, - // only fetches detailed statistics when requestId is invalidated by main statistics api call - // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId], - { preservePreviousData: false } - ); + query: { + environment, + kuery, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, + start, + end, + numBuckets: 20, + transactionType, + serviceNodeIds: JSON.stringify( + currentPeriodOrderedItems.map((item) => item.serviceNodeName) + ), + comparisonStart, + comparisonEnd, + }, + }, + } + ); + }, + // only fetches detailed statistics when requestId is invalidated by main statistics api call + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId], + { preservePreviousData: false } + ); return ( <> @@ -233,6 +235,10 @@ export function ServiceOverviewInstancesChartAndTable({ mainStatsItems={currentPeriodOrderedItems} mainStatsStatus={mainStatsStatus} mainStatsItemCount={currentPeriodItemsCount} + detailedStatsLoading={ + detailedStatsStatus === FETCH_STATUS.LOADING || + detailedStatsStatus === FETCH_STATUS.NOT_INITIATED + } detailedStatsData={detailedStatsData} serviceName={serviceName} tableOptions={tableOptions} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index 0b6e846f95239..26a117224ace1 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -48,6 +48,7 @@ export function getColumns({ kuery, agentName, latencyAggregationType, + detailedStatsLoading, detailedStatsData, comparisonEnabled, toggleRowDetails, @@ -60,6 +61,7 @@ export function getColumns({ kuery: string; agentName?: string; latencyAggregationType?: LatencyAggregationType; + detailedStatsLoading: boolean; detailedStatsData?: ServiceInstanceDetailedStatistics; comparisonEnabled?: boolean; toggleRowDetails: (selectedServiceNodeName: string) => void; @@ -125,6 +127,7 @@ export function getColumns({ color={currentPeriodColor} valueLabel={asMillisecondDuration(latency)} hideSeries={!shouldShowSparkPlots} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -158,6 +161,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asTransactionRate(throughput)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -191,6 +195,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asPercent(errorRate, 1)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -224,6 +229,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asPercent(cpuUsage, 1)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined @@ -257,6 +263,7 @@ export function getColumns({ color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} valueLabel={asPercent(memoryUsage, 1)} + isLoading={detailedStatsLoading} series={currentPeriodTimestamp} comparisonSeries={ comparisonEnabled ? previousPeriodTimestamp : undefined diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index c12b0a19644f5..b49208e2cdde7 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -53,6 +53,7 @@ interface Props { page?: { index: number }; sort?: { field: string; direction: SortDirection }; }) => void; + detailedStatsLoading: boolean; detailedStatsData?: ServiceInstanceDetailedStatistics; isLoading: boolean; isNotInitiated: boolean; @@ -64,6 +65,7 @@ export function ServiceOverviewInstancesTable({ mainStatsStatus: status, tableOptions, onChangeTableOptions, + detailedStatsLoading, detailedStatsData: detailedStatsData, isLoading, isNotInitiated, @@ -124,6 +126,7 @@ export function ServiceOverviewInstancesTable({ serviceName, kuery, latencyAggregationType: latencyAggregationType as LatencyAggregationType, + detailedStatsLoading, detailedStatsData, comparisonEnabled, toggleRowDetails, diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 325eb3d12f899..c497d35ed2cf6 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -14,7 +14,12 @@ import { ScaleType, Settings, } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingChart, +} from '@elastic/eui'; import React from 'react'; import { useChartTheme } from '../../../../../../observability/public'; import { Coordinate } from '../../../../../typings/timeseries'; @@ -32,6 +37,7 @@ const flexGroupStyle = { overflow: 'hidden' }; export function SparkPlot({ color, + isLoading, series, comparisonSeries = [], valueLabel, @@ -39,11 +45,52 @@ export function SparkPlot({ comparisonSeriesColor, }: { color: string; + isLoading: boolean; series?: Coordinate[] | null; valueLabel: React.ReactNode; compact?: boolean; comparisonSeries?: Coordinate[]; comparisonSeriesColor: string; +}) { + return ( + + + {valueLabel} + + + + + + ); +} + +function SparkPlotItem({ + color, + isLoading, + series, + comparisonSeries, + comparisonSeriesColor, + compact, +}: { + color: string; + isLoading: boolean; + series?: Coordinate[] | null; + compact?: boolean; + comparisonSeries?: Coordinate[]; + comparisonSeriesColor: string; }) { const theme = useTheme(); const defaultChartTheme = useChartTheme(); @@ -68,61 +115,65 @@ export function SparkPlot({ const Sparkline = hasComparisonSeries ? LineSeries : AreaSeries; + if (isLoading) { + return ( +
+ +
+ ); + } + + if (hasValidTimeseries(series)) { + return ( + + + + {hasComparisonSeries && ( + + )} + + ); + } + return ( - - - {valueLabel} - - - {hasValidTimeseries(series) ? ( - - - - {hasComparisonSeries && ( - - )} - - ) : ( -
- -
- )} -
-
+ +
); } diff --git a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx index 0986c8fe587de..a0dba6f5b870b 100644 --- a/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/dependencies_table/index.tsx @@ -97,6 +97,10 @@ export function DependenciesTable(props: Props) { compact color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} + isLoading={ + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + } series={currentStats.latency.timeseries} comparisonSeries={previousStats?.latency.timeseries} valueLabel={asMillisecondDuration(currentStats.latency.value)} @@ -122,6 +126,10 @@ export function DependenciesTable(props: Props) { compact color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} + isLoading={ + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + } series={currentStats.throughput.timeseries} comparisonSeries={previousStats?.throughput.timeseries} valueLabel={asTransactionRate(currentStats.throughput.value)} @@ -168,6 +176,10 @@ export function DependenciesTable(props: Props) { compact color={currentPeriodColor} hideSeries={!shouldShowSparkPlots} + isLoading={ + status === FETCH_STATUS.LOADING || + status === FETCH_STATUS.NOT_INITIATED + } series={currentStats.errorRate.timeseries} comparisonSeries={previousStats?.errorRate.timeseries} valueLabel={asPercent(currentStats.errorRate.value, 1)} diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx index ecfe277247d4c..054514f430a07 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/get_columns.tsx @@ -46,6 +46,7 @@ type TransactionGroupDetailedStatistics = export function getColumns({ serviceName, latencyAggregationType, + transactionGroupDetailedStatisticsLoading, transactionGroupDetailedStatistics, comparisonEnabled, shouldShowSparkPlots = true, @@ -53,6 +54,7 @@ export function getColumns({ }: { serviceName: string; latencyAggregationType?: LatencyAggregationType; + transactionGroupDetailedStatisticsLoading: boolean; transactionGroupDetailedStatistics?: TransactionGroupDetailedStatistics; comparisonEnabled?: boolean; shouldShowSparkPlots?: boolean; @@ -106,6 +108,7 @@ export function getColumns({ color={currentPeriodColor} compact hideSeries={!shouldShowSparkPlots} + isLoading={transactionGroupDetailedStatisticsLoading} series={currentTimeseries} comparisonSeries={ comparisonEnabled ? previousTimeseries : undefined @@ -140,6 +143,7 @@ export function getColumns({ color={currentPeriodColor} compact hideSeries={!shouldShowSparkPlots} + isLoading={transactionGroupDetailedStatisticsLoading} series={currentTimeseries} comparisonSeries={ comparisonEnabled ? previousTimeseries : undefined @@ -196,6 +200,7 @@ export function getColumns({ color={currentPeriodColor} compact hideSeries={!shouldShowSparkPlots} + isLoading={transactionGroupDetailedStatisticsLoading} series={currentTimeseries} comparisonSeries={ comparisonEnabled ? previousTimeseries : undefined diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 6134f9c3cdcb1..66f068f6cb05c 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -182,7 +182,10 @@ export function TransactionsTable({ }, } = data; - const { data: transactionGroupDetailedStatistics } = useFetcher( + const { + data: transactionGroupDetailedStatistics, + status: transactionGroupDetailedStatisticsStatus, + } = useFetcher( (callApmApi) => { if ( transactionGroupsTotalItems && @@ -225,6 +228,9 @@ export function TransactionsTable({ const columns = getColumns({ serviceName, latencyAggregationType: latencyAggregationType as LatencyAggregationType, + transactionGroupDetailedStatisticsLoading: + transactionGroupDetailedStatisticsStatus === FETCH_STATUS.LOADING || + transactionGroupDetailedStatisticsStatus === FETCH_STATUS.NOT_INITIATED, transactionGroupDetailedStatistics, comparisonEnabled, shouldShowSparkPlots, From d48b82ad5b2f156f0c9c4740f6caaa7cdeff3f29 Mon Sep 17 00:00:00 2001 From: Faisal Kanout Date: Wed, 23 Mar 2022 17:49:05 +0300 Subject: [PATCH 080/132] [RAC][APM] Add "View in App URL" {{context.viewInAppUrl}} variable to the rule templating language (#128243) * Add viewInAppUrl variable * export getAlertUrl as common function * add getAlertUrl to the error rule type * Add viewInAppUrl for Error rule type * Fix tests mocking * Add viewInAppUrl to tracation duration * Add viewInAppUrl for transaction duration * Add viewInAppUrl to anomolay alert * Fix funxtion arg * Add viewInAppUrl to TransactionDurationAnomaly * Get all related code to use the shared functions * Add/Fix tests * Update file name to snack case * Add comment * Fix lint * Remove join * Fix basePath mock * Code Review - refactor foramtting functions * Remove comment * fix typo --- .../apm/common/utils/formatters/alert_url.ts | 42 ++++++++++ .../apm/common/utils/formatters/index.ts | 1 + .../alerting/register_apm_alerts.ts | 81 +++++++------------ x-pack/plugins/apm/server/plugin.ts | 1 + .../server/routes/alerts/action_variables.ts | 10 +++ .../routes/alerts/register_apm_alerts.ts | 3 +- .../register_error_count_alert_type.test.ts | 6 ++ .../alerts/register_error_count_alert_type.ts | 19 ++++- ...er_transaction_duration_alert_type.test.ts | 2 + ...egister_transaction_duration_alert_type.ts | 18 +++++ ...action_duration_anomaly_alert_type.test.ts | 2 + ...transaction_duration_anomaly_alert_type.ts | 18 +++++ ..._transaction_error_rate_alert_type.test.ts | 2 + ...ister_transaction_error_rate_alert_type.ts | 16 ++++ .../server/routes/alerts/test_utils/index.ts | 7 +- 15 files changed, 172 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/apm/common/utils/formatters/alert_url.ts diff --git a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts new file mode 100644 index 0000000000000..a88f69b4ef5c7 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts @@ -0,0 +1,42 @@ +/* + * 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 { stringify } from 'querystring'; +import { ENVIRONMENT_ALL } from '../../environment_filter_values'; + +const format = ({ + pathname, + query, +}: { + pathname: string; + query: Record; +}): string => { + return `${pathname}?${stringify(query)}`; +}; + +export const getAlertUrlErrorCount = ( + serviceName: string, + serviceEnv: string | undefined +) => + format({ + pathname: `/app/apm/services/${serviceName}/errors`, + query: { + environment: serviceEnv ?? ENVIRONMENT_ALL.value, + }, + }); +// This formatter is for TransactionDuration, TransactionErrorRate, and TransactionDurationAnomaly. +export const getAlertUrlTransaction = ( + serviceName: string, + serviceEnv: string | undefined, + transactionType: string +) => + format({ + pathname: `/app/apm/services/${serviceName}`, + query: { + transactionType, + environment: serviceEnv ?? ENVIRONMENT_ALL.value, + }, + }); diff --git a/x-pack/plugins/apm/common/utils/formatters/index.ts b/x-pack/plugins/apm/common/utils/formatters/index.ts index 1a431867308b6..f510a54b37102 100644 --- a/x-pack/plugins/apm/common/utils/formatters/index.ts +++ b/x-pack/plugins/apm/common/utils/formatters/index.ts @@ -9,3 +9,4 @@ export * from './formatters'; export * from './datetime'; export * from './duration'; export * from './size'; +export * from './alert_url'; diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 3be124573728b..692165f2b2ff5 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -7,10 +7,12 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { stringify } from 'querystring'; import { ALERT_REASON } from '@kbn/rule-data-utils'; import type { ObservabilityRuleTypeRegistry } from '../../../../observability/public'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { + getAlertUrlErrorCount, + getAlertUrlTransaction, +} from '../../../common/utils/formatters'; import { AlertType } from '../../../common/alert_types'; // copied from elasticsearch_fieldnames.ts to limit page load bundle size @@ -18,16 +20,6 @@ const SERVICE_ENVIRONMENT = 'service.environment'; const SERVICE_NAME = 'service.name'; const TRANSACTION_TYPE = 'transaction.type'; -const format = ({ - pathname, - query, -}: { - pathname: string; - query: Record; -}): string => { - return `${pathname}?${stringify(query)}`; -}; - export function registerApmAlerts( observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry ) { @@ -40,16 +32,10 @@ export function registerApmAlerts( format: ({ fields }) => { return { reason: fields[ALERT_REASON]!, - link: format({ - pathname: `/app/apm/services/${String( - fields[SERVICE_NAME][0] - )}/errors`, - query: { - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), + link: getAlertUrlErrorCount( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]) + ), }; }, iconClass: 'bell', @@ -83,19 +69,16 @@ export function registerApmAlerts( 'Alert when the latency of a specific transaction type in a service exceeds a defined threshold.', } ), - format: ({ fields, formatters: { asDuration } }) => ({ - reason: fields[ALERT_REASON]!, - - link: format({ - pathname: `/app/apm/services/${fields[SERVICE_NAME][0]!}`, - query: { - transactionType: fields[TRANSACTION_TYPE][0]!, - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), - }), + format: ({ fields, formatters: { asDuration } }) => { + return { + reason: fields[ALERT_REASON]!, + link: getAlertUrlTransaction( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]), + String(fields[TRANSACTION_TYPE][0]!) + ), + }; + }, iconClass: 'bell', documentationUrl(docLinks) { return `${docLinks.links.alerting.apmRules}`; @@ -132,15 +115,11 @@ export function registerApmAlerts( ), format: ({ fields, formatters: { asPercent } }) => ({ reason: fields[ALERT_REASON]!, - link: format({ - pathname: `/app/apm/services/${String(fields[SERVICE_NAME][0]!)}`, - query: { - transactionType: String(fields[TRANSACTION_TYPE][0]!), - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), + link: getAlertUrlTransaction( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]), + String(fields[TRANSACTION_TYPE][0]!) + ), }), iconClass: 'bell', documentationUrl(docLinks) { @@ -177,15 +156,11 @@ export function registerApmAlerts( ), format: ({ fields }) => ({ reason: fields[ALERT_REASON]!, - link: format({ - pathname: `/app/apm/services/${String(fields[SERVICE_NAME][0])}`, - query: { - transactionType: String(fields[TRANSACTION_TYPE][0]), - ...(fields[SERVICE_ENVIRONMENT]?.[0] - ? { environment: String(fields[SERVICE_ENVIRONMENT][0]) } - : { environment: ENVIRONMENT_ALL.value }), - }, - }), + link: getAlertUrlTransaction( + String(fields[SERVICE_NAME][0]!), + fields[SERVICE_ENVIRONMENT] && String(fields[SERVICE_ENVIRONMENT][0]), + String(fields[TRANSACTION_TYPE][0]!) + ), }), iconClass: 'bell', documentationUrl(docLinks) { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 2dda29019239a..4e603741ea2a5 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -194,6 +194,7 @@ export class APMPlugin ml: plugins.ml, config$, logger: this.logger!.get('rule'), + basePath: core.http.basePath, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/action_variables.ts b/x-pack/plugins/apm/server/routes/alerts/action_variables.ts index 540cd9ffd4946..ce78dbc7bee6d 100644 --- a/x-pack/plugins/apm/server/routes/alerts/action_variables.ts +++ b/x-pack/plugins/apm/server/routes/alerts/action_variables.ts @@ -65,4 +65,14 @@ export const apmActionVariables = { ), name: 'reason' as const, }, + viewInAppUrl: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.viewInAppUrl', + { + defaultMessage: + 'Link to the view or feature within Elastic that can be used to investigate the alert and its context further', + } + ), + name: 'viewInAppUrl' as const, + }, }; diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts index db79b4f11df29..4556abfea1ee5 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts @@ -6,7 +6,7 @@ */ import { Observable } from 'rxjs'; -import { Logger } from 'kibana/server'; +import { IBasePath, Logger } from 'kibana/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../alerting/server'; import { IRuleDataClient } from '../../../../rule_registry/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; @@ -22,6 +22,7 @@ export interface RegisterRuleDependencies { alerting: AlertingPluginSetupContract; config$: Observable; logger: Logger; + basePath: IBasePath; } export function registerApmAlerts(dependencies: RegisterRuleDependencies) { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts index 175b87f7943b0..3125791e7853b 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.test.ts @@ -144,6 +144,8 @@ describe('Error count alert', () => { triggerValue: 5, reason: 'Error count is 5 in the last 5 mins for foo. Alert when > 2.', interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', @@ -152,6 +154,8 @@ describe('Error count alert', () => { triggerValue: 4, reason: 'Error count is 4 in the last 5 mins for foo. Alert when > 2.', interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', @@ -160,6 +164,8 @@ describe('Error count alert', () => { threshold: 2, triggerValue: 3, interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts index f5df3c946f46e..5fc32ea363bc6 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_error_count_alert_type.ts @@ -18,6 +18,7 @@ import { getEnvironmentEsField, getEnvironmentLabel, } from '../../../common/environment_filter_values'; +import { getAlertUrlErrorCount } from '../../../common/utils/formatters'; import { AlertType, APM_SERVER_FEATURE_ID, @@ -52,6 +53,7 @@ export function registerErrorCountAlertType({ logger, ruleDataClient, config$, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -75,6 +77,7 @@ export function registerErrorCountAlertType({ apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, @@ -83,11 +86,11 @@ export function registerErrorCountAlertType({ executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); const ruleParams = params; + const indices = await getApmIndices({ config, savedObjectsClient: services.savedObjectsClient, }); - const searchParams = { index: indices.error, size: 0, @@ -147,6 +150,19 @@ export function registerErrorCountAlertType({ windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlErrorCount( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT] + ); + + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; + services .alertWithLifecycle({ id: [AlertType.ErrorCount, serviceName, environment] @@ -168,6 +184,7 @@ export function registerErrorCountAlertType({ triggerValue: errorCount, interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: alertReason, + viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts index 6a3feed69c19a..57b596bf94087 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.test.ts @@ -57,6 +57,8 @@ describe('registerTransactionDurationAlertType', () => { interval: `5m`, reason: 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts index 4567670129720..bfbb2a99c662c 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_alert_type.ts @@ -13,6 +13,7 @@ import { ALERT_REASON, } from '@kbn/rule-data-utils'; import { take } from 'rxjs/operators'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; import { asDuration } from '../../../../observability/common/utils/formatters'; import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; import { SearchAggregatedTransactionSetting } from '../../../common/aggregated_transactions'; @@ -26,6 +27,7 @@ import { PROCESSOR_EVENT, SERVICE_NAME, TRANSACTION_TYPE, + SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { getEnvironmentEsField, @@ -64,6 +66,7 @@ export function registerTransactionDurationAlertType({ ruleDataClient, config$, logger, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -87,6 +90,7 @@ export function registerTransactionDurationAlertType({ apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, @@ -188,6 +192,19 @@ export function registerTransactionDurationAlertType({ windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlTransaction( + ruleParams.serviceName, + getEnvironmentEsField(ruleParams.environment)?.[SERVICE_ENVIRONMENT], + ruleParams.transactionType + ); + + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; services .alertWithLifecycle({ id: `${AlertType.TransactionDuration}_${getEnvironmentLabel( @@ -211,6 +228,7 @@ export function registerTransactionDurationAlertType({ triggerValue: transactionDurationFormatted, interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: reasonMessage, + viewInAppUrl, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts index 585fadc348700..2bb8530ca03f6 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts @@ -201,6 +201,8 @@ describe('Transaction duration anomaly alert', () => { triggerValue: 'critical', reason: 'critical anomaly with a score of 80 was detected in the last 5 mins for foo.', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=development', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts index fbdd7f5e33f0a..64f06c9f638f1 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -22,7 +22,9 @@ import { PROCESSOR_EVENT, SERVICE_NAME, TRANSACTION_TYPE, + SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; import { asMutableArray } from '../../../common/utils/as_mutable_array'; import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { KibanaRequest } from '../../../../../../src/core/server'; @@ -63,6 +65,7 @@ export function registerTransactionDurationAnomalyAlertType({ ruleDataClient, alerting, ml, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ logger, @@ -86,6 +89,7 @@ export function registerTransactionDurationAnomalyAlertType({ apmActionVariables.threshold, apmActionVariables.triggerValue, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: 'apm', @@ -218,6 +222,19 @@ export function registerTransactionDurationAnomalyAlertType({ windowSize: params.windowSize, windowUnit: params.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlTransaction( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], + transactionType + ); + + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; services .alertWithLifecycle({ id: [ @@ -246,6 +263,7 @@ export function registerTransactionDurationAnomalyAlertType({ threshold: selectedOption?.label, triggerValue: severityLevel, reason: reasonMessage, + viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts index 36ec8e6ce205f..d3a024ec92a73 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.test.ts @@ -129,6 +129,8 @@ describe('Transaction error rate alert', () => { threshold: 10, triggerValue: '10', interval: '5m', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts index 0f68e74a2a9bc..219f992ad15be 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_transaction_error_rate_alert_type.ts @@ -17,6 +17,7 @@ import { getEnvironmentEsField, getEnvironmentLabel, } from '../../../common/environment_filter_values'; +import { getAlertUrlTransaction } from '../../../common/utils/formatters'; import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; import { AlertType, @@ -60,6 +61,7 @@ export function registerTransactionErrorRateAlertType({ ruleDataClient, logger, config$, + basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -84,6 +86,7 @@ export function registerTransactionErrorRateAlertType({ apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, @@ -207,6 +210,18 @@ export function registerTransactionErrorRateAlertType({ windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); + + const relativeViewInAppUrl = getAlertUrlTransaction( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], + transactionType + ); + const viewInAppUrl = basePath.publicBaseUrl + ? new URL( + basePath.prepend(relativeViewInAppUrl), + basePath.publicBaseUrl + ).toString() + : relativeViewInAppUrl; services .alertWithLifecycle({ id: [ @@ -235,6 +250,7 @@ export function registerTransactionErrorRateAlertType({ triggerValue: asDecimalOrInteger(errorRate), interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: reasonMessage, + viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts index a34b3cdb1334d..71a4e0d3d111e 100644 --- a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Logger } from 'kibana/server'; +import { IBasePath, Logger } from 'kibana/server'; import { of } from 'rxjs'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; import { IRuleDataClient } from '../../../../../rule_registry/server'; @@ -56,6 +56,11 @@ export const createRuleTypeMocks = () => { ruleDataClient: ruleRegistryMocks.createRuleDataClient( '.alerts-observability.apm.alerts' ) as IRuleDataClient, + basePath: { + serverBasePath: '/eyr', + publicBaseUrl: 'http://localhost:5601/eyr', + prepend: (path: string) => `http://localhost:5601/eyr${path}`, + } as IBasePath, }, services, scheduleActions, From c55bb917fab509df7922ac727d4246514dbd7a59 Mon Sep 17 00:00:00 2001 From: Karl Godard Date: Wed, 23 Mar 2022 08:23:22 -0700 Subject: [PATCH 081/132] [Security team: AWP] Session view: Alert details tab (#127500) * alerts tab work. list view done * View mode toggle + group view implemented * tests written * clean up * addressed @opauloh comments * fixed weird bug due to importing assests from a test into its component * empty state added for alerts tab * react-query caching keys updated to include sessionEntityId * rule_registry added as a dependency in order to use AlertsClient in alerts_route.ts * fixed build/test errors due to merge. events route now orders by process.start then @timestamp * plumbing for the alert details tie in done. * removed rule_registry ecs mappings. kqualters PR will add this. * alerts index merge conflict fix Co-authored-by: mitodrummer --- .../plugins/session_view/common/constants.ts | 3 +- x-pack/plugins/session_view/kibana.json | 7 +- .../detail_panel_alert_actions/index.test.tsx | 88 ++++++ .../detail_panel_alert_actions/index.tsx | 105 ++++++++ .../detail_panel_alert_actions/styles.ts | 107 ++++++++ .../detail_panel_alert_group_item/index.tsx | 84 ++++++ .../detail_panel_alert_list_item/index.tsx | 137 ++++++++++ .../detail_panel_alert_list_item/styles.ts | 112 ++++++++ .../detail_panel_alert_tab/index.test.tsx | 251 ++++++++++++++++++ .../detail_panel_alert_tab/index.tsx | 146 ++++++++++ .../detail_panel_alert_tab/styles.ts | 43 +++ .../public/components/process_tree/hooks.ts | 13 + .../components/process_tree/index.test.tsx | 5 +- .../public/components/process_tree/index.tsx | 12 +- .../process_tree_alert/index.test.tsx | 10 +- .../components/process_tree_alert/index.tsx | 12 +- .../process_tree_alerts/index.test.tsx | 2 +- .../components/process_tree_alerts/index.tsx | 9 +- .../process_tree_node/index.test.tsx | 2 +- .../components/process_tree_node/index.tsx | 48 +++- .../public/components/session_view/hooks.ts | 35 ++- .../public/components/session_view/index.tsx | 34 ++- .../public/components/session_view/styles.ts | 11 + .../session_view_detail_panel/index.test.tsx | 48 +++- .../session_view_detail_panel/index.tsx | 71 +++-- x-pack/plugins/session_view/server/plugin.ts | 12 +- .../server/routes/alerts_route.test.ts | 133 ++++++++++ .../server/routes/alerts_route.ts | 66 +++++ .../session_view/server/routes/index.ts | 5 +- .../server/routes/process_events_route.ts | 28 +- x-pack/plugins/session_view/server/types.ts | 15 +- x-pack/plugins/session_view/tsconfig.json | 3 +- 32 files changed, 1555 insertions(+), 102 deletions(-) create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts create mode 100644 x-pack/plugins/session_view/server/routes/alerts_route.test.ts create mode 100644 x-pack/plugins/session_view/server/routes/alerts_route.ts diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 42e1d33ab6dba..9e8e1ae0d5e04 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -6,10 +6,11 @@ */ export const PROCESS_EVENTS_ROUTE = '/internal/session_view/process_events_route'; +export const ALERTS_ROUTE = '/internal/session_view/alerts_route'; export const ALERT_STATUS_ROUTE = '/internal/session_view/alert_status_route'; export const SESSION_ENTRY_LEADERS_ROUTE = '/internal/session_view/session_entry_leaders_route'; export const PROCESS_EVENTS_INDEX = 'logs-endpoint.events.process-default'; -export const ALERTS_INDEX = '.siem-signals-default'; +export const ALERTS_INDEX = '.alerts-security.alerts-default'; // TODO: changes to remove this and use AlertsClient instead to get indices. export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id'; export const ALERT_UUID_PROPERTY = 'kibana.alert.uuid'; export const KIBANA_DATE_FORMAT = 'MMM DD, YYYY @ hh:mm:ss.SSS'; diff --git a/x-pack/plugins/session_view/kibana.json b/x-pack/plugins/session_view/kibana.json index ff9d849016c55..4807315569d34 100644 --- a/x-pack/plugins/session_view/kibana.json +++ b/x-pack/plugins/session_view/kibana.json @@ -1,6 +1,6 @@ { "id": "sessionView", - "version": "8.0.0", + "version": "1.0.0", "kibanaVersion": "kibana", "owner": { "name": "Security Team", @@ -8,10 +8,11 @@ }, "requiredPlugins": [ "data", - "timelines" + "timelines", + "ruleRegistry" ], "requiredBundles": [ - "kibanaReact", + "kibanaReact", "esUiShared" ], "server": true, diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx new file mode 100644 index 0000000000000..1d0c9d0227699 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { + DetailPanelAlertActions, + BUTTON_TEST_ID, + SHOW_DETAILS_TEST_ID, + JUMP_TO_PROCESS_TEST_ID, +} from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import userEvent from '@testing-library/user-event'; +import { ProcessImpl } from '../process_tree/hooks'; + +describe('DetailPanelAlertActions component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockShowAlertDetails = jest.fn((uuid) => uuid); + let mockOnProcessSelected = jest.fn((process) => process); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockShowAlertDetails = jest.fn((uuid) => uuid); + mockOnProcessSelected = jest.fn((process) => process); + }); + + describe('When DetailPanelAlertActions is mounted', () => { + it('renders a popover when button is clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + expect(renderResult.queryByTestId(SHOW_DETAILS_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(JUMP_TO_PROCESS_TEST_ID)).toBeTruthy(); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls alert flyout callback when View details clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(SHOW_DETAILS_TEST_ID)); + expect(mockShowAlertDetails.mock.calls.length).toBe(1); + expect(mockShowAlertDetails.mock.results[0].value).toBe(mockEvent.kibana?.alert.uuid); + expect(mockOnProcessSelected.mock.calls.length).toBe(0); + }); + + it('calls onProcessSelected when Jump to process clicked', async () => { + const mockEvent = mockAlerts[0]; + + renderResult = mockedContext.render( + + ); + + userEvent.click(renderResult.getByTestId(BUTTON_TEST_ID)); + userEvent.click(renderResult.getByTestId(JUMP_TO_PROCESS_TEST_ID)); + expect(mockOnProcessSelected.mock.calls.length).toBe(1); + expect(mockOnProcessSelected.mock.results[0].value).toBeInstanceOf(ProcessImpl); + expect(mockShowAlertDetails.mock.calls.length).toBe(0); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx new file mode 100644 index 0000000000000..4c7e3fdfaa961 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/index.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiPopover, EuiContextMenuPanel, EuiButtonIcon, EuiContextMenuItem } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { ProcessImpl } from '../process_tree/hooks'; + +export const BUTTON_TEST_ID = 'sessionView:detailPanelAlertActionsBtn'; +export const SHOW_DETAILS_TEST_ID = 'sessionView:detailPanelAlertActionShowDetails'; +export const JUMP_TO_PROCESS_TEST_ID = 'sessionView:detailPanelAlertActionJumpToProcess'; + +interface DetailPanelAlertActionsDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; +} + +/** + * Detail panel alert context menu actions + */ +export const DetailPanelAlertActions = ({ + event, + onShowAlertDetails, + onProcessSelected, +}: DetailPanelAlertActionsDeps) => { + const [isPopoverOpen, setPopover] = useState(false); + + const onClosePopover = useCallback(() => { + setPopover(false); + }, []); + + const onToggleMenu = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const onJumpToAlert = useCallback(() => { + const process = new ProcessImpl(event.process.entity_id); + process.addEvent(event); + + onProcessSelected(process); + setPopover(false); + }, [event, onProcessSelected]); + + const onShowDetails = useCallback(() => { + if (event.kibana) { + onShowAlertDetails(event.kibana.alert.uuid); + setPopover(false); + } + }, [event, onShowAlertDetails]); + + if (!event.kibana) { + return null; + } + + const { uuid } = event.kibana.alert; + + const menuItems = [ + + + , + + + , + ]; + + return ( + + } + isOpen={isPopoverOpen} + closePopover={onClosePopover} + panelPaddingSize="none" + anchorPosition="leftCenter" + > + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts new file mode 100644 index 0000000000000..14d0be374b5d1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_actions/styles.ts @@ -0,0 +1,107 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +interface StylesDeps { + minimal?: boolean; + isInvestigated?: boolean; +} + +export const useStyles = ({ minimal = false, isInvestigated = false }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: mediumPadding, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.tsx new file mode 100644 index 0000000000000..daa472cd6e5b4 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_group_item/index.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 React, { useMemo } from 'react'; +import { EuiIcon, EuiText, EuiAccordion, EuiNotificationBadge } from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from '../detail_panel_alert_list_item/styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; + +export const ALERT_GROUP_ITEM_TEST_ID = 'sessionView:detailPanelAlertGroupItem'; +export const ALERT_GROUP_ITEM_COUNT_TEST_ID = 'sessionView:detailPanelAlertGroupCount'; +export const ALERT_GROUP_ITEM_TITLE_TEST_ID = 'sessionView:detailPanelAlertGroupTitle'; + +interface DetailPanelAlertsGroupItemDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertGroupItem = ({ + alerts, + onProcessSelected, + onShowAlertDetails, +}: DetailPanelAlertsGroupItemDeps) => { + const styles = useStyles(); + + const alertsCount = useMemo(() => { + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + if (!alerts[0].kibana) { + return null; + } + + const { rule } = alerts[0].kibana.alert; + + return ( + +

+ + {rule.name} +

+ + } + css={styles.alertItem} + extraAction={ + + {alertsCount} + + } + > + {alerts.map((event) => { + const key = 'minimal_' + event.kibana?.alert.uuid; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx new file mode 100644 index 0000000000000..516d04539432e --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/index.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiIcon, + EuiText, + EuiAccordion, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertActions } from '../detail_panel_alert_actions'; + +export const ALERT_LIST_ITEM_TEST_ID = 'sessionView:detailPanelAlertListItem'; +export const ALERT_LIST_ITEM_ARGS_TEST_ID = 'sessionView:detailPanelAlertListItemArgs'; +export const ALERT_LIST_ITEM_TIMESTAMP_TEST_ID = 'sessionView:detailPanelAlertListItemTimestamp'; + +interface DetailPanelAlertsListItemDeps { + event: ProcessEvent; + onShowAlertDetails: (alertId: string) => void; + onProcessSelected: (process: Process) => void; + isInvestigated?: boolean; + minimal?: boolean; +} + +/** + * Detail panel description list item. + */ +export const DetailPanelAlertListItem = ({ + event, + onProcessSelected, + onShowAlertDetails, + isInvestigated, + minimal, +}: DetailPanelAlertsListItemDeps) => { + const styles = useStyles(minimal, isInvestigated); + + if (!event.kibana) { + return null; + } + + const timestamp = event['@timestamp']; + const { uuid, name } = event.kibana.alert.rule; + const { args } = event.process; + + const forceState = !isInvestigated ? 'open' : undefined; + + return minimal ? ( +
+ + + + + {timestamp} + + + + + + + + {args.join(' ')} + + +
+ ) : ( + +

+ + {name} +

+ + } + initialIsOpen={true} + forceState={forceState} + css={styles.alertItem} + extraAction={ + + } + > + + + {timestamp} + + + + {args.join(' ')} + + + {isInvestigated && ( +
+ + + +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts new file mode 100644 index 0000000000000..7672bb942ff32 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_list_item/styles.ts @@ -0,0 +1,112 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject, css } from '@emotion/react'; + +export const useStyles = (minimal = false, isInvestigated = false) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, font, size, border } = euiTheme; + + const dangerBorder = transparentize(colors.danger, 0.2); + const dangerBackground = transparentize(colors.danger, 0.08); + const borderThickness = border.width.thin; + const mediumPadding = size.m; + + let alertTitleColor = colors.text; + let borderColor = colors.lightShade; + + if (isInvestigated) { + alertTitleColor = colors.primaryText; + borderColor = dangerBorder; + } + + const alertItem = css` + border: ${borderThickness} solid ${borderColor}; + padding: ${mediumPadding}; + border-radius: ${border.radius.medium}; + + margin: 0 ${mediumPadding} ${mediumPadding} ${mediumPadding}; + background-color: ${colors.emptyShade}; + + & .euiAccordion__buttonContent { + width: 100%; + } + + & .euiAccordion__button { + min-width: 0; + width: calc(100% - ${size.l}); + } + + & .euiAccordion__childWrapper { + overflow: visible; + } + `; + + const alertTitle: CSSObject = { + display: minimal ? 'none' : 'initial', + color: alertTitleColor, + fontWeight: font.weight.semiBold, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }; + + const alertIcon: CSSObject = { + marginRight: size.s, + }; + + const alertAccordionButton: CSSObject = { + width: `calc(100% - ${size.l})`, + minWidth: 0, + }; + + const processPanel: CSSObject = { + border: `${borderThickness} solid ${colors.lightShade}`, + fontFamily: font.familyCode, + marginTop: minimal ? size.s : size.m, + padding: `${size.xs} ${size.s}`, + }; + + const investigatedLabel: CSSObject = { + position: 'relative', + zIndex: 1, + bottom: `-${mediumPadding}`, + left: `-${mediumPadding}`, + width: `calc(100% + ${mediumPadding} * 2)`, + borderTop: `${borderThickness} solid ${dangerBorder}`, + borderBottomLeftRadius: border.radius.medium, + borderBottomRightRadius: border.radius.medium, + backgroundColor: dangerBackground, + textAlign: 'center', + }; + + const minimalContextMenu: CSSObject = { + float: 'right', + }; + + const minimalHR: CSSObject = { + marginBottom: 0, + }; + + return { + alertItem, + alertTitle, + alertIcon, + alertAccordionButton, + processPanel, + investigatedLabel, + minimalContextMenu, + minimalHR, + }; + }, [euiTheme, isInvestigated, minimal]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx new file mode 100644 index 0000000000000..a915f8e285ad1 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.test.tsx @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { DetailPanelAlertTab } from './index'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { fireEvent } from '@testing-library/dom'; +import { + INVESTIGATED_ALERT_TEST_ID, + VIEW_MODE_TOGGLE, + ALERTS_TAB_EMPTY_STATE_TEST_ID, +} from './index'; +import { + ALERT_LIST_ITEM_TEST_ID, + ALERT_LIST_ITEM_ARGS_TEST_ID, + ALERT_LIST_ITEM_TIMESTAMP_TEST_ID, +} from '../detail_panel_alert_list_item/index'; +import { + ALERT_GROUP_ITEM_TEST_ID, + ALERT_GROUP_ITEM_COUNT_TEST_ID, + ALERT_GROUP_ITEM_TITLE_TEST_ID, +} from '../detail_panel_alert_group_item/index'; + +const ACCORDION_BUTTON_CLASS = '.euiAccordion__button'; +const VIEW_MODE_GROUP = 'groupView'; +const ARIA_EXPANDED_ATTR = 'aria-expanded'; + +describe('DetailPanelAlertTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); + }); + + describe('When DetailPanelAlertTab is mounted', () => { + it('renders a list of alerts for the session (defaulting to list view mode)', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeFalsy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('List view'); + }); + + it('renders a list of alerts grouped by rule when group-view clicked', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TEST_ID).length).toBe(mockAlerts.length); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TEST_ID)).toBeTruthy(); + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeFalsy(); + expect( + renderResult + .queryByTestId(VIEW_MODE_TOGGLE) + ?.querySelector('.euiButtonGroupButton-isSelected')?.textContent + ).toBe('Group view'); + }); + + it('renders a sticky investigated alert (outside of main list) if one is set', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(INVESTIGATED_ALERT_TEST_ID)).toBeTruthy(); + }); + + it('investigated alert should be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryByTestId(INVESTIGATED_ALERT_TEST_ID) + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + }); + + it('non investigated alert should NOT be collapsible', async () => { + renderResult = mockedContext.render( + + ); + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_LIST_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('grouped alerts should be expandable/collapsible (default to collapsed)', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('false'); + + const expandButton = renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS); + + if (expandButton) { + fireEvent.click(expandButton); + } + + expect( + renderResult + .queryAllByTestId(ALERT_GROUP_ITEM_TEST_ID)[0] + ?.querySelector(ACCORDION_BUTTON_CLASS) + ?.attributes.getNamedItem(ARIA_EXPANDED_ATTR)?.value + ).toBe('true'); + }); + + it('each alert list item should show a timestamp and process arguments', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_TIMESTAMP_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0]['@timestamp'] + ); + + expect(renderResult.queryAllByTestId(ALERT_LIST_ITEM_ARGS_TEST_ID)[0]).toHaveTextContent( + mockAlerts[0].process.args.join(' ') + ); + }); + + it('each alert group should show a rule title and alert count', async () => { + renderResult = mockedContext.render( + + ); + + fireEvent.click(renderResult.getByTestId(VIEW_MODE_GROUP)); + + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_COUNT_TEST_ID)).toHaveTextContent('2'); + expect(renderResult.queryByTestId(ALERT_GROUP_ITEM_TITLE_TEST_ID)).toHaveTextContent( + mockAlerts[0].kibana?.alert.rule.name || '' + ); + }); + + it('renders an empty state when there are no alerts', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(ALERTS_TAB_EMPTY_STATE_TEST_ID)).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx new file mode 100644 index 0000000000000..7fa47f4f5daf7 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/index.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useMemo } from 'react'; +import { EuiEmptyPrompt, EuiButtonGroup, EuiHorizontalRule } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { groupBy } from 'lodash'; +import { ProcessEvent, Process } from '../../../common/types/process_tree'; +import { useStyles } from './styles'; +import { DetailPanelAlertListItem } from '../detail_panel_alert_list_item'; +import { DetailPanelAlertGroupItem } from '../detail_panel_alert_group_item'; + +export const ALERTS_TAB_EMPTY_STATE_TEST_ID = 'sessionView:detailPanelAlertsEmptyState'; +export const INVESTIGATED_ALERT_TEST_ID = 'sessionView:detailPanelInvestigatedAlert'; +export const VIEW_MODE_TOGGLE = 'sessionView:detailPanelAlertsViewMode'; + +interface DetailPanelAlertTabDeps { + alerts: ProcessEvent[]; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; + investigatedAlert?: ProcessEvent; +} + +const VIEW_MODE_LIST = 'listView'; +const VIEW_MODE_GROUP = 'groupView'; + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelAlertTab = ({ + alerts, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, +}: DetailPanelAlertTabDeps) => { + const styles = useStyles(); + const [viewMode, setViewMode] = useState(VIEW_MODE_LIST); + const viewModes = [ + { + id: VIEW_MODE_LIST, + label: i18n.translate('xpack.sessionView.alertDetailsTab.listView', { + defaultMessage: 'List view', + }), + }, + { + id: VIEW_MODE_GROUP, + label: i18n.translate('xpack.sessionView.alertDetailsTab.groupView', { + defaultMessage: 'Group view', + }), + }, + ]; + + const filteredAlerts = useMemo(() => { + return alerts.filter((event) => { + const isInvestigatedAlert = + event.kibana?.alert.uuid === investigatedAlert?.kibana?.alert.uuid; + return !isInvestigatedAlert; + }); + }, [investigatedAlert, alerts]); + + const groupedAlerts = useMemo(() => { + return groupBy(filteredAlerts, (event) => event.kibana?.alert.rule.uuid); + }, [filteredAlerts]); + + if (alerts.length === 0) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + + return ( +
+ + {investigatedAlert && ( +
+ + +
+ )} + + {viewMode === VIEW_MODE_LIST + ? filteredAlerts.map((event) => { + const key = event.kibana?.alert.uuid; + + return ( + + ); + }) + : Object.keys(groupedAlerts).map((ruleId: string) => { + const alertsByRule = groupedAlerts[ruleId]; + + return ( + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts new file mode 100644 index 0000000000000..a906744cdafb2 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_alert_tab/styles.ts @@ -0,0 +1,43 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { colors, size } = euiTheme; + + const container: CSSObject = { + position: 'relative', + }; + + const stickyItem: CSSObject = { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: colors.emptyShade, + paddingTop: size.base, + }; + + const viewMode: CSSObject = { + margin: size.base, + marginBottom: 0, + }; + + return { + container, + stickyItem, + viewMode, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index fb00344d5e280..2b7f78e88fafb 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -21,12 +21,14 @@ import { processNewEvents, searchProcessTree, autoExpandProcessTree, + updateProcessMap, } from './helpers'; import { sortProcesses } from '../../../common/utils/sort_processes'; interface UseProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; searchQuery?: string; updatedAlertsStatus: AlertStatusEventEntityIdMap; } @@ -196,6 +198,7 @@ export class ProcessImpl implements Process { export const useProcessTree = ({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }: UseProcessTreeDeps) => { @@ -221,6 +224,7 @@ export const useProcessTree = ({ const [processMap, setProcessMap] = useState(initializedProcessMap); const [processedPages, setProcessedPages] = useState([]); + const [alertsProcessed, setAlertsProcessed] = useState(false); const [searchResults, setSearchResults] = useState([]); const [orphans, setOrphans] = useState([]); @@ -257,6 +261,15 @@ export const useProcessTree = ({ } }, [data, processMap, orphans, processedPages, sessionEntityId]); + useEffect(() => { + // currently we are loading a single page of alerts, with no pagination + // so we only need to add these alert events to processMap once. + if (!alertsProcessed) { + updateProcessMap(processMap, alerts); + setAlertsProcessed(true); + } + }, [processMap, alerts, alertsProcessed]); + useEffect(() => { setSearchResults(searchProcessTree(processMap, searchQuery)); autoExpandProcessTree(processMap); diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx index 9fa7900d04b0d..3c0b9c5d0d4d9 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mockData } from '../../../common/mocks/constants/session_view_process.mock'; +import { mockData, mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; import { Process } from '../../../common/types/process_tree'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { ProcessImpl } from './hooks'; @@ -21,6 +21,7 @@ describe('ProcessTree component', () => { const props: ProcessTreeDeps = { sessionEntityId: sessionLeader.process.entity_id, data: mockData, + alerts: mockAlerts, isFetching: false, fetchNextPage: jest.fn(), hasNextPage: false, @@ -28,7 +29,7 @@ describe('ProcessTree component', () => { hasPreviousPage: false, onProcessSelected: jest.fn(), updatedAlertsStatus: {}, - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 4b489797c7e26..1e10e58d1cca0 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -26,6 +26,7 @@ export interface ProcessTreeDeps { sessionEntityId: string; data: ProcessEventsPage[]; + alerts: ProcessEvent[]; jumpToEvent?: ProcessEvent; isFetching: boolean; @@ -44,8 +45,7 @@ export interface ProcessTreeDeps { // a map for alerts with updated status and process.entity_id updatedAlertsStatus: AlertStatusEventEntityIdMap; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; timeStampOn?: boolean; verboseModeOn?: boolean; } @@ -53,6 +53,7 @@ export interface ProcessTreeDeps { export const ProcessTree = ({ sessionEntityId, data, + alerts, jumpToEvent, isFetching, hasNextPage, @@ -64,8 +65,7 @@ export const ProcessTree = ({ onProcessSelected, setSearchResults, updatedAlertsStatus, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, timeStampOn, verboseModeOn, }: ProcessTreeDeps) => { @@ -76,6 +76,7 @@ export const ProcessTree = ({ const { sessionLeader, processMap, searchResults } = useProcessTree({ sessionEntityId, data, + alerts, searchQuery, updatedAlertsStatus, }); @@ -203,8 +204,7 @@ export const ProcessTree = ({ selectedProcessId={selectedProcess?.id} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} timeStampOn={timeStampOn} verboseModeOn={verboseModeOn} /> diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx index 2a56a0ae2be67..c1b0c807528ec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -26,7 +26,7 @@ describe('ProcessTreeAlerts component', () => { isSelected: false, onClick: jest.fn(), selectAlert: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { @@ -61,16 +61,16 @@ describe('ProcessTreeAlerts component', () => { expect(selectAlert).toHaveBeenCalledTimes(1); }); - it('should execute loadAlertDetails callback when clicking on expand button', async () => { - const loadAlertDetails = jest.fn(); + it('should execute onShowAlertDetails callback when clicking on expand button', async () => { + const onShowAlertDetails = jest.fn(); renderResult = mockedContext.render( - + ); const expandButton = renderResult.queryByTestId(EXPAND_BUTTON_TEST_ID); expect(expandButton).toBeTruthy(); expandButton?.click(); - expect(loadAlertDetails).toHaveBeenCalledTimes(1); + expect(onShowAlertDetails).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx index 5ec1c4a7693c3..30892d02c5428 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -17,8 +17,7 @@ export interface ProcessTreeAlertDeps { isSelected: boolean; onClick: (alert: ProcessEventAlert | null) => void; selectAlert: (alertUuid: string) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string, status?: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export const ProcessTreeAlert = ({ @@ -27,8 +26,7 @@ export const ProcessTreeAlert = ({ isSelected, onClick, selectAlert, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertDeps) => { const styles = useStyles({ isInvestigated, isSelected }); @@ -41,10 +39,10 @@ export const ProcessTreeAlert = ({ }, [isInvestigated, uuid, selectAlert]); const handleExpandClick = useCallback(() => { - if (loadAlertDetails && uuid) { - loadAlertDetails(uuid, () => handleOnAlertDetailsClosed(uuid)); + if (uuid) { + onShowAlertDetails(uuid); } - }, [handleOnAlertDetailsClosed, loadAlertDetails, uuid]); + }, [onShowAlertDetails, uuid]); const handleClick = useCallback(() => { if (alert.kibana?.alert) { diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx index 2333c71d36a51..ee6866f6a8a60 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -17,7 +17,7 @@ describe('ProcessTreeAlerts component', () => { const props: ProcessTreeAlertsDeps = { alerts: mockAlerts, onAlertSelected: jest.fn(), - handleOnAlertDetailsClosed: jest.fn(), + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx index c97ccfe253605..b51d58bf825ec 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -16,8 +16,7 @@ export interface ProcessTreeAlertsDeps { jumpToAlertID?: string; isProcessSelected?: boolean; onAlertSelected: (e: MouseEvent) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } export function ProcessTreeAlerts({ @@ -25,8 +24,7 @@ export function ProcessTreeAlerts({ jumpToAlertID, isProcessSelected = false, onAlertSelected, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessTreeAlertsDeps) { const [selectedAlert, setSelectedAlert] = useState(null); const styles = useStyles(); @@ -90,8 +88,7 @@ export function ProcessTreeAlerts({ isSelected={isProcessSelected && selectedAlert?.uuid === alertUuid} onClick={handleAlertClick} selectAlert={selectAlert} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 2e82e822f0c82..5c3b790ad0430 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -36,7 +36,7 @@ describe('ProcessTreeNode component', () => { }, } as unknown as RefObject, onChangeJumpToEventVisibility: jest.fn(), - handleOnAlertDetailsClosed: (_alertUuid: string) => {}, + onShowAlertDetails: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index b1c42dd95efb9..387e7a5074699 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -21,7 +21,8 @@ import React, { useMemo, RefObject, } from 'react'; -import { EuiButton, EuiIcon, formatDate } from '@elastic/eui'; +import { EuiButton, EuiIcon, EuiToolTip, formatDate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { Process } from '../../../common/types/process_tree'; import { useVisible } from '../../hooks/use_visible'; @@ -43,8 +44,7 @@ export interface ProcessDeps { verboseModeOn?: boolean; scrollerRef: RefObject; onChangeJumpToEventVisibility: (isVisible: boolean, isAbove: boolean) => void; - loadAlertDetails?: (alertUuid: string, handleOnAlertDetailsClosed: () => void) => void; - handleOnAlertDetailsClosed: (alertUuid: string) => void; + onShowAlertDetails: (alertUuid: string) => void; } /** @@ -62,8 +62,7 @@ export function ProcessTreeNode({ verboseModeOn = true, scrollerRef, onChangeJumpToEventVisibility, - loadAlertDetails, - handleOnAlertDetailsClosed, + onShowAlertDetails, }: ProcessDeps) { const textRef = useRef(null); @@ -144,6 +143,33 @@ export function ProcessTreeNode({ ); const processDetails = process.getDetails(); + const hasExec = process.hasExec(); + + const processIcon = useMemo(() => { + if (!process.parent) { + return 'unlink'; + } else if (hasExec) { + return 'console'; + } else { + return 'branch'; + } + }, [hasExec, process.parent]); + + const iconTooltip = useMemo(() => { + if (!process.parent) { + return i18n.translate('xpack.sessionView.processNode.tooltipOrphan', { + defaultMessage: 'Process missing parent (orphan)', + }); + } else if (hasExec) { + return i18n.translate('xpack.sessionView.processNode.tooltipExec', { + defaultMessage: "Process exec'd", + }); + } else { + return i18n.translate('xpack.sessionView.processNode.tooltipFork', { + defaultMessage: 'Process forked (no exec)', + }); + } + }, [hasExec, process.parent]); if (!processDetails?.process) { return null; @@ -169,11 +195,9 @@ export function ProcessTreeNode({ const showUserEscalation = user.id !== parent.user.id; const interactiveSession = !!tty; const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; - const hasExec = process.hasExec(); const iconTestSubj = hasExec ? 'sessionView:processTreeNodeExecIcon' : 'sessionView:processTreeNodeForkIcon'; - const processIcon = hasExec ? 'console' : 'branch'; const timeStampsNormal = formatDate(start, KIBANA_DATE_FORMAT); @@ -200,7 +224,9 @@ export function ProcessTreeNode({ ) : ( - + + + {' '} {workingDirectory}  {args[0]}  @@ -255,8 +281,7 @@ export function ProcessTreeNode({ jumpToAlertID={jumpToAlertID} isProcessSelected={selectedProcessId === process.id} onAlertSelected={onProcessClicked} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> )} @@ -276,8 +301,7 @@ export function ProcessTreeNode({ verboseModeOn={verboseModeOn} scrollerRef={scrollerRef} onChangeJumpToEventVisibility={onChangeJumpToEventVisibility} - loadAlertDetails={loadAlertDetails} - handleOnAlertDetailsClosed={handleOnAlertDetailsClosed} + onShowAlertDetails={onShowAlertDetails} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index a134a366c4168..bf8796336602d 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -15,9 +15,11 @@ import { ProcessEventResults, } from '../../../common/types/process_tree'; import { + ALERTS_ROUTE, PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, ALERT_STATUS_ROUTE, + QUERY_KEY_PROCESS_EVENTS, QUERY_KEY_ALERTS, } from '../../../common/constants'; @@ -28,9 +30,10 @@ export const useFetchSessionViewProcessEvents = ( const { http } = useKibana().services; const [isJumpToFirstPage, setIsJumpToFirstPage] = useState(false); const jumpToCursor = jumpToEvent && jumpToEvent.process.start; + const cachingKeys = [QUERY_KEY_PROCESS_EVENTS, sessionEntityId]; const query = useInfiniteQuery( - 'sessionViewProcessEvents', + cachingKeys, async ({ pageParam = {} }) => { let { cursor } = pageParam; const { forward } = pageParam; @@ -52,7 +55,7 @@ export const useFetchSessionViewProcessEvents = ( return { events, cursor }; }, { - getNextPageParam: (lastPage, pages) => { + getNextPageParam: (lastPage) => { if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], @@ -60,7 +63,7 @@ export const useFetchSessionViewProcessEvents = ( }; } }, - getPreviousPageParam: (firstPage, pages) => { + getPreviousPageParam: (firstPage) => { if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { cursor: firstPage.events[0]['@timestamp'], @@ -84,6 +87,32 @@ export const useFetchSessionViewProcessEvents = ( return query; }; +export const useFetchSessionViewAlerts = (sessionEntityId: string) => { + const { http } = useKibana().services; + const cachingKeys = [QUERY_KEY_ALERTS, sessionEntityId]; + const query = useQuery( + cachingKeys, + async () => { + const res = await http.get(ALERTS_ROUTE, { + query: { + sessionEntityId, + }, + }); + + const events = res.events.map((event: any) => event._source as ProcessEvent); + + return events; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + } + ); + + return query; +}; + export const useFetchAlertStatus = ( updatedAlertsStatus: AlertStatusEventEntityIdMap, alertUuid: string diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index af4eb6114a0a2..ee481c4204108 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -23,7 +23,11 @@ import { SessionViewDetailPanel } from '../session_view_detail_panel'; import { SessionViewSearchBar } from '../session_view_search_bar'; import { SessionViewDisplayOptions } from '../session_view_display_options'; import { useStyles } from './styles'; -import { useFetchAlertStatus, useFetchSessionViewProcessEvents } from './hooks'; +import { + useFetchAlertStatus, + useFetchSessionViewProcessEvents, + useFetchSessionViewAlerts, +} from './hooks'; /** * The main wrapper component for the session view. @@ -61,8 +65,12 @@ export const SessionView = ({ hasPreviousPage, } = useFetchSessionViewProcessEvents(sessionEntityId, jumpToEvent); - const hasData = data && data.pages.length > 0 && data.pages[0].events.length > 0; - const renderIsLoading = isFetching && !data; + const alertsQuery = useFetchSessionViewAlerts(sessionEntityId); + const { data: alerts, error: alertsError, isFetching: alertsFetching } = alertsQuery; + + const hasData = alerts && data && data.pages?.[0].events.length > 0; + const hasError = error || alertsError; + const renderIsLoading = (isFetching || alertsFetching) && !data; const renderDetails = isDetailOpen && selectedProcess; const { data: newUpdatedAlertsStatus } = useFetchAlertStatus( updatedAlertsStatus, @@ -83,6 +91,15 @@ export const SessionView = ({ setIsDetailOpen(!isDetailOpen); }, [isDetailOpen]); + const onShowAlertDetails = useCallback( + (alertUuid: string) => { + if (loadAlertDetails) { + loadAlertDetails(alertUuid, () => handleOnAlertDetailsClosed(alertUuid)); + } + }, + [loadAlertDetails, handleOnAlertDetailsClosed] + ); + const handleOptionChange = useCallback((checkedOptions: DisplayOptionsState) => { setDisplayOptions(checkedOptions); }, []); @@ -165,7 +182,7 @@ export const SessionView = ({ )} - {error && ( + {hasError && ( @@ -215,7 +232,7 @@ export const SessionView = ({ {renderDetails ? ( <> - + diff --git a/x-pack/plugins/session_view/public/components/session_view/styles.ts b/x-pack/plugins/session_view/public/components/session_view/styles.ts index d2c87130bfa4b..edfe2356d5aa2 100644 --- a/x-pack/plugins/session_view/public/components/session_view/styles.ts +++ b/x-pack/plugins/session_view/public/components/session_view/styles.ts @@ -17,6 +17,10 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { + const { border, colors } = euiTheme; + + const thinBorder = `${border.width.thin} solid ${colors.lightShade}!important`; + const processTree: CSSObject = { height: `${height}px`, position: 'relative', @@ -24,6 +28,12 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { const detailPanel: CSSObject = { height: `${height}px`, + borderLeft: thinBorder, + borderRight: thinBorder, + }; + + const resizeHandle: CSSObject = { + zIndex: 2, }; const searchBar: CSSObject = { @@ -38,6 +48,7 @@ export const useStyles = ({ height = 500 }: StylesDeps) => { return { processTree, detailPanel, + resizeHandle, searchBar, buttonsEyeDetail, }; diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx index f754086fe5fab..40e71efd8a6cf 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -6,7 +6,10 @@ */ import React from 'react'; -import { sessionViewBasicProcessMock } from '../../../common/mocks/constants/session_view_process.mock'; +import { + mockAlerts, + sessionViewBasicProcessMock, +} from '../../../common/mocks/constants/session_view_process.mock'; import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; import { SessionViewDetailPanel } from './index'; @@ -14,27 +17,66 @@ describe('SessionView component', () => { let render: () => ReturnType; let renderResult: ReturnType; let mockedContext: AppContextTestRender; + let mockOnProcessSelected = jest.fn((process) => process); + let mockShowAlertDetails = jest.fn((alertId) => alertId); beforeEach(() => { mockedContext = createAppRootMockRenderer(); + mockOnProcessSelected = jest.fn((process) => process); + mockShowAlertDetails = jest.fn((alertId) => alertId); }); describe('When SessionViewDetailPanel is mounted', () => { it('shows process detail by default', async () => { renderResult = mockedContext.render( - + ); expect(renderResult.queryByText('8e4daeb2-4a4e-56c4-980e-f0dcfdbc3726')).toBeVisible(); }); it('can switch tabs to show host details', async () => { renderResult = mockedContext.render( - + ); renderResult.queryByText('Host')?.click(); expect(renderResult.queryByText('hostname')).toBeVisible(); expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); }); + + it('can switch tabs to show alert details', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeVisible(); + }); + it('alert tab disabled when no alerts', async () => { + renderResult = mockedContext.render( + + ); + + renderResult.queryByText('Alerts')?.click(); + expect(renderResult.queryByText('List view')).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx index a47ce1d91ac97..51eb65a38f835 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -6,50 +6,91 @@ */ import React, { useState, useMemo, useCallback } from 'react'; import { EuiTabs, EuiTab, EuiNotificationBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { EuiTabProps } from '../../types'; -import { Process } from '../../../common/types/process_tree'; +import { Process, ProcessEvent } from '../../../common/types/process_tree'; import { getDetailPanelProcess, getSelectedTabContent } from './helpers'; import { DetailPanelProcessTab } from '../detail_panel_process_tab'; import { DetailPanelHostTab } from '../detail_panel_host_tab'; +import { DetailPanelAlertTab } from '../detail_panel_alert_tab'; +import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; interface SessionViewDetailPanelDeps { selectedProcess: Process; - onProcessSelected?: (process: Process) => void; + alerts?: ProcessEvent[]; + investigatedAlert?: ProcessEvent; + onProcessSelected: (process: Process) => void; + onShowAlertDetails: (alertId: string) => void; } /** * Detail panel in the session view. */ -export const SessionViewDetailPanel = ({ selectedProcess }: SessionViewDetailPanelDeps) => { +export const SessionViewDetailPanel = ({ + alerts, + selectedProcess, + investigatedAlert, + onProcessSelected, + onShowAlertDetails, +}: SessionViewDetailPanelDeps) => { const [selectedTabId, setSelectedTabId] = useState('process'); const processDetail = useMemo(() => getDetailPanelProcess(selectedProcess), [selectedProcess]); - const tabs: EuiTabProps[] = useMemo( - () => [ + const alertsCount = useMemo(() => { + if (!alerts) { + return 0; + } + + return alerts.length >= ALERT_COUNT_THRESHOLD ? ALERT_COUNT_THRESHOLD + '+' : alerts.length; + }, [alerts]); + + const tabs: EuiTabProps[] = useMemo(() => { + const hasAlerts = !!alerts?.length; + + return [ { id: 'process', - name: 'Process', + name: i18n.translate('xpack.sessionView.detailsPanel.process', { + defaultMessage: 'Process', + }), content: , }, { id: 'host', - name: 'Host', + name: i18n.translate('xpack.sessionView.detailsPanel.host', { + defaultMessage: 'Host', + }), content: , }, { id: 'alerts', - disabled: true, - name: 'Alerts', - append: ( + name: i18n.translate('xpack.sessionView.detailsPanel.alerts', { + defaultMessage: 'Alerts', + }), + append: hasAlerts && ( - 10 + {alertsCount} ), - content: null, + content: alerts && ( + + ), }, - ], - [processDetail, selectedProcess.events] - ); + ]; + }, [ + alerts, + alertsCount, + processDetail, + selectedProcess.events, + onProcessSelected, + onShowAlertDetails, + investigatedAlert, + ]); const onSelectedTabChanged = useCallback((id: string) => { setSelectedTabId(id); diff --git a/x-pack/plugins/session_view/server/plugin.ts b/x-pack/plugins/session_view/server/plugin.ts index c7fd511b3de05..7347f7676af62 100644 --- a/x-pack/plugins/session_view/server/plugin.ts +++ b/x-pack/plugins/session_view/server/plugin.ts @@ -11,12 +11,14 @@ import { Plugin, Logger, PluginInitializerContext, + IRouter, } from '../../../../src/core/server'; import { SessionViewSetupPlugins, SessionViewStartPlugins } from './types'; import { registerRoutes } from './routes'; export class SessionViewPlugin implements Plugin { private logger: Logger; + private router: IRouter | undefined; /** * Initialize SessionViewPlugin class properties (logger, etc) that is accessible @@ -28,14 +30,16 @@ export class SessionViewPlugin implements Plugin { public setup(core: CoreSetup, plugins: SessionViewSetupPlugins) { this.logger.debug('session view: Setup'); - const router = core.http.createRouter(); - - // Register server routes - registerRoutes(router); + this.router = core.http.createRouter(); } public start(core: CoreStart, plugins: SessionViewStartPlugins) { this.logger.debug('session view: Start'); + + // Register server routes + if (this.router) { + registerRoutes(this.router, plugins.ruleRegistry); + } } public stop() { diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.test.ts b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts new file mode 100644 index 0000000000000..4c8ee6fb2c83e --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + ALERT_RULE_CONSUMER, + ALERT_RULE_TYPE_ID, + SPACE_IDS, + ALERT_WORKFLOW_STATUS, +} from '@kbn/rule-data-utils'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import { doSearch } from './alerts_route'; +import { mockEvents } from '../../common/mocks/constants/session_view_process.mock'; + +import { + AlertsClient, + ConstructorOptions, +} from '../../../rule_registry/server/alert_data_client/alerts_client'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock'; +import { auditLoggerMock } from '../../../security/server/audit/mocks'; +import { AlertingAuthorizationEntity } from '../../../alerting/server'; +import { ruleDataServiceMock } from '../../../rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock'; + +const alertingAuthMock = alertingAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const DEFAULT_SPACE = 'test_default_space_id'; + +const getEmptyResponse = async () => { + return { + hits: { + total: 0, + hits: [], + }, + }; +}; + +const getResponse = async () => { + return { + hits: { + total: mockEvents.length, + hits: mockEvents.map((event) => { + return { + found: true, + _type: 'alert', + _index: '.alerts-security', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + [ALERT_RULE_TYPE_ID]: 'apm.error_rate', + message: 'hello world 1', + [ALERT_RULE_CONSUMER]: 'apm', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['test_default_space_id'], + ...event, + }, + }; + }), + }, + }; +}; + +const esClientMock = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + auditLogger, + ruleDataService: ruleDataServiceMock.create(), + esClient: esClientMock, +}; + +describe('alerts_route.ts', () => { + beforeEach(() => { + jest.resetAllMocks(); + + alertingAuthMock.getSpaceId.mockImplementation(() => DEFAULT_SPACE); + // @ts-expect-error + alertingAuthMock.getAuthorizationFilter.mockImplementation(async () => + Promise.resolve({ filter: [] }) + ); + // @ts-expect-error + alertingAuthMock.getAugmentedRuleTypesWithAuthorization.mockImplementation(async () => { + const authorizedRuleTypes = new Set(); + authorizedRuleTypes.add({ producer: 'apm' }); + return Promise.resolve({ authorizedRuleTypes }); + }); + + alertingAuthMock.ensureAuthorized.mockImplementation( + // @ts-expect-error + async ({ + ruleTypeId, + consumer, + operation, + entity, + }: { + ruleTypeId: string; + consumer: string; + operation: string; + entity: typeof AlertingAuthorizationEntity.Alert; + }) => { + if (ruleTypeId === 'apm.error_rate' && consumer === 'apm') { + return Promise.resolve(); + } + return Promise.reject(new Error(`Unauthorized for ${ruleTypeId} and ${consumer}`)); + } + ); + }); + + describe('doSearch(client, sessionEntityId)', () => { + it('should return an empty events array for a non existant entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getEmptyResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(0); + }); + + it('returns results for a particular session entity_id', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(getResponse()); + const alertsClient = new AlertsClient({ ...alertsClientParams, esClient }); + + const body = await doSearch(alertsClient, 'asdf'); + + expect(body.events.length).toBe(mockEvents.length); + }); + }); +}); diff --git a/x-pack/plugins/session_view/server/routes/alerts_route.ts b/x-pack/plugins/session_view/server/routes/alerts_route.ts new file mode 100644 index 0000000000000..3d03cb5cb8214 --- /dev/null +++ b/x-pack/plugins/session_view/server/routes/alerts_route.ts @@ -0,0 +1,66 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from '../../../../../src/core/server'; +import { + ALERTS_ROUTE, + ALERTS_PER_PAGE, + ENTRY_SESSION_ENTITY_ID_PROPERTY, +} from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; +import type { AlertsClient, RuleRegistryPluginStartContract } from '../../../rule_registry/server'; + +export const registerAlertsRoute = ( + router: IRouter, + ruleRegistry: RuleRegistryPluginStartContract +) => { + router.get( + { + path: ALERTS_ROUTE, + validate: { + query: schema.object({ + sessionEntityId: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = await ruleRegistry.getRacClientWithRequest(request); + const { sessionEntityId } = request.query; + const body = await doSearch(client, sessionEntityId); + + return response.ok({ body }); + } + ); +}; + +export const doSearch = async (client: AlertsClient, sessionEntityId: string) => { + const indices = await client.getAuthorizedAlertsIndices(['siem']); + + if (!indices) { + return { events: [] }; + } + + const results = await client.find({ + query: { + match: { + [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, + }, + }, + track_total_hits: false, + size: ALERTS_PER_PAGE, + index: indices.join(','), + }); + + const events = results.hits.hits.map((hit: any) => { + // the alert indexes flattens many properties. this util unflattens them as session view expects structured json. + hit._source = expandDottedObject(hit._source); + + return hit; + }); + + return { events }; +}; diff --git a/x-pack/plugins/session_view/server/routes/index.ts b/x-pack/plugins/session_view/server/routes/index.ts index b8cb80dc1d1d4..17efeb5d07a7b 100644 --- a/x-pack/plugins/session_view/server/routes/index.ts +++ b/x-pack/plugins/session_view/server/routes/index.ts @@ -6,11 +6,14 @@ */ import { IRouter } from '../../../../../src/core/server'; import { registerProcessEventsRoute } from './process_events_route'; +import { registerAlertsRoute } from './alerts_route'; import { registerAlertStatusRoute } from './alert_status_route'; import { sessionEntryLeadersRoute } from './session_entry_leaders_route'; +import { RuleRegistryPluginStartContract } from '../../../rule_registry/server'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: IRouter, ruleRegistry: RuleRegistryPluginStartContract) => { registerProcessEventsRoute(router); registerAlertStatusRoute(router); sessionEntryLeadersRoute(router); + registerAlertsRoute(router, ruleRegistry); }; diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 47e2d917733d5..7be1885c70ab1 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -11,10 +11,8 @@ import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE, PROCESS_EVENTS_INDEX, - ALERTS_INDEX, ENTRY_SESSION_ENTITY_ID_PROPERTY, } from '../../common/constants'; -import { expandDottedObject } from '../../common/utils/expand_dotted_object'; export const registerProcessEventsRoute = (router: IRouter) => { router.get( @@ -45,35 +43,25 @@ export const doSearch = async ( forward = true ) => { const search = await client.search({ - // TODO: move alerts into it's own route with it's own pagination. - index: [PROCESS_EVENTS_INDEX, ALERTS_INDEX], - ignore_unavailable: true, + index: [PROCESS_EVENTS_INDEX], body: { query: { match: { [ENTRY_SESSION_ENTITY_ID_PROPERTY]: sessionEntityId, }, }, - // This runtime_mappings is a temporary fix, so we are able to Query these ECS fields while they are not available - // TODO: Remove the runtime_mappings once process.entry_leader.entity_id is implemented to ECS - runtime_mappings: { - [ENTRY_SESSION_ENTITY_ID_PROPERTY]: { - type: 'keyword', - }, - }, size: PROCESS_EVENTS_PER_PAGE, - sort: [{ '@timestamp': forward ? 'asc' : 'desc' }], + // we first sort by process.start, this allows lifecycle events to load all at once for a given process, and + // avoid issues like where the session leaders 'end' event is loaded at the very end of what could be multiple pages of events + sort: [ + { 'process.start': forward ? 'asc' : 'desc' }, + { '@timestamp': forward ? 'asc' : 'desc' }, + ], search_after: cursor ? [cursor] : undefined, }, }); - const events = search.hits.hits.map((hit: any) => { - // TODO: re-eval if this is needed after moving alerts to it's own route. - // the .siem-signals-default index flattens many properties. this util unflattens them. - hit._source = expandDottedObject(hit._source); - - return hit; - }); + const events = search.hits.hits; if (!forward) { events.reverse(); diff --git a/x-pack/plugins/session_view/server/types.ts b/x-pack/plugins/session_view/server/types.ts index 0d1375081ca87..29995077ccfbe 100644 --- a/x-pack/plugins/session_view/server/types.ts +++ b/x-pack/plugins/session_view/server/types.ts @@ -4,8 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { + RuleRegistryPluginSetupContract as RuleRegistryPluginSetup, + RuleRegistryPluginStartContract as RuleRegistryPluginStart, +} from '../../rule_registry/server'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewSetupPlugins {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface SessionViewStartPlugins {} +export interface SessionViewSetupPlugins { + ruleRegistry: RuleRegistryPluginSetup; +} + +export interface SessionViewStartPlugins { + ruleRegistry: RuleRegistryPluginStart; +} diff --git a/x-pack/plugins/session_view/tsconfig.json b/x-pack/plugins/session_view/tsconfig.json index a99e83976a31d..0a21d320dfb29 100644 --- a/x-pack/plugins/session_view/tsconfig.json +++ b/x-pack/plugins/session_view/tsconfig.json @@ -37,6 +37,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../infra/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" } + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../rule_registry/tsconfig.json" } ] } From d253355234e2b1b393ec7e6dc10641edfe8f900c Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Wed, 23 Mar 2022 11:46:42 -0400 Subject: [PATCH 082/132] [SearchProfiler] Handle scenario when user has no indices (#128066) --- .../application/hooks/use_request_profile.ts | 31 ++++++++- .../apps/dev_tools/searchprofiler_editor.ts | 64 +++++++++++++++---- x-pack/test/functional/config.js | 8 +++ 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts index c27ca90e6e2f2..7f5d31b781310 100644 --- a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts +++ b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts @@ -21,6 +21,16 @@ interface ReturnValue { error?: string; } +interface ProfileResponse { + profile?: { shards: ShardSerialized[] }; + _shards: { + failed: number; + skipped: number; + total: number; + successful: number; + }; +} + const extractProfilerErrorMessage = (e: any): string | undefined => { if (e.body?.attributes?.error?.reason) { const { reason, line, col } = e.body.attributes.error; @@ -67,8 +77,7 @@ export const useRequestProfile = () => { try { const resp = await http.post< - | { ok: true; resp: { profile: { shards: ShardSerialized[] } } } - | { ok: false; err: { msg: string } } + { ok: true; resp: ProfileResponse } | { ok: false; err: { msg: string } } >('../api/searchprofiler/profile', { body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' }, @@ -78,7 +87,23 @@ export const useRequestProfile = () => { return { data: null, error: resp.err.msg }; } - return { data: resp.resp.profile.shards }; + // If a user attempts to run Search Profiler without any indices, + // _shards=0 and a "profile" output will not be returned + if (resp.resp._shards.total === 0) { + notifications.addDanger({ + 'data-test-subj': 'noShardsNotification', + title: i18n.translate('xpack.searchProfiler.errorNoShardsTitle', { + defaultMessage: 'Unable to profile', + }), + text: i18n.translate('xpack.searchProfiler.errorNoShardsDescription', { + defaultMessage: 'Verify your index input matches a valid index', + }), + }); + + return { data: null }; + } + + return { data: resp.resp.profile!.shards }; } catch (e) { const profilerErrorMessage = extractProfilerErrorMessage(e); if (profilerErrorMessage) { diff --git a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts index 3ab27e52477a6..9a2968a1fd8b5 100644 --- a/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts +++ b/x-pack/test/functional/apps/dev_tools/searchprofiler_editor.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { asyncForEach } from '@kbn/std'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { @@ -14,6 +15,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const aceEditor = getService('aceEditor'); const retry = getService('retry'); const security = getService('security'); + const es = getService('es'); + const log = getService('log'); const editorTestSubjectSelector = 'searchProfilerEditor'; @@ -34,23 +37,23 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const okInput = [ `{ -"query": { -"match_all": {}`, + "query": { + "match_all": {}`, `{ -"query": { -"match_all": { -"test": """{ "more": "json" }"""`, + "query": { + "match_all": { + "test": """{ "more": "json" }"""`, ]; const notOkInput = [ `{ -"query": { -"match_all": { -"test": """{ "more": "json" }""`, + "query": { + "match_all": { + "test": """{ "more": "json" }""`, `{ -"query": { -"match_all": { -"test": """{ "more": "json" }""'`, + "query": { + "match_all": { + "test": """{ "more": "json" }""'`, ]; const expectHasParseErrorsToBe = (expectation: boolean) => async (inputs: string[]) => { @@ -70,5 +73,44 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await expectHasParseErrorsToBe(false)(okInput); await expectHasParseErrorsToBe(true)(notOkInput); }); + + describe('No indices', () => { + before(async () => { + // Delete any existing indices that were not properly cleaned up + try { + const indices = await es.indices.get({ + index: '*', + }); + const indexNames = Object.keys(indices); + + if (indexNames.length > 0) { + await asyncForEach(indexNames, async (indexName) => { + await es.indices.delete({ index: indexName }); + }); + } + } catch (e) { + log.debug('[Setup error] Error deleting existing indices'); + throw e; + } + }); + + it('returns error if profile is executed with no valid indices', async () => { + const input = { + query: { + match_all: {}, + }, + }; + + await aceEditor.setValue(editorTestSubjectSelector, JSON.stringify(input)); + + await testSubjects.click('profileButton'); + + await retry.waitFor('notification renders', async () => { + const notification = await testSubjects.find('noShardsNotification'); + const notificationText = await notification.getVisibleText(); + return notificationText.includes('Unable to profile'); + }); + }); + }); }); } diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 28000c3d4bac8..b7774b463d058 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -425,6 +425,14 @@ export default async function ({ readConfigFile }) { }, global_devtools_read: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['read', 'all'], + }, + ], + }, kibana: [ { feature: { From 09f78b01b966854d63b5cd7c79e36c9f35bbd580 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 23 Mar 2022 09:33:28 -0700 Subject: [PATCH 083/132] skip suite failing es promotion (#128396) --- x-pack/test/functional/apps/lens/show_underlying_data.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/lens/show_underlying_data.ts b/x-pack/test/functional/apps/lens/show_underlying_data.ts index d6ae299baceaf..2444e8714e014 100644 --- a/x-pack/test/functional/apps/lens/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/show_underlying_data.ts @@ -16,7 +16,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const browser = getService('browser'); - describe('show underlying data', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/128396 + describe.skip('show underlying data', () => { it('should show the open button for a compatible saved visualization', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('lnsXYvis'); From 2f06801f8ee18b2cdd7ce2280530fe8be479eb6c Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Mar 2022 12:58:41 -0400 Subject: [PATCH 084/132] [Fleet] Fix refresh assets tab on package install (#128285) --- .../integrations/sections/epm/screens/detail/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx index d002a743e77bc..dbd1c71da3d1b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/index.tsx @@ -144,9 +144,13 @@ export function Detail() { // Refresh package info when status change const [oldPackageInstallStatus, setOldPackageStatus] = useState(packageInstallStatus); + useEffect(() => { + if (packageInstallStatus === 'not_installed') { + setOldPackageStatus(packageInstallStatus); + } if (oldPackageInstallStatus === 'not_installed' && packageInstallStatus === 'installed') { - setOldPackageStatus(oldPackageInstallStatus); + setOldPackageStatus(packageInstallStatus); refreshPackageInfo(); } }, [packageInstallStatus, oldPackageInstallStatus, refreshPackageInfo]); From 42e6cee204043b97eda251b5fefffdaf4008ce43 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Wed, 23 Mar 2022 19:00:00 +0100 Subject: [PATCH 085/132] [Cases] Select case modal hook hides closed and all dropdown filters by default (#128380) --- .../use_cases_add_to_existing_case_modal.test.tsx | 4 ++++ .../selector_modal/use_cases_add_to_existing_case_modal.tsx | 2 ++ 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index df40ccd3b1e90..b0e316e891744 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -9,6 +9,7 @@ import { renderHook } from '@testing-library/react-hooks'; import React from 'react'; +import { CaseStatuses, StatusAll } from '../../../../common'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal'; @@ -62,6 +63,9 @@ describe('use cases add to existing case modal hook', () => { expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, + payload: expect.objectContaining({ + hiddenStatuses: [CaseStatuses.closed, StatusAll], + }), }) ); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx index 5341f5be4183d..1e65fee4565b2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.tsx @@ -6,6 +6,7 @@ */ import { useCallback } from 'react'; +import { CaseStatuses, StatusAll } from '../../../../common'; import { AllCasesSelectorModalProps } from '.'; import { useCasesToast } from '../../../common/use_cases_toast'; import { Case } from '../../../containers/types'; @@ -44,6 +45,7 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL, payload: { ...props, + hiddenStatuses: [CaseStatuses.closed, StatusAll], onRowClick: (theCase?: Case) => { // when the case is undefined in the modal // the user clicked "create new case" From f49f58614f3e6fe2310f61d19a6571b49f4053a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Wed, 23 Mar 2022 19:34:03 +0100 Subject: [PATCH 086/132] [App Search] Fix sorting options for elasticsearch index based engines (#128384) * Fix sorting options for elasticsearch index based engines * review changes and missing translation changes --- .../build_search_ui_config.ts | 12 +++--- .../search_experience/search_experience.tsx | 40 +++++++++++++++---- .../app_search/components/engine/types.ts | 1 + .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts index 25342f24cc872..9c06527162b81 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/build_search_ui_config.ts @@ -9,7 +9,12 @@ import { Schema } from '../../../../shared/schema/types'; import { Fields } from './types'; -export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields: Fields) => { +export const buildSearchUIConfig = ( + apiConnector: object, + schema: Schema, + fields: Fields, + initialState = { sortDirection: 'desc', sortField: 'id' } +) => { const facets = fields.filterFields.reduce( (facetsConfig, fieldName) => ({ ...facetsConfig, @@ -22,10 +27,7 @@ export const buildSearchUIConfig = (apiConnector: object, schema: Schema, fields alwaysSearchOnInitialLoad: true, apiConnector, trackUrlState: false, - initialState: { - sortDirection: 'desc', - sortField: 'id', - }, + initialState, searchQuery: { disjunctiveFacets: fields.filterFields, facets, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx index ed2a1ed54f06d..52e0acbc81520 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.tsx @@ -31,25 +31,39 @@ import { SearchExperienceContent } from './search_experience_content'; import { Fields, SortOption } from './types'; import { SearchBoxView, SortingView, MultiCheckboxFacetsView } from './views'; -const RECENTLY_UPLOADED = i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded', +const DOCUMENT_ID = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.documentId', { - defaultMessage: 'Recently Uploaded', + defaultMessage: 'Document ID', } ); + +const RELEVANCE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.relevance', + { defaultMessage: 'Relevance' } +); + const DEFAULT_SORT_OPTIONS: SortOption[] = [ { - name: DESCENDING(RECENTLY_UPLOADED), + name: DESCENDING(DOCUMENT_ID), value: 'id', direction: 'desc', }, { - name: ASCENDING(RECENTLY_UPLOADED), + name: ASCENDING(DOCUMENT_ID), value: 'id', direction: 'asc', }, ]; +const RELEVANCE_SORT_OPTIONS: SortOption[] = [ + { + name: RELEVANCE, + value: '_score', + direction: 'desc', + }, +]; + export const SearchExperience: React.FC = () => { const { engine } = useValues(EngineLogic); const { http } = useValues(HttpLogic); @@ -66,8 +80,10 @@ export const SearchExperience: React.FC = () => { sortFields: [], } ); + const sortOptions = + engine.type === 'elasticsearch' ? RELEVANCE_SORT_OPTIONS : DEFAULT_SORT_OPTIONS; - const sortingOptions = buildSortOptions(fields, DEFAULT_SORT_OPTIONS); + const sortingOptions = buildSortOptions(fields, sortOptions); const connector = new AppSearchAPIConnector({ cacheResponses: false, @@ -78,7 +94,17 @@ export const SearchExperience: React.FC = () => { }, }); - const searchProviderConfig = buildSearchUIConfig(connector, engine.schema || {}, fields); + const initialState = { + sortField: engine.type === 'elasticsearch' ? '_score' : 'id', + sortDirection: 'desc', + }; + + const searchProviderConfig = buildSearchUIConfig( + connector, + engine.schema || {}, + fields, + initialState + ); return (
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts index 6faa749f95864..acdeed4854ecd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/types.ts @@ -12,6 +12,7 @@ export enum EngineTypes { default = 'default', indexed = 'indexed', meta = 'meta', + elasticsearch = 'elasticsearch', } export interface Engine { name: string; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8914efcf12ded..db10095ce0591 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -8729,7 +8729,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "Trier les résultats par", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName} (croiss.)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName} (décroiss.)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "Récemment chargé", "xpack.enterpriseSearch.appSearch.documents.title": "Documents", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "Les éditeurs peuvent gérer les paramètres de recherche.", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "Créer un moteur", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 48f0d74d73765..f1ab772dbb243 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10279,7 +10279,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "結果の並べ替え条件", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName}(昇順)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName}(降順)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "最近アップロードされたドキュメント", "xpack.enterpriseSearch.appSearch.documents.title": "ドキュメント", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "エディターは検索設定を管理できます。", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "エンジンを作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bbc00d8d205f7..51c4915baab29 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10300,7 +10300,6 @@ "xpack.enterpriseSearch.appSearch.documents.search.sortBy.ariaLabel": "结果排序方式", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.ascendingDropDownOptionLabel": "{fieldName}(升序)", "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.descendingDropDownOptionLabel": "{fieldName}(降序)", - "xpack.enterpriseSearch.appSearch.documents.search.sortBy.option.recentlyUploaded": "最近上传", "xpack.enterpriseSearch.appSearch.documents.title": "文档", "xpack.enterpriseSearch.appSearch.editorRoleTypeDescription": "编辑人员可以管理搜索设置。", "xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta": "创建引擎", From 98300c236404d0378caf26de08b0866877f997b2 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Wed, 23 Mar 2022 19:39:38 +0100 Subject: [PATCH 087/132] [Security Solution][Endpoint] Accept all kinds of filenames (without wildcard) in wildcard-ed event filter `file.path.text` (#127432) * update filename regex to include multiple hyphens and periods Uses a much simpler pattern that covers a whole gamut file name patterns. fixes elastic/security-team/issues/3294 * remove duplicated code * add tests for `process.name` entry for filenames with wildcard path refs elastic/kibana/pull/120349 elastic/kibana/pull/125202 * Add file.name optimized entry when wildcard filepath in file.path.text has a filename fixes elastic/security-team/issues/3294 * update regex to include unicode chars review changes * add tests for `file.name` and `process.name` entries if it already exists This works out of the box and we don't add endpoint related `file.name` or `process.name` entry when it already is added by the user refs elastic/kibana/pull/127958#discussion_r829086447 elastic/security-team/issues/3199 * fix `file.name` and `file.path.text` entries for linux and mac/linux refs elastic/kibana/pull/127098 * do not add endpoint optimized entry Add `file.name` and `process.name` entry for wildcard path values only when file.name and process.name entries do not already exist. The earlier commit 8a516ae9c0580eb44b57666e7a5934c543c3e4bb was mistakenly labeled as this worked out of the box. In the same commit we notice that the test data had a wildcard file path that did not add a `file.name` or `process.name` entry. For more see: elastic/kibana/pull/127958#discussion_r829086447 elastic/security-team/issues/3199 * update regex to include gamut of unicode characters review suggestions * remove regex altogether simplifies the logic to check if path is without wildcard characters. This way it includes all other strings as valid filenames that do not have * or ? * update artifact creation for `file.path.text` entries Similar to when we normalize `file.path.caseless` entries, except that the `type` is `*_cased` for linux and `*_caseless` for non-linux --- .../src/path_validations/index.test.ts | 89 ++- .../src/path_validations/index.ts | 25 +- .../endpoint/lib/artifacts/lists.test.ts | 616 ++++++++++++++++++ .../server/endpoint/lib/artifacts/lists.ts | 50 +- .../manifest_manager/manifest_manager.ts | 109 ++-- 5 files changed, 790 insertions(+), 99 deletions(-) diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index ee2d8764a30af..5bb84816b1602 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -20,10 +20,31 @@ describe('validateFilePathInput', () => { describe('windows', () => { const os = OperatingSystem.WINDOWS; + it('does not warn on valid filenames', () => { + expect( + validateFilePathInput({ + os, + value: 'C:\\Windows\\*\\FILENAME.EXE-1231205124.gz', + }) + ).not.toBeDefined(); + expect( + validateFilePathInput({ + os, + value: "C:\\Windows\\*\\test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).toEqual(undefined); + }); + it('warns on wildcard in file name at the end of the path', () => { expect(validateFilePathInput({ os, value: 'c:\\path*.exe' })).toEqual( FILENAME_WILDCARD_WARNING ); + expect( + validateFilePathInput({ + os, + value: 'C:\\Windows\\*\\FILENAME.EXE-*.gz', + }) + ).toEqual(FILENAME_WILDCARD_WARNING); }); it('warns on unix paths or non-windows paths', () => { @@ -34,6 +55,7 @@ describe('validateFilePathInput', () => { expect(validateFilePathInput({ os, value: 'c:\\path/opt' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'c:\\folder\\' })).toEqual(FILEPATH_WARNING); }); }); describe('unix paths', () => { @@ -42,8 +64,22 @@ describe('validateFilePathInput', () => { ? OperatingSystem.MAC : OperatingSystem.LINUX; + it('does not warn on valid filenames', () => { + expect(validateFilePathInput({ os, value: '/opt/*/FILENAME.EXE-1231205124.gz' })).not.toEqual( + FILENAME_WILDCARD_WARNING + ); + expect( + validateFilePathInput({ + os, + value: "/opt/*/test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).not.toEqual(FILENAME_WILDCARD_WARNING); + }); it('warns on wildcard in file name at the end of the path', () => { expect(validateFilePathInput({ os, value: '/opt/bin*' })).toEqual(FILENAME_WILDCARD_WARNING); + expect(validateFilePathInput({ os, value: '/opt/FILENAME.EXE-*.gz' })).toEqual( + FILENAME_WILDCARD_WARNING + ); }); it('warns on windows paths', () => { @@ -54,6 +90,7 @@ describe('validateFilePathInput', () => { expect(validateFilePathInput({ os, value: 'opt/bin\\file.exe' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '/folder/' })).toEqual(FILEPATH_WARNING); }); }); }); @@ -577,50 +614,82 @@ describe('Unacceptable Mac/Linux exact paths', () => { }); }); -describe('Executable filenames with wildcard PATHS', () => { +describe('hasSimpleExecutableName', () => { it('should return TRUE when MAC/LINUX wildcard paths have an executable name', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + expect( hasSimpleExecutableName({ - os: OperatingSystem.LINUX, + os, type: 'wildcard', value: '/opt/*/app', }) ).toEqual(true); expect( hasSimpleExecutableName({ - os: OperatingSystem.MAC, + os, type: 'wildcard', value: '/op*/**/app.dmg', }) ).toEqual(true); - }); - - it('should return TRUE when WINDOWS wildcards paths have a executable name', () => { expect( hasSimpleExecutableName({ - os: OperatingSystem.WINDOWS, + os, type: 'wildcard', - value: 'c:\\**\\path.exe', + value: "/sy*/test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", }) ).toEqual(true); }); it('should return FALSE when MAC/LINUX wildcard paths have a wildcard in executable name', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + expect( hasSimpleExecutableName({ - os: OperatingSystem.LINUX, + os, type: 'wildcard', value: '/op/*/*pp', }) ).toEqual(false); expect( hasSimpleExecutableName({ - os: OperatingSystem.MAC, + os, type: 'wildcard', value: '/op*/b**/ap.m**', }) ).toEqual(false); }); + + it('should return TRUE when WINDOWS wildcards paths have a executable name', () => { + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: 'c:\\**\\path.exe', + }) + ).toEqual(true); + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: 'C:\\*\\file-name.path华语 1234.txt', + }) + ).toEqual(true); + expect( + hasSimpleExecutableName({ + os: OperatingSystem.WINDOWS, + type: 'wildcard', + value: "C:\\*\\test$ as2@13---12!@#A,DS.#$^&$!#~ 'as'd.华语.txt", + }) + ).toEqual(true); + }); + it('should return FALSE when WINDOWS wildcards paths have a wildcard in executable name', () => { expect( hasSimpleExecutableName({ diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts index 97a726703feef..b64cb4cf6a052 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -31,20 +31,6 @@ export const enum OperatingSystem { export type EntryTypes = 'match' | 'wildcard' | 'match_any'; export type TrustedAppEntryTypes = Extract; -/* - * regex to match executable names - * starts matching from the eol of the path - * file names with a single or multiple spaces (for spaced names) - * and hyphens and combinations of these that produce complex names - * such as: - * c:\home\lib\dmp.dmp - * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp - * /home/lib/dmp.dmp - * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp - */ -export const WIN_EXEC_PATH = /(\\[-\w]+|\\[-\w]+[\.]+[\w]+)$/i; -export const UNIX_EXEC_PATH = /(\/[-\w]+|\/[-\w]+[\.]+[\w]+)$/i; - export const validateFilePathInput = ({ os, value = '', @@ -70,7 +56,7 @@ export const validateFilePathInput = ({ } if (isValidFilePath) { - if (!hasSimpleFileName) { + if (hasSimpleFileName !== undefined && !hasSimpleFileName) { return FILENAME_WILDCARD_WARNING; } } else { @@ -86,9 +72,14 @@ export const hasSimpleExecutableName = ({ os: OperatingSystem; type: EntryTypes; value: string; -}): boolean => { +}): boolean | undefined => { + const separator = os === OperatingSystem.WINDOWS ? '\\' : '/'; + const lastString = value.split(separator).pop(); + if (!lastString) { + return; + } if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); + return (lastString.split('*').length || lastString.split('?').length) === 1; } return true; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index 83dbcf1ca6f6d..179ea3827df0c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -513,6 +513,622 @@ describe('artifacts lists', () => { }); }); + describe('Endpoint Artifacts', () => { + const getOsFilter = (os: 'macos' | 'linux' | 'windows') => + `exception-list-agnostic.attributes.os_types:"${os} "`; + + describe('linux', () => { + test('it should add process.name entry when wildcard process.executable entry has filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/doc.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/doc.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should add file.name entry when wildcard file.path.text entry has filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/doc.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/doc.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when wildcard process.executable entry has wildcard filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bin/*.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bin/*.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when process.name entry already exists', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value: '/usr/bi*/donotadd.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'match_any', + value: ['one.exe', 'two.exe'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bi*/donotadd.md', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_cased_any', + value: ['one.exe', 'two.exe'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when wildcard file.path.text entry has wildcard filename', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/bin/*.md', + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/bin/*.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when file.name entry already exists', async () => { + const os = 'linux'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: '/usr/b*/donotadd.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'match', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'match_any', + value: ['one.app', 'two.app'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_cased', + value: '/usr/b*/donotadd.md', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_cased_any', + value: ['one.app', 'two.app'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + }); + + describe('macos/windows', () => { + test('it should add process.name entry for process.executable entry with wildcard type', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\doc.md' : '/usr/bi*/doc.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should add file.name entry when wildcard file.path.text entry has filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\doc.md' : '/usr/bi*/doc.md'; + + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless', + value: 'doc.md', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when wildcard process.executable entry has wildcard filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\*.md' : '/usr/bin/*.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add process.name entry when process.name entry already exists', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\donotadd.md' : '/usr/bin/donotadd.md'; + + const testEntries: EntriesArray = [ + { + field: 'process.executable.caseless', + operator: 'included', + type: 'wildcard', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'match', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'match_any', + value: ['one.exe', 'two.exe'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'process.executable', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless', + value: 'appname.exe', + }, + { + field: 'process.name', + operator: 'included', + type: 'exact_caseless_any', + value: ['one.exe', 'two.exe'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when wildcard file.path.text entry has wildcard filename', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\*.md' : '/usr/bin/*.md'; + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not add file.name entry when file.name entry already exists', async () => { + const os = Math.floor(Math.random() * 2) === 0 ? 'windows' : 'macos'; + const value = os === 'windows' ? 'C:\\My Doc*\\donotadd.md' : '/usr/bin/donotadd.md'; + + const testEntries: EntriesArray = [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'match', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'match_any', + value: ['one.app', 'two.app'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'wildcard_caseless', + value, + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless', + value: 'filename.app', + }, + { + field: 'file.name', + operator: 'included', + type: 'exact_caseless_any', + value: ['one.app', 'two.app'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + first.data[0].os_types = [os]; + + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFilteredEndpointExceptionList({ + elClient: mockExceptionClient, + schemaVersion: 'v1', + filter: `${getOsFilter(os)} and (exception-list-agnostic.attributes.tags:"policy:all")`, + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + }); + + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + }); + }); + const TEST_EXCEPTION_LIST_ITEM = { entries: [ { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 7521ccbf9df91..2ea52485e625b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -187,11 +187,16 @@ function getMatcherFunction({ matchAny?: boolean; os: ExceptionListItemSchema['os_types'][number]; }): TranslatedEntryMatcher { + const doesFieldEndWith: boolean = + field.endsWith('.caseless') || field.endsWith('.name') || field.endsWith('.text'); + return matchAny - ? field.endsWith('.caseless') && os !== 'linux' - ? 'exact_caseless_any' + ? doesFieldEndWith + ? os === 'linux' + ? 'exact_cased_any' + : 'exact_caseless_any' : 'exact_cased_any' - : field.endsWith('.caseless') + : doesFieldEndWith ? os === 'linux' ? 'exact_cased' : 'exact_caseless' @@ -213,7 +218,9 @@ function getMatcherWildcardFunction({ } function normalizeFieldName(field: string): string { - return field.endsWith('.caseless') ? field.substring(0, field.lastIndexOf('.')) : field; + return field.endsWith('.caseless') || field.endsWith('.text') + ? field.substring(0, field.lastIndexOf('.')) + : field; } function translateItem( @@ -223,7 +230,7 @@ function translateItem( const itemSet = new Set(); const getEntries = (): TranslatedExceptionListItem['entries'] => { return item.entries.reduce((translatedEntries, entry) => { - const translatedEntry = translateEntry(schemaVersion, entry, item.os_types[0]); + const translatedEntry = translateEntry(schemaVersion, item.entries, entry, item.os_types[0]); if (translatedEntry !== undefined) { if (translatedEntryType.is(translatedEntry)) { @@ -256,12 +263,11 @@ function translateItem( }; } -function appendProcessNameEntry({ - wildcardProcessEntry, +function appendOptimizedEntryForEndpoint({ entry, os, + wildcardProcessEntry, }: { - wildcardProcessEntry: TranslatedEntryMatchWildcard; entry: { field: string; operator: 'excluded' | 'included'; @@ -269,11 +275,15 @@ function appendProcessNameEntry({ value: string; }; os: ExceptionListItemSchema['os_types'][number]; + wildcardProcessEntry: TranslatedEntryMatchWildcard; }): TranslatedPerformantEntries { const entries: TranslatedPerformantEntries = [ wildcardProcessEntry, { - field: normalizeFieldName('process.name'), + field: + entry.field === 'file.path.text' + ? normalizeFieldName('file.name') + : normalizeFieldName('process.name'), operator: entry.operator, type: (os === 'linux' ? 'exact_cased' : 'exact_caseless') as Extract< TranslatedEntryMatcher, @@ -291,6 +301,7 @@ function appendProcessNameEntry({ function translateEntry( schemaVersion: string, + exceptionListItemEntries: ExceptionListItemSchema['entries'], entry: Entry | EntryNested, os: ExceptionListItemSchema['os_types'][number] ): TranslatedEntry | TranslatedPerformantEntries | undefined { @@ -298,7 +309,12 @@ function translateEntry( case 'nested': { const nestedEntries = entry.entries.reduce( (entries, nestedEntry) => { - const translatedEntry = translateEntry(schemaVersion, nestedEntry, os); + const translatedEntry = translateEntry( + schemaVersion, + exceptionListItemEntries, + nestedEntry, + os + ); if (nestedEntry !== undefined && translatedEntryNestedEntry.is(translatedEntry)) { entries.push(translatedEntry); } @@ -354,11 +370,21 @@ function translateEntry( type: entry.type, value: entry.value, }); - if (hasExecutableName) { + + const existingFields = exceptionListItemEntries.map((e) => e.field); + const doAddPerformantEntries = !( + existingFields.includes('process.name') || existingFields.includes('file.name') + ); + + if (hasExecutableName && doAddPerformantEntries) { // when path has a full executable name // append a process.name entry based on os // `exact_cased` for linux and `exact_caseless` for others - return appendProcessNameEntry({ entry, os, wildcardProcessEntry }); + return appendOptimizedEntryForEndpoint({ + entry, + os, + wildcardProcessEntry, + }); } else { return wildcardProcessEntry; } diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 7be2a36396a71..a8c63bbb88e13 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -31,6 +31,7 @@ import { getArtifactId, getEndpointExceptionList, Manifest, + ArtifactListId, } from '../../../lib/artifacts'; import { InternalArtifactCompleteSchema, @@ -48,6 +49,11 @@ interface ArtifactsBuildResult { policySpecificArtifacts: Record; } +interface BuildArtifactsForOsOptions { + listId: ArtifactListId; + name: string; +} + const iterateArtifactsBuildResult = async ( result: ArtifactsBuildResult, callback: (artifact: InternalArtifactCompleteSchema, policyId?: string) => Promise @@ -174,20 +180,29 @@ export class ManifestManager { /** * Builds an artifact (one per supported OS) based on the current state of the - * Trusted Apps list (which uses the `exception-list-agnostic` SO type) + * artifacts list (Trusted Apps, Host Iso. Exceptions, Event Filters, Blocklists) + * (which uses the `exception-list-agnostic` SO type) */ - protected async buildTrustedAppsArtifact(os: string, policyId?: string) { + protected async buildArtifactsForOs({ + listId, + name, + os, + policyId, + }: { + os: string; + policyId?: string; + } & BuildArtifactsForOsOptions): Promise { return buildArtifact( await getEndpointExceptionList({ elClient: this.exceptionListClient, schemaVersion: this.schemaVersion, os, policyId, - listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + listId, }), this.schemaVersion, os, - ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME + name ); } @@ -198,9 +213,13 @@ export class ManifestManager { protected async buildTrustedAppsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_TRUSTED_APPS_LIST_ID, + name: ArtifactConstants.GLOBAL_TRUSTED_APPS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildTrustedAppsArtifact(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -208,7 +227,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_TRUSTED_APPS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildTrustedAppsArtifact(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -224,9 +245,13 @@ export class ManifestManager { protected async buildEventFiltersArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_EVENT_FILTERS_LIST_ID, + name: ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildEventFiltersForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -234,7 +259,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildEventFiltersForOs(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -242,21 +269,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildEventFiltersForOs(os: string, policyId?: string) { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_EVENT_FILTERS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_EVENT_FILTERS_NAME - ); - } - /** * Builds an array of Blocklist entries (one per supported OS) based on the current state of the * Blocklist list @@ -265,9 +277,13 @@ export class ManifestManager { protected async buildBlocklistArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_BLOCKLISTS_LIST_ID, + name: ArtifactConstants.GLOBAL_BLOCKLISTS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildBlocklistForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -275,7 +291,9 @@ export class ManifestManager { async (policyId) => { for (const os of ArtifactConstants.SUPPORTED_EVENT_FILTERS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; - policySpecificArtifacts[policyId].push(await this.buildBlocklistForOs(os, policyId)); + policySpecificArtifacts[policyId].push( + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) + ); } } ); @@ -283,21 +301,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildBlocklistForOs(os: string, policyId?: string) { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_BLOCKLISTS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_BLOCKLISTS_NAME - ); - } - /** * Builds an array of endpoint host isolation exception (one per supported OS) based on the current state of the * Host Isolation Exception List @@ -307,9 +310,13 @@ export class ManifestManager { protected async buildHostIsolationExceptionsArtifacts(): Promise { const defaultArtifacts: InternalArtifactCompleteSchema[] = []; const policySpecificArtifacts: Record = {}; + const buildArtifactsForOsOptions: BuildArtifactsForOsOptions = { + listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + name: ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME, + }; for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { - defaultArtifacts.push(await this.buildHostIsolationExceptionForOs(os)); + defaultArtifacts.push(await this.buildArtifactsForOs({ os, ...buildArtifactsForOsOptions })); } await iterateAllListItems( @@ -318,7 +325,7 @@ export class ManifestManager { for (const os of ArtifactConstants.SUPPORTED_HOST_ISOLATION_EXCEPTIONS_OPERATING_SYSTEMS) { policySpecificArtifacts[policyId] = policySpecificArtifacts[policyId] || []; policySpecificArtifacts[policyId].push( - await this.buildHostIsolationExceptionForOs(os, policyId) + await this.buildArtifactsForOs({ os, policyId, ...buildArtifactsForOsOptions }) ); } } @@ -327,24 +334,6 @@ export class ManifestManager { return { defaultArtifacts, policySpecificArtifacts }; } - protected async buildHostIsolationExceptionForOs( - os: string, - policyId?: string - ): Promise { - return buildArtifact( - await getEndpointExceptionList({ - elClient: this.exceptionListClient, - schemaVersion: this.schemaVersion, - os, - policyId, - listId: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, - }), - this.schemaVersion, - os, - ArtifactConstants.GLOBAL_HOST_ISOLATION_EXCEPTIONS_NAME - ); - } - /** * Writes new artifact SO. * From 5e73ef53277aae2da5e94b10d1fe6138a4721db1 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 23 Mar 2022 12:41:27 -0600 Subject: [PATCH 088/132] [Security Solution] Collapse KPI and Table queries on Explore pages (#127930) --- .../__snapshots__/index.test.tsx.snap | 28 +- .../components/header_section/index.test.tsx | 90 +++++ .../components/header_section/index.tsx | 133 ++++--- .../matrix_histogram/index.test.tsx | 89 ++++- .../components/matrix_histogram/index.tsx | 45 ++- .../matrix_histogram/matrix_loader.tsx | 2 +- .../ml/anomaly/use_anomalies_table_data.ts | 4 +- .../ml/tables/anomalies_host_table.test.tsx | 88 +++++ .../ml/tables/anomalies_host_table.tsx | 40 +- .../tables/anomalies_network_table.test.tsx | 90 +++++ .../ml/tables/anomalies_network_table.tsx | 41 +- .../ml/tables/anomalies_user_table.test.tsx | 89 +++++ .../ml/tables/anomalies_user_table.tsx | 39 +- .../__snapshots__/index.test.tsx.snap | 3 + .../components/paginated_table/index.test.tsx | 365 ++++-------------- .../components/paginated_table/index.tsx | 113 +++--- .../components/stat_items/index.test.tsx | 198 ++++++---- .../common/components/stat_items/index.tsx | 262 +++++++------ .../containers/matrix_histogram/index.test.ts | 13 +- .../containers/matrix_histogram/index.ts | 8 + .../containers/query_toggle/index.test.tsx | 74 ++++ .../common/containers/query_toggle/index.tsx | 55 +++ .../containers/query_toggle/translations.tsx | 17 + .../use_search_strategy/index.test.ts | 17 +- .../containers/use_search_strategy/index.tsx | 8 + .../alerts_count_panel/index.test.tsx | 42 ++ .../alerts_kpis/alerts_count_panel/index.tsx | 37 +- .../alerts_histogram_panel/index.test.tsx | 46 +++ .../alerts_histogram_panel/index.tsx | 53 ++- .../alerts_kpis/common/components.tsx | 14 +- .../alerts/use_query.test.tsx | 18 + .../detection_engine/alerts/use_query.tsx | 6 + .../__snapshots__/index.test.tsx.snap | 1 + .../authentications_table/index.test.tsx | 1 + .../authentications_table/index.tsx | 3 + .../host_risk_score_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 1 + .../components/hosts_table/index.test.tsx | 4 + .../hosts/components/hosts_table/index.tsx | 3 + .../kpi_hosts/authentications/index.test.tsx | 66 ++++ .../kpi_hosts/authentications/index.tsx | 13 +- .../components/kpi_hosts/common/index.tsx | 21 +- .../components/kpi_hosts/hosts/index.test.tsx | 66 ++++ .../components/kpi_hosts/hosts/index.tsx | 13 +- .../kpi_hosts/risky_hosts/index.tsx | 9 +- .../kpi_hosts/unique_ips/index.test.tsx | 66 ++++ .../components/kpi_hosts/unique_ips/index.tsx | 13 +- .../index.test.tsx | 96 ++++- .../top_host_score_contributors/index.tsx | 62 ++- .../__snapshots__/index.test.tsx.snap | 1 + .../uncommon_process_table/index.test.tsx | 121 ++---- .../uncommon_process_table/index.tsx | 3 + .../containers/authentications/index.test.tsx | 30 ++ .../containers/authentications/index.tsx | 10 +- .../hosts/containers/hosts/index.test.tsx | 30 ++ .../public/hosts/containers/hosts/index.tsx | 8 + .../kpi_hosts/authentications/index.test.tsx | 28 ++ .../kpi_hosts/authentications/index.tsx | 10 +- .../containers/kpi_hosts/hosts/index.test.tsx | 28 ++ .../containers/kpi_hosts/hosts/index.tsx | 10 +- .../hosts/containers/kpi_hosts/index.tsx | 10 - .../kpi_hosts/unique_ips/index.test.tsx | 28 ++ .../containers/kpi_hosts/unique_ips/index.tsx | 10 +- .../uncommon_processes/index.test.tsx | 30 ++ .../containers/uncommon_processes/index.tsx | 10 +- .../authentications_query_tab_body.test.tsx | 68 ++++ .../authentications_query_tab_body.tsx | 11 +- .../host_risk_score_tab_body.test.tsx | 81 ++++ .../navigation/host_risk_score_tab_body.tsx | 13 +- .../navigation/hosts_query_tab_body.test.tsx | 68 ++++ .../pages/navigation/hosts_query_tab_body.tsx | 21 +- .../uncommon_process_query_tab_body.test.tsx | 68 ++++ .../uncommon_process_query_tab_body.tsx | 13 +- .../__snapshots__/embeddable.test.tsx.snap | 1 + .../components/embeddables/embeddable.tsx | 2 +- .../embeddables/embedded_map.test.tsx | 10 +- .../components/embeddables/embedded_map.tsx | 39 +- .../components/kpi_network/dns/index.test.tsx | 66 ++++ .../components/kpi_network/dns/index.tsx | 13 +- .../network/components/kpi_network/mock.ts | 2 + .../kpi_network/network_events/index.test.tsx | 66 ++++ .../kpi_network/network_events/index.tsx | 14 +- .../kpi_network/tls_handshakes/index.test.tsx | 66 ++++ .../kpi_network/tls_handshakes/index.tsx | 13 +- .../kpi_network/unique_flows/index.test.tsx | 66 ++++ .../kpi_network/unique_flows/index.tsx | 13 +- .../unique_private_ips/index.test.tsx | 66 ++++ .../kpi_network/unique_private_ips/index.tsx | 16 +- .../__snapshots__/index.test.tsx.snap | 1 + .../network_dns_table/index.test.tsx | 37 +- .../components/network_dns_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 1 + .../network_http_table/index.test.tsx | 36 +- .../components/network_http_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 2 + .../index.test.tsx | 72 +--- .../network_top_countries_table/index.tsx | 3 + .../__snapshots__/index.test.tsx.snap | 2 + .../network_top_n_flow_table/index.test.tsx | 52 +-- .../network_top_n_flow_table/index.tsx | 3 + .../components/tls_table/index.test.tsx | 37 +- .../network/components/tls_table/index.tsx | 3 + .../components/users_table/index.test.tsx | 40 +- .../network/components/users_table/index.tsx | 3 + .../containers/kpi_network/dns/index.test.tsx | 28 ++ .../containers/kpi_network/dns/index.tsx | 10 +- .../network/containers/kpi_network/index.tsx | 12 - .../kpi_network/network_events/index.test.tsx | 28 ++ .../kpi_network/network_events/index.tsx | 10 +- .../kpi_network/tls_handshakes/index.test.tsx | 28 ++ .../kpi_network/tls_handshakes/index.tsx | 10 +- .../kpi_network/unique_flows/index.test.tsx | 28 ++ .../kpi_network/unique_flows/index.tsx | 11 +- .../unique_private_ips/index.test.tsx | 28 ++ .../kpi_network/unique_private_ips/index.tsx | 10 +- .../containers/network_dns/index.test.tsx | 31 ++ .../network/containers/network_dns/index.tsx | 10 +- .../containers/network_http/index.test.tsx | 31 ++ .../network/containers/network_http/index.tsx | 14 +- .../network_top_countries/index.test.tsx | 33 ++ .../network_top_countries/index.tsx | 10 +- .../network_top_n_flow/index.test.tsx | 33 ++ .../containers/network_top_n_flow/index.tsx | 10 +- .../network/containers/tls/index.test.tsx | 34 ++ .../public/network/containers/tls/index.tsx | 10 +- .../network/containers/users/index.test.tsx | 34 ++ .../public/network/containers/users/index.tsx | 10 +- .../details/network_http_query_table.tsx | 13 +- .../network_top_countries_query_table.tsx | 13 +- .../network_top_n_flow_query_table.tsx | 13 +- .../network/pages/details/tls_query_table.tsx | 13 +- .../pages/details/users_query_table.tsx | 13 +- .../navigation/countries_query_tab_body.tsx | 13 +- .../pages/navigation/dns_query_tab_body.tsx | 13 +- .../pages/navigation/http_query_tab_body.tsx | 13 +- .../pages/navigation/ips_query_tab_body.tsx | 13 +- .../pages/navigation/tls_query_tab_body.tsx | 13 +- .../components/overview_host/index.test.tsx | 29 +- .../components/overview_host/index.tsx | 43 ++- .../overview_network/index.test.tsx | 29 +- .../components/overview_network/index.tsx | 43 ++- .../containers/overview_host/index.test.tsx | 28 ++ .../containers/overview_host/index.tsx | 7 + .../overview_network/index.test.tsx | 28 ++ .../containers/overview_network/index.tsx | 7 + .../risk_score/containers/all/index.tsx | 8 + .../kpi_users/total_users/index.test.tsx | 68 ++++ .../kpi_users/total_users/index.tsx | 14 +- .../user_risk_score_table/index.test.tsx | 5 +- .../user_risk_score_table/index.tsx | 3 + .../all_users_query_tab_body.test.tsx | 68 ++++ .../navigation/all_users_query_tab_body.tsx | 13 +- .../user_risk_score_tab_body.test.tsx | 81 ++++ .../navigation/user_risk_score_tab_body.tsx | 13 +- 154 files changed, 3965 insertions(+), 1144 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx create mode 100644 x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index 6701224289e66..45a6e20cf087d 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -18,19 +18,25 @@ exports[`HeaderSection it renders 1`] = ` responsive={false} > - -

- + - Test title - -

-
+

+ + Test title + +

+ +
+ diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index 5ec97ea59bc1d..2296dc78241f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -180,4 +180,94 @@ describe('HeaderSection', () => { expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); }); + + test('it does not render query-toggle-header when no arguments provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(false); + }); + + test('it does render query-toggle-header when toggleQuery arguments provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(true); + }); + + test('it does render everything but title when toggleStatus = true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe( + 'arrowDown' + ); + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + true + ); + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(true); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + test('it does not render anything but title when toggleStatus = false', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().prop('iconType')).toBe( + 'arrowRight' + ); + expect(wrapper.find('[data-test-subj="header-section-supplements"]').first().exists()).toBe( + false + ); + expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(false); + expect(wrapper.find('[data-test-subj="header-section-subtitle"]').first().exists()).toBe(false); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); + + test('it toggles query when icon is clicked', () => { + const mockToggle = jest.fn(); + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockToggle).toBeCalledWith(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index ae07a03ba6407..7997dfa83e27b 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -5,13 +5,21 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiTitle, EuiTitleSize } from '@elastic/eui'; -import React from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiTitle, + EuiTitleSize, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled, { css } from 'styled-components'; import { InspectButton } from '../inspect'; import { Subtitle } from '../subtitle'; +import * as i18n from '../../containers/query_toggle/translations'; interface HeaderProps { border?: boolean; @@ -51,6 +59,8 @@ export interface HeaderSectionProps extends HeaderProps { split?: boolean; stackHeader?: boolean; subtitle?: string | React.ReactNode; + toggleQuery?: (status: boolean) => void; + toggleStatus?: boolean; title: string | React.ReactNode; titleSize?: EuiTitleSize; tooltip?: string; @@ -72,56 +82,87 @@ const HeaderSectionComponent: React.FC = ({ subtitle, title, titleSize = 'm', + toggleQuery, + toggleStatus = true, tooltip, -}) => ( -
- - - - - -

- {title} - {tooltip && ( - <> - {' '} - - +}) => { + const toggle = useCallback(() => { + if (toggleQuery) { + toggleQuery(!toggleStatus); + } + }, [toggleQuery, toggleStatus]); + return ( +
+ + + + + + {toggleQuery && ( + + + )} -

-
+ + +

+ {title} + {tooltip && ( + <> + {' '} + + + )} +

+
+
+
- {!hideSubtitle && ( - - )} -
- - {id && showInspectButton && ( - - + {!hideSubtitle && toggleStatus && ( + + )} - )} - {headerFilters && {headerFilters}} -
- + {id && showInspectButton && toggleStatus && ( + + + + )} - {children && ( - - {children} + {headerFilters && toggleStatus && ( + + {headerFilters} + + )} + - )} - -
-); + + {children && toggleStatus && ( + + {children} + + )} + +
+ ); +}; export const HeaderSection = React.memo(HeaderSectionComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index aee49bd1b00ae..1de9e08b4c65c 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -15,6 +15,9 @@ import { TestProviders } from '../../mock'; import { mockRuntimeMappings } from '../../containers/source/mock'; import { dnsTopDomainsLensAttributes } from '../visualization_actions/lens_attributes/network/dns_top_domains'; import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { useQueryToggle } from '../../containers/query_toggle'; + +jest.mock('../../containers/query_toggle'); jest.mock('../../lib/kibana'); jest.mock('./matrix_loader', () => ({ @@ -25,9 +28,7 @@ jest.mock('../charts/barchart', () => ({ BarChart: () =>
, })); -jest.mock('../../containers/matrix_histogram', () => ({ - useMatrixHistogramCombined: jest.fn(), -})); +jest.mock('../../containers/matrix_histogram'); jest.mock('../visualization_actions', () => ({ VisualizationActions: jest.fn(({ className }: { className: string }) => ( @@ -78,9 +79,13 @@ describe('Matrix Histogram Component', () => { title: 'mockTitle', runtimeMappings: mockRuntimeMappings, }; - - beforeAll(() => { - (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + const mockUseMatrix = useMatrixHistogramCombined as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseMatrix.mockReturnValue([ false, { data: null, @@ -88,14 +93,16 @@ describe('Matrix Histogram Component', () => { totalCount: null, }, ]); - wrapper = mount(, { - wrappingComponent: TestProviders, - }); }); describe('on initial load', () => { + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + }); test('it requests Matrix Histogram', () => { - expect(useMatrixHistogramCombined).toHaveBeenCalledWith({ + expect(mockUseMatrix).toHaveBeenCalledWith({ endDate: mockMatrixOverTimeHistogramProps.endDate, errorMessage: mockMatrixOverTimeHistogramProps.errorMessage, histogramType: mockMatrixOverTimeHistogramProps.histogramType, @@ -114,6 +121,9 @@ describe('Matrix Histogram Component', () => { describe('spacer', () => { test('it renders a spacer by default', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find('[data-test-subj="spacer"]').exists()).toBe(true); }); @@ -129,8 +139,11 @@ describe('Matrix Histogram Component', () => { }); describe('not initial load', () => { - beforeAll(() => { - (useMatrixHistogramCombined as jest.Mock).mockReturnValue([ + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + mockUseMatrix.mockReturnValue([ false, { data: [ @@ -159,6 +172,9 @@ describe('Matrix Histogram Component', () => { describe('select dropdown', () => { test('should be hidden if only one option is provided', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find('EuiSelect').exists()).toBe(false); }); }); @@ -287,4 +303,53 @@ describe('Matrix Histogram Component', () => { expect(wrapper.find('[data-test-subj="mock-viz-actions"]').exists()).toBe(false); }); }); + + describe('toggle query', () => { + const testProps = { + ...mockMatrixOverTimeHistogramProps, + lensAttributes: dnsTopDomainsLensAttributes, + }; + + test('toggleQuery updates toggleStatus', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseMatrix.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('MatrixLoader').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('MatrixLoader').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseMatrix.mock.calls[0][0].skip).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index dbf525f8e14cb..488948de074f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -34,6 +34,7 @@ import { GetLensAttributes, LensAttributes } from '../visualization_actions/type import { useKibana, useGetUserCasesPermissions } from '../../lib/kibana'; import { APP_ID, SecurityPageName } from '../../../../common/constants'; import { useRouteSpy } from '../../utils/route/use_route_spy'; +import { useQueryToggle } from '../../containers/query_toggle'; export type MatrixHistogramComponentProps = MatrixHistogramProps & Omit & { @@ -148,6 +149,19 @@ export const MatrixHistogramComponent: React.FC = }, [defaultStackByOption, stackByOptions] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); const matrixHistogramRequest = { endDate, @@ -161,9 +175,8 @@ export const MatrixHistogramComponent: React.FC = runtimeMappings, isPtrIncluded, docValueFields, - skip, + skip: querySkip, }; - const [loading, { data, inspect, totalCount, refetch }] = useMatrixHistogramCombined(matrixHistogramRequest); const [{ pageName }] = useRouteSpy(); @@ -225,7 +238,7 @@ export const MatrixHistogramComponent: React.FC = > {loading && !isInitialLoading && ( @@ -239,8 +252,11 @@ export const MatrixHistogramComponent: React.FC = = {headerChildren} - - {isInitialLoading ? ( - - ) : ( - - )} + {toggleStatus ? ( + isInitialLoading ? ( + + ) : ( + + ) + ) : null} {showSpacer && } diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx index efa4ba4c6eb0f..8eca508a4b74b 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/matrix_loader.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` - flex 1; + flex: 1; `; const MatrixLoaderComponent = () => ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts index f1cab9c2f441d..58610298d4395 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts @@ -80,7 +80,9 @@ export const useAnomaliesTableData = ({ earliestMs: number, latestMs: number ) { - if (isMlUser && !skip && jobIds.length > 0) { + if (skip) { + setLoading(false); + } else if (isMlUser && !skip && jobIds.length > 0) { try { const data = await anomaliesTableData( { diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx new file mode 100644 index 0000000000000..7701880bd7b2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import { AnomaliesHostTable } from './anomalies_host_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { HostsType } from '../../../../hosts/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies host table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + narrowDateRange: jest.fn(), + skip: false, + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="host-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx index 318f452e0c1df..eec90e6117c28 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -21,6 +21,7 @@ import { BasicTable } from './basic_table'; import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type'; import { Panel } from '../../panel'; import { anomaliesTableDefaultEquality } from './default_equality'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -37,10 +38,24 @@ const AnomaliesHostTableComponent: React.FC = ({ type, }) => { const capabilities = useMlCapabilities(); + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesHostTable`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromHostType(type, hostName), filterQuery: { exists: { field: 'host.name' }, @@ -64,21 +79,26 @@ const AnomaliesHostTableComponent: React.FC = ({ return ( - - type is not as specific as EUI's... - columns={columns} - items={hosts} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={hosts} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx new file mode 100644 index 0000000000000..b7491562a5d72 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 { mount } from 'enzyme'; +import { AnomaliesNetworkTable } from './anomalies_network_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { NetworkType } from '../../../../network/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; +import { FlowTarget } from '../../../../../common/search_strategy'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies network table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + flowTarget: FlowTarget.destination, + narrowDateRange: jest.fn(), + skip: false, + type: NetworkType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="network-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx index 78795c6d3614a..242114a806ca8 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_network_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -20,6 +20,7 @@ import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; import { Panel } from '../../panel'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -37,10 +38,25 @@ const AnomaliesNetworkTableComponent: React.FC = ({ flowTarget, }) => { const capabilities = useMlCapabilities(); + + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesNetwork-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromNetworkType(type, ip, flowTarget), }); @@ -63,18 +79,23 @@ const AnomaliesNetworkTableComponent: React.FC = ({ subtitle={`${i18n.SHOWING}: ${pagination.totalItemCount.toLocaleString()} ${i18n.UNIT( pagination.totalItemCount )}`} + height={!toggleStatus ? 40 : undefined} title={i18n.ANOMALIES} tooltip={i18n.TOOLTIP} + toggleQuery={toggleQuery} + toggleStatus={toggleStatus} isInspectDisabled={skip} /> - - type is not as specific as EUI's... - columns={columns} - items={networks} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={networks} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx new file mode 100644 index 0000000000000..40aab638b854a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 { mount } from 'enzyme'; +import { AnomaliesUserTable } from './anomalies_user_table'; +import { TestProviders } from '../../../mock'; +import React from 'react'; +import { useQueryToggle } from '../../../containers/query_toggle'; +import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; +import { UsersType } from '../../../../users/store/model'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; + +jest.mock('../../../containers/query_toggle'); +jest.mock('../anomaly/use_anomalies_table_data'); +jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); + +describe('Anomalies user table', () => { + describe('toggle query', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; + const mockSetToggle = jest.fn(); + const testProps = { + startDate: '2019-07-17T20:00:00.000Z', + endDate: '2019-07-18T20:00:00.000Z', + narrowDateRange: jest.fn(), + userName: 'coolguy', + skip: false, + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + (hasMlUserPermissions as jest.Mock).mockReturnValue(true); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + mockUseAnomaliesTableData.mockReturnValue([ + false, + { + anomalies: [], + interval: '10', + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockUseAnomaliesTableData.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(true); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + expect(wrapper.find('[data-test-subj="user-anomalies-table"]').exists()).toBe(false); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + mount(, { + wrappingComponent: TestProviders, + }); + + expect(mockUseAnomaliesTableData.mock.calls[0][0].skip).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx index 061f2c04cef6d..c67455c0772b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -23,6 +23,7 @@ import { Panel } from '../../panel'; import { anomaliesTableDefaultEquality } from './default_equality'; import { convertAnomaliesToUsers } from './convert_anomalies_to_users'; import { getAnomaliesUserTableColumnsCurated } from './get_anomalies_user_table_columns'; +import { useQueryToggle } from '../../../containers/query_toggle'; const sorting = { sort: { @@ -40,10 +41,24 @@ const AnomaliesUserTableComponent: React.FC = ({ }) => { const capabilities = useMlCapabilities(); + const { toggleStatus, setToggleStatus } = useQueryToggle(`AnomaliesUserTable`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, - skip, + skip: querySkip, criteriaFields: getCriteriaFromUsersType(type, userName), filterQuery: { exists: { field: 'user.name' }, @@ -67,21 +82,27 @@ const AnomaliesUserTableComponent: React.FC = ({ return ( - type is not as specific as EUI's... - columns={columns} - items={users} - pagination={pagination} - sorting={sorting} - /> + {toggleStatus && ( + type is not as specific as EUI's... + columns={columns} + items={users} + pagination={pagination} + sorting={sorting} + /> + )} {loading && ( diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index a2fffc32be46d..bf03d637e8811 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -11,6 +11,8 @@ exports[`Paginated Table Component rendering it renders the default load more ta

@@ -58,6 +60,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, ] } + data-test-subj="paginated-basic-table" items={ Array [ Object { diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx index 0c09dce9c07cb..57686126dfb10 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.test.tsx @@ -15,6 +15,8 @@ import { getHostsColumns, mockData, rowItems, sortedHosts } from './index.mock'; import { ThemeProvider } from 'styled-components'; import { getMockTheme } from '../../lib/kibana/kibana_react.mock'; import { Direction } from '../../../../common/search_strategy'; +import { useQueryToggle } from '../../containers/query_toggle'; +jest.mock('../../containers/query_toggle'); jest.mock('react', () => { const r = jest.requireActual('react'); @@ -36,37 +38,41 @@ const mockTheme = getMockTheme({ }); describe('Paginated Table Component', () => { - let loadPage: jest.Mock; - let updateLimitPagination: jest.Mock; - let updateActivePage: jest.Mock; + const loadPage = jest.fn(); + const updateLimitPagination = jest.fn(); + const updateActivePage = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + const mockSetQuerySkip = jest.fn(); + beforeEach(() => { - loadPage = jest.fn(); - updateLimitPagination = jest.fn(); - updateActivePage = jest.fn(); + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); }); + const testProps = { + activePage: 0, + columns: getHostsColumns(), + headerCount: 1, + headerSupplement:

{'My test supplement.'}

, + headerTitle: 'Hosts', + headerTooltip: 'My test tooltip', + headerUnit: 'Test Unit', + itemsPerRow: rowItems, + limit: 1, + loading: false, + loadPage, + pageOfItems: mockData.Hosts.edges, + setQuerySkip: jest.fn(), + showMorePagesIndicator: true, + totalCount: 10, + updateActivePage, + updateLimitPagination: (limit: number) => updateLimitPagination({ limit }), + }; + describe('rendering', () => { test('it renders the default load more table', () => { - const wrapper = shallow( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -74,24 +80,7 @@ describe('Paginated Table Component', () => { test('it renders the loading panel at the beginning ', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadPage={loadPage} - pageOfItems={[]} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -103,24 +92,7 @@ describe('Paginated Table Component', () => { test('it renders the over loading panel after data has been in the table ', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={true} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -130,24 +102,7 @@ describe('Paginated Table Component', () => { test('it renders the correct amount of pages and starts at activePage: 0', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -167,24 +122,7 @@ describe('Paginated Table Component', () => { test('it render popover to select new limit in table', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -195,24 +133,7 @@ describe('Paginated Table Component', () => { test('it will NOT render popover to select new limit in table if props itemsPerRow is empty', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={[]} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -224,24 +145,11 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} limit={2} - loading={false} - loadPage={jest.fn()} onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} sorting={{ direction: Direction.asc, field: 'node.host.name' }} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -253,22 +161,9 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} + {...testProps} limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} totalCount={DEFAULT_MAX_TABLE_QUERY_SIZE * 3} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -279,24 +174,7 @@ describe('Paginated Table Component', () => { test('Should show items per row if totalCount is greater than items', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={30} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeTruthy(); @@ -305,24 +183,7 @@ describe('Paginated Table Component', () => { test('Should hide items per row if totalCount is less than items', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={1} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); expect(wrapper.find('[data-test-subj="loadingMoreSizeRowPopover"]').exists()).toBeFalsy(); @@ -331,24 +192,7 @@ describe('Paginated Table Component', () => { test('Should hide pagination if totalCount is zero', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={DEFAULT_MAX_TABLE_QUERY_SIZE} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={0} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -360,24 +204,7 @@ describe('Paginated Table Component', () => { test('should call updateActivePage with 1 when clicking to the first page', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={1} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); @@ -387,24 +214,7 @@ describe('Paginated Table Component', () => { test('Should call updateActivePage with 0 when you pick a new limit', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); wrapper.find('[data-test-subj="pagination-button-next"]').first().simulate('click'); @@ -417,22 +227,8 @@ describe('Paginated Table Component', () => { test('should update the page when the activePage is changed from redux', () => { const ourProps: BasicTableProps = { + ...testProps, activePage: 3, - columns: getHostsColumns(), - headerCount: 1, - headerSupplement:

{'My test supplement.'}

, - headerTitle: 'Hosts', - headerTooltip: 'My test tooltip', - headerUnit: 'Test Unit', - itemsPerRow: rowItems, - limit: 1, - loading: false, - loadPage, - pageOfItems: mockData.Hosts.edges, - showMorePagesIndicator: true, - totalCount: 10, - updateActivePage, - updateLimitPagination: (limit) => updateLimitPagination({ limit }), }; // enzyme does not allow us to pass props to child of HOC @@ -462,24 +258,7 @@ describe('Paginated Table Component', () => { test('Should call updateLimitPagination when you pick a new limit', () => { const wrapper = mount( - {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} - limit={2} - loading={false} - loadPage={loadPage} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} - /> +
); @@ -494,24 +273,11 @@ describe('Paginated Table Component', () => { const wrapper = mount( {'My test supplement.'}

} - headerTitle="Hosts" - headerTooltip="My test tooltip" - headerUnit="Test Unit" - itemsPerRow={rowItems} limit={2} - loading={false} - loadPage={jest.fn()} onChange={mockOnChange} - pageOfItems={mockData.Hosts.edges} - showMorePagesIndicator={true} sorting={{ direction: Direction.asc, field: 'node.host.name' }} - totalCount={10} - updateActivePage={updateActivePage} - updateLimitPagination={(limit) => updateLimitPagination({ limit })} />
); @@ -524,4 +290,41 @@ describe('Paginated Table Component', () => { ]); }); }); + + describe('Toggle query', () => { + test('toggleQuery updates toggleStatus', () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockSetQuerySkip).toBeCalledWith(true); + }); + + test('toggleStatus=true, render table', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual( + true + ); + }); + + test('toggleStatus=false, hide table', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="paginated-basic-table"]').first().exists()).toEqual( + false + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index 310ab039057c2..b9de144c5735e 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -20,7 +20,7 @@ import { EuiTableRowCellProps, } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { FC, memo, useState, useMemo, useEffect, ComponentType } from 'react'; +import React, { FC, memo, useState, useMemo, useEffect, ComponentType, useCallback } from 'react'; import styled from 'styled-components'; import { Direction } from '../../../../common/search_strategy'; @@ -49,6 +49,7 @@ import { useStateToaster } from '../toasters'; import * as i18n from './translations'; import { Panel } from '../panel'; import { InspectButtonContainer } from '../inspect'; +import { useQueryToggle } from '../../containers/query_toggle'; const DEFAULT_DATA_TEST_SUBJ = 'paginated-table'; @@ -113,6 +114,7 @@ export interface BasicTableProps { onChange?: (criteria: Criteria) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any pageOfItems: any[]; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; sorting?: SortingBasicTable; split?: boolean; @@ -153,6 +155,7 @@ const PaginatedTableComponent: FC = ({ loadPage, onChange = noop, pageOfItems, + setQuerySkip, showMorePagesIndicator, sorting = null, split, @@ -253,10 +256,24 @@ const PaginatedTableComponent: FC = ({ [sorting] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + return ( = ({ > {!loadingInitial && headerSupplement} - - {loadingInitial ? ( - - ) : ( - <> - - - - {itemsPerRow && itemsPerRow.length > 0 && totalCount >= itemsPerRow[0].numberOfRow && ( - - - - )} - - - - {totalCount > 0 && ( - - )} - - - {(isInspect || myLoading) && ( - - )} - - )} + {toggleStatus && + (loadingInitial ? ( + + ) : ( + <> + + + + {itemsPerRow && + itemsPerRow.length > 0 && + totalCount >= itemsPerRow[0].numberOfRow && ( + + + + )} + + + + {totalCount > 0 && ( + + )} + + + {(isInspect || myLoading) && ( + + )} + + ))} ); diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx index 5f2c76632aba9..944eeb8b42a57 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx @@ -41,6 +41,7 @@ import { NetworkKpiStrategyResponse, } from '../../../../common/search_strategy'; import { getMockTheme } from '../../lib/kibana/kibana_react.mock'; +import * as module from '../../containers/query_toggle'; const from = '2019-06-15T06:00:00.000Z'; const to = '2019-06-18T06:00:00.000Z'; @@ -53,26 +54,37 @@ jest.mock('../charts/barchart', () => { return { BarChart: () =>
}; }); +const mockSetToggle = jest.fn(); + +jest + .spyOn(module, 'useQueryToggle') + .mockImplementation(() => ({ toggleStatus: true, setToggleStatus: mockSetToggle })); +const mockSetQuerySkip = jest.fn(); describe('Stat Items Component', () => { const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - + const testProps = { + description: 'HOSTS', + fields: [{ key: 'hosts', value: null, color: '#6092C0', icon: 'cross' }], + from, + id: 'statItems', + key: 'mock-keys', + loading: false, + setQuerySkip: mockSetQuerySkip, + to, + narrowDateRange: mockNarrowDateRange, + }; + beforeEach(() => { + jest.clearAllMocks(); + }); describe.each([ [ mount( - + ), @@ -81,17 +93,7 @@ describe('Stat Items Component', () => { mount( - + ), @@ -118,62 +120,59 @@ describe('Stat Items Component', () => { }); }); + const mockStatItemsData: StatItemsProps = { + ...testProps, + areaChart: [ + { + key: 'uniqueSourceIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, + ], + color: '#D36086', + }, + { + key: 'uniqueDestinationIpsHistogram', + value: [ + { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, + { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, + { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, + ], + color: '#9170B8', + }, + ], + barChart: [ + { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' }, + { + key: 'uniqueDestinationIps', + value: [{ x: 'uniqueDestinationIps', y: 2354 }], + color: '#9170B8', + }, + ], + description: 'UNIQUE_PRIVATE_IPS', + enableAreaChart: true, + enableBarChart: true, + fields: [ + { + key: 'uniqueSourceIps', + description: 'Source', + value: 1714, + color: '#D36086', + icon: 'cross', + }, + { + key: 'uniqueDestinationIps', + description: 'Dest.', + value: 2359, + color: '#9170B8', + icon: 'cross', + }, + ], + }; + + let wrapper: ReactWrapper; describe('rendering kpis with charts', () => { - const mockStatItemsData: StatItemsProps = { - areaChart: [ - { - key: 'uniqueSourceIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, - ], - color: '#D36086', - }, - { - key: 'uniqueDestinationIpsHistogram', - value: [ - { x: new Date('2019-05-03T13:00:00.000Z').toISOString(), y: 565975 }, - { x: new Date('2019-05-04T01:00:00.000Z').toISOString(), y: 1084366 }, - { x: new Date('2019-05-04T13:00:00.000Z').toISOString(), y: 12280 }, - ], - color: '#9170B8', - }, - ], - barChart: [ - { key: 'uniqueSourceIps', value: [{ x: 'uniqueSourceIps', y: '1714' }], color: '#D36086' }, - { - key: 'uniqueDestinationIps', - value: [{ x: 'uniqueDestinationIps', y: 2354 }], - color: '#9170B8', - }, - ], - description: 'UNIQUE_PRIVATE_IPS', - enableAreaChart: true, - enableBarChart: true, - fields: [ - { - key: 'uniqueSourceIps', - description: 'Source', - value: 1714, - color: '#D36086', - icon: 'cross', - }, - { - key: 'uniqueDestinationIps', - description: 'Dest.', - value: 2359, - color: '#9170B8', - icon: 'cross', - }, - ], - from, - id: 'statItems', - key: 'mock-keys', - to, - narrowDateRange: mockNarrowDateRange, - }; - let wrapper: ReactWrapper; beforeAll(() => { wrapper = mount( @@ -202,6 +201,43 @@ describe('Stat Items Component', () => { expect(wrapper.find(EuiHorizontalRule)).toHaveLength(1); }); }); + describe('Toggle query', () => { + test('toggleQuery updates toggleStatus', () => { + wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-stat"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + expect(mockSetQuerySkip).toBeCalledWith(true); + }); + test('toggleStatus=true, render all', () => { + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(true); + }); + test('toggleStatus=false, render none', () => { + jest + .spyOn(module, 'useQueryToggle') + .mockImplementation(() => ({ toggleStatus: false, setToggleStatus: mockSetToggle })); + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toEqual( + false + ); + expect(wrapper.find('[data-test-subj="stat-title"]').first().exists()).toEqual(false); + }); + }); }); describe('addValueToFields', () => { @@ -244,7 +280,9 @@ describe('useKpiMatrixStatus', () => { 'statItem', from, to, - mockNarrowDateRange + mockNarrowDateRange, + mockSetQuerySkip, + false ); return ( @@ -262,8 +300,10 @@ describe('useKpiMatrixStatus', () => { ); - - expect(wrapper.find('MockChildComponent').get(0).props).toEqual(mockEnableChartsData); + const result = { ...wrapper.find('MockChildComponent').get(0).props }; + const { setQuerySkip, ...restResult } = result; + const { setQuerySkip: a, ...restExpect } = mockEnableChartsData; + expect(restResult).toEqual(restExpect); }); test('it should not append areaChart if enableAreaChart is off', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx index 424920d34e2e8..6de3cc07472bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.tsx @@ -12,13 +12,16 @@ import { EuiPanel, EuiHorizontalRule, EuiIcon, + EuiButtonIcon, + EuiLoadingSpinner, EuiTitle, IconType, } from '@elastic/eui'; import { get, getOr } from 'lodash/fp'; -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { useQueryToggle } from '../../containers/query_toggle'; import { HostsKpiStrategyResponse, @@ -34,6 +37,7 @@ import { InspectButton } from '../inspect'; import { VisualizationActions, HISTOGRAM_ACTIONS_BUTTON_CLASS } from '../visualization_actions'; import { HoverVisibilityContainer } from '../hover_visibility_container'; import { LensAttributes } from '../visualization_actions/types'; +import * as i18n from '../../containers/query_toggle/translations'; import { UserskKpiStrategyResponse } from '../../../../common/search_strategy/security_solution/users'; const FlexItem = styled(EuiFlexItem)` @@ -84,6 +88,8 @@ export interface StatItemsProps extends StatItems { narrowDateRange: UpdateDateRange; to: string; showInspectButton?: boolean; + loading: boolean; + setQuerySkip: (skip: boolean) => void; } export const numberFormatter = (value: string | number): string => value.toLocaleString(); @@ -176,33 +182,27 @@ export const useKpiMatrixStatus = ( id: string, from: string, to: string, - narrowDateRange: UpdateDateRange -): StatItemsProps[] => { - const [statItemsProps, setStatItemsProps] = useState(mappings as StatItemsProps[]); - - useEffect(() => { - setStatItemsProps( - mappings.map((stat) => { - return { - ...stat, - areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined, - barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined, - fields: addValueToFields(stat.fields, data), - id, - key: `kpi-summary-${stat.key}`, - statKey: `${stat.key}`, - from, - to, - narrowDateRange, - }; - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); - - return statItemsProps; -}; - + narrowDateRange: UpdateDateRange, + setQuerySkip: (skip: boolean) => void, + loading: boolean +): StatItemsProps[] => + mappings.map((stat) => ({ + ...stat, + areaChart: stat.enableAreaChart ? addValueToAreaChart(stat.fields, data) : undefined, + barChart: stat.enableBarChart ? addValueToBarChart(stat.fields, data) : undefined, + fields: addValueToFields(stat.fields, data), + id, + key: `kpi-summary-${stat.key}`, + statKey: `${stat.key}`, + from, + to, + narrowDateRange, + setQuerySkip, + loading, + })); +const StyledTitle = styled.h6` + line-height: 200%; +`; export const StatItemsComponent = React.memo( ({ areaChart, @@ -214,13 +214,15 @@ export const StatItemsComponent = React.memo( from, grow, id, - showInspectButton, + loading = false, + showInspectButton = true, index, narrowDateRange, statKey = 'item', to, barChartLensAttributes, areaChartLensAttributes, + setQuerySkip, }) => { const isBarChartDataAvailable = barChart && @@ -239,101 +241,143 @@ export const StatItemsComponent = React.memo( [from, to] ); + const { toggleStatus, setToggleStatus } = useQueryToggle(id); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const toggle = useCallback(() => toggleQuery(!toggleStatus), [toggleQuery, toggleStatus]); + return ( - -
{description}
-
+ + + + + + + {description} + + +
- {showInspectButton && ( + {showInspectButton && toggleStatus && !loading && ( )}
+ {loading && ( + + + + + + )} + {toggleStatus && !loading && ( + <> + + {fields.map((field) => ( + + + {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( + + + + )} - - {fields.map((field) => ( - - - {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( - - - - )} + + + +

+ {field.value != null + ? field.value.toLocaleString() + : getEmptyTagValue()}{' '} + {field.description} +

+
+ {field.lensAttributes && timerange && ( + + )} +
+
+
+
+ ))} +
+ {(enableAreaChart || enableBarChart) && } + + {enableBarChart && ( - - -

- {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} - {field.description} -

-
- {field.lensAttributes && timerange && ( - - )} -
+
-
-
- ))} -
+ )} - {(enableAreaChart || enableBarChart) && } - - {enableBarChart && ( - - - - )} - - {enableAreaChart && from != null && to != null && ( - <> - - - - - )} - + {enableAreaChart && from != null && to != null && ( + <> + + + + + )} + + + )}
); @@ -344,6 +388,8 @@ export const StatItemsComponent = React.memo( prevProps.enableBarChart === nextProps.enableBarChart && prevProps.from === nextProps.from && prevProps.grow === nextProps.grow && + prevProps.loading === nextProps.loading && + prevProps.setQuerySkip === nextProps.setQuerySkip && prevProps.id === nextProps.id && prevProps.index === nextProps.index && prevProps.narrowDateRange === nextProps.narrowDateRange && diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts index e09dbe23d512a..138fa99ef4074 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.test.ts @@ -6,7 +6,6 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; - import { useKibana } from '../../../common/lib/kibana'; import { useMatrixHistogram, useMatrixHistogramCombined } from '.'; import { MatrixHistogramType } from '../../../../common/search_strategy'; @@ -39,6 +38,7 @@ describe('useMatrixHistogram', () => { indexNames: [], stackByField: 'event.module', startDate: new Date(Date.now()).toISOString(), + skip: false, }; afterEach(() => { @@ -145,6 +145,17 @@ describe('useMatrixHistogram', () => { mockDnsSearchStrategyResponse.rawResponse.aggregations?.dns_name_query_count.buckets ); }); + + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { ...props }; + const { rerender } = renderHook(() => useMatrixHistogram(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(3); + }); }); describe('useMatrixHistogramCombined', () => { diff --git a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts index c49a9d0438b2d..f6670c98fc0ee 100644 --- a/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts +++ b/x-pack/plugins/security_solution/public/common/containers/matrix_histogram/index.ts @@ -229,6 +229,14 @@ export const useMatrixHistogram = ({ }; }, [matrixHistogramRequest, hostsSearch, skip]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + const runMatrixHistogramSearch = useCallback( (to: string, from: string) => { hostsSearch({ diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx new file mode 100644 index 0000000000000..76f1c02dcb43c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { + renderHook, + act, + RenderResult, + WaitForNextUpdate, + cleanup, +} from '@testing-library/react-hooks'; +import { QueryToggle, useQueryToggle } from '.'; +import { RouteSpyState } from '../../utils/route/types'; +import { SecurityPageName } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana'; + +const mockRouteSpy: RouteSpyState = { + pageName: SecurityPageName.overview, + detailName: undefined, + tabName: undefined, + search: '', + pathName: '/', +}; +jest.mock('../../lib/kibana'); +jest.mock('../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => [mockRouteSpy], +})); + +describe('useQueryToggle', () => { + let result: RenderResult; + let waitForNextUpdate: WaitForNextUpdate; + const mockSet = jest.fn(); + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + storage: { + get: () => true, + set: mockSet, + }, + }, + }); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('Toggles local storage', async () => { + await act(async () => { + ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle('queryId'))); + await waitForNextUpdate(); + expect(result.current.toggleStatus).toEqual(true); + }); + act(() => { + result.current.setToggleStatus(false); + }); + expect(result.current.toggleStatus).toEqual(false); + expect(mockSet).toBeCalledWith('kibana.siem:queryId.query.toggle:overview', false); + cleanup(); + }); + it('null storage key, do not set', async () => { + await act(async () => { + ({ result, waitForNextUpdate } = renderHook(() => useQueryToggle())); + await waitForNextUpdate(); + expect(result.current.toggleStatus).toEqual(true); + }); + act(() => { + result.current.setToggleStatus(false); + }); + expect(mockSet).not.toBeCalled(); + cleanup(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx new file mode 100644 index 0000000000000..53bcd6b60fc1b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/index.tsx @@ -0,0 +1,55 @@ +/* + * 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 { useEffect, useCallback, useState } from 'react'; +import { useKibana } from '../../lib/kibana'; +import { useRouteSpy } from '../../utils/route/use_route_spy'; + +export const getUniqueStorageKey = (pageName: string, id?: string): string | null => + id && pageName.length > 0 ? `kibana.siem:${id}.query.toggle:${pageName}` : null; +export interface QueryToggle { + toggleStatus: boolean; + setToggleStatus: (b: boolean) => void; +} + +export const useQueryToggle = (id?: string): QueryToggle => { + const [{ pageName }] = useRouteSpy(); + const { + services: { storage }, + } = useKibana(); + const storageKey = getUniqueStorageKey(pageName, id); + + const [storageValue, setStorageValue] = useState( + storageKey != null ? storage.get(storageKey) ?? true : true + ); + + useEffect(() => { + if (storageKey != null) { + setStorageValue(storage.get(storageKey) ?? true); + } + }, [storage, storageKey]); + + const setToggleStatus = useCallback( + (isOpen: boolean) => { + if (storageKey != null) { + storage.set(storageKey, isOpen); + setStorageValue(isOpen); + } + }, + [storage, storageKey] + ); + + return id + ? { + toggleStatus: storageValue, + setToggleStatus, + } + : { + toggleStatus: true, + setToggleStatus: () => {}, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx new file mode 100644 index 0000000000000..acb64e7e6b510 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/query_toggle/translations.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const QUERY_BUTTON_TITLE = (buttonOn: boolean) => + buttonOn + ? i18n.translate('xpack.securitySolution.toggleQuery.on', { + defaultMessage: 'Open', + }) + : i18n.translate('xpack.securitySolution.toggleQuery.off', { + defaultMessage: 'Closed', + }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts index 5bfa9028a0fe8..c1513b7a0485b 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.test.ts @@ -6,7 +6,7 @@ */ import { useSearchStrategy } from './index'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import { useObservable } from '@kbn/securitysolution-hook-utils'; import { FactoryQueryTypes } from '../../../../common/search_strategy'; @@ -200,4 +200,19 @@ describe('useSearchStrategy', () => { expect(start).toBeCalledWith(expect.objectContaining({ signal })); }); + it('skip = true will cancel any running request', () => { + const abortSpy = jest.fn(); + const signal = new AbortController().signal; + jest.spyOn(window, 'AbortController').mockReturnValue({ abort: abortSpy, signal }); + const factoryQueryType = 'fakeQueryType' as FactoryQueryTypes; + const localProps = { + ...userSearchStrategyProps, + skip: false, + factoryQueryType, + }; + const { rerender } = renderHook(() => useSearchStrategy(localProps)); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx index 77676a83d39b6..234cf039024ba 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_search_strategy/index.tsx @@ -96,6 +96,7 @@ export const useSearchStrategy = ({ factoryQueryType, initialResult, errorMessage, + skip = false, }: { factoryQueryType: QueryType; /** @@ -106,6 +107,7 @@ export const useSearchStrategy = ({ * Message displayed to the user on a Toast when an erro happens. */ errorMessage?: string; + skip?: boolean; }) => { const abortCtrl = useRef(new AbortController()); const { getTransformChangesIfTheyExist } = useTransforms(); @@ -154,6 +156,12 @@ export const useSearchStrategy = ({ }; }, []); + useEffect(() => { + if (skip) { + abortCtrl.current.abort(); + } + }, [skip]); + const [formatedResult, inspect] = useMemo( () => [ result diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx index d0b05587a4711..4fc47421a720e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx @@ -12,7 +12,9 @@ import { mount } from 'enzyme'; import { TestProviders } from '../../../../common/mock'; import { AlertsCountPanel } from './index'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +jest.mock('../../../../common/containers/query_toggle'); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; @@ -22,6 +24,12 @@ describe('AlertsCountPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', }; + const mockSetToggle = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); it('renders correctly', async () => { await act(async () => { @@ -54,4 +62,38 @@ describe('AlertsCountPanel', () => { }); }); }); + describe('toggleQuery', () => { + it('toggles', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 04b8f482fd121..1c0e2144ad9d4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -6,7 +6,7 @@ */ import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; -import React, { memo, useMemo, useState, useEffect } from 'react'; +import React, { memo, useMemo, useState, useEffect, useCallback } from 'react'; import uuid from 'uuid'; import type { Filter, Query } from '@kbn/es-query'; @@ -24,6 +24,7 @@ import type { AlertsCountAggregation } from './types'; import { DEFAULT_STACK_BY_FIELD } from '../common/config'; import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; @@ -64,6 +65,20 @@ export const AlertsCountPanel = memo( } }, [query, filters]); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_COUNT_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const { loading: isLoadingAlerts, data: alertsData, @@ -80,6 +95,7 @@ export const AlertsCountPanel = memo( runtimeMappings ), indexName: signalIndexName, + skip: querySkip, }); useEffect(() => { @@ -99,21 +115,26 @@ export const AlertsCountPanel = memo( }); return ( - - + + - + {toggleStatus && ( + + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index 29e18a1c49c12..3135e2e173793 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -12,9 +12,13 @@ import { mount } from 'enzyme'; import type { Filter } from '@kbn/es-query'; import { TestProviders } from '../../../../common/mock'; import { SecurityPageName } from '../../../../app/types'; +import { MatrixLoader } from '../../../../common/components/matrix_histogram/matrix_loader'; import { AlertsHistogramPanel } from './index'; import * as helpers from './helpers'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; + +jest.mock('../../../../common/containers/query_toggle'); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -91,6 +95,12 @@ describe('AlertsHistogramPanel', () => { updateDateRange: jest.fn(), }; + const mockSetToggle = jest.fn(); + const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); + afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); @@ -339,4 +349,40 @@ describe('AlertsHistogramPanel', () => { `); }); }); + + describe('toggleQuery', () => { + it('toggles', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + it('toggleStatus=true, render', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(MatrixLoader).exists()).toEqual(true); + }); + }); + it('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + await act(async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find(MatrixLoader).exists()).toEqual(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 571f656389f6a..84476c3ee6885 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -45,6 +45,7 @@ import type { AlertsStackByField } from '../common/types'; import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const defaultTotalAlertsObj: AlertsTotal = { value: 0, @@ -116,6 +117,19 @@ export const AlertsHistogramPanel = memo( onlyField == null ? defaultStackByOption : onlyField ); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_HISTOGRAM_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); const { loading: isLoadingAlerts, data: alertsData, @@ -132,6 +146,7 @@ export const AlertsHistogramPanel = memo( runtimeMappings ), indexName: signalIndexName, + skip: querySkip, }); const kibana = useKibana(); @@ -270,17 +285,21 @@ export const AlertsHistogramPanel = memo( ); return ( - + ( - {isInitialLoading ? ( - - ) : ( - - )} + {toggleStatus ? ( + isInitialLoading ? ( + + ) : ( + + ) + ) : null} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx index 6a56f7bc220ac..27f33409ae1a5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx @@ -12,17 +12,23 @@ import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config'; import { useStackByFields } from './hooks'; import * as i18n from './translations'; -export const KpiPanel = styled(EuiPanel)<{ height?: number }>` +export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boolean }>` display: flex; flex-direction: column; position: relative; overflow: hidden; - - height: ${MOBILE_PANEL_HEIGHT}px; - @media only screen and (min-width: ${(props) => props.theme.eui.euiBreakpoints.m}) { + ${({ $toggleStatus }) => + $toggleStatus && + ` height: ${PANEL_HEIGHT}px; + `} } + ${({ $toggleStatus }) => + $toggleStatus && + ` + height: ${MOBILE_PANEL_HEIGHT}px; + `} `; interface StackedBySelectProps { selected: string; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx index 277e2008601dc..5ed7a219e5068 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.test.tsx @@ -129,4 +129,22 @@ describe('useQueryAlerts', () => { }); }); }); + + test('skip', async () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + await act(async () => { + const localProps = { query: mockAlertsQuery, indexName, skip: false }; + const { rerender, waitForNextUpdate } = renderHook< + [object, string], + ReturnQueryAlerts + >(() => useQueryAlerts(localProps)); + await waitForNextUpdate(); + await waitForNextUpdate(); + + localProps.skip = true; + act(() => rerender()); + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index b2bbcdf277992..2b98987e52675 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -94,6 +94,12 @@ export const useQueryAlerts = ({ if (!isEmpty(query) && !skip) { fetchData(); } + if (skip) { + setLoading(false); + isSubscribed = false; + abortCtrl.abort(); + } + return () => { isSubscribed = false; abortCtrl.abort(); diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap index ed119568cdcb3..bffd5e2261ad9 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/__snapshots__/index.test.tsx.snap @@ -105,6 +105,7 @@ exports[`Authentication Table Component rendering it renders the authentication isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={54} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx index 14dc1769dbd05..2ec333e335639 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx @@ -45,6 +45,7 @@ describe('Authentication Table Component', () => { isInspect={false} loading={false} loadPage={loadPage} + setQuerySkip={jest.fn()} showMorePagesIndicator={getOr( false, 'showMorePagesIndicator', diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx index 4402f6a210947..2bbda82e15315 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.tsx @@ -43,6 +43,7 @@ interface AuthenticationTableProps { loadPage: (newActivePage: number) => void; id: string; isInspect: boolean; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -78,6 +79,7 @@ const AuthenticationTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -133,6 +135,7 @@ const AuthenticationTableComponent: React.FC = ({ loading={loading} loadPage={loadPage} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} totalCount={fakeTotalCount} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx index e4130eee21909..f4da6983fc590 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/host_risk_score_table/index.tsx @@ -54,6 +54,7 @@ interface HostRiskScoreTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; severityCount: SeverityCount; totalCount: number; type: hostsModel.HostsType; @@ -71,6 +72,7 @@ const HostRiskScoreTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, severityCount, totalCount, type, @@ -207,6 +209,7 @@ const HostRiskScoreTableComponent: React.FC = ({ loadPage={loadPage} onChange={onSort} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={false} sorting={sort} split={true} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap index 59a00cbf190f6..f646fc12c4697 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/__snapshots__/index.test.tsx.snap @@ -36,6 +36,7 @@ exports[`Hosts Table rendering it renders the default Hosts table 1`] = ` isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={false} totalCount={-1} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx index 71efbb0a44d15..43dc31c68d1bc 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx @@ -69,6 +69,7 @@ describe('Hosts Table', () => { fakeTotalCount={0} loading={false} loadPage={loadPage} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} totalCount={-1} type={hostsModel.HostsType.page} @@ -91,6 +92,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} @@ -113,6 +115,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} @@ -136,6 +139,7 @@ describe('Hosts Table', () => { data={mockData} totalCount={0} fakeTotalCount={-1} + setQuerySkip={jest.fn()} showMorePagesIndicator={false} loadPage={loadPage} type={hostsModel.HostsType.page} diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index 01306004844d8..42c8254ffd183 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -42,6 +42,7 @@ interface HostsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -77,6 +78,7 @@ const HostsTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -172,6 +174,7 @@ const HostsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx new file mode 100644 index 0000000000000..164b88399bbe9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiAuthentications } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/authentications'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Authentications KPI', () => { + const mockUseHostsKpiAuthentications = useHostsKpiAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiAuthentications.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx index 1158c842e04cb..f12eca88ffc95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/authentications/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUserAuthenticationsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_area'; import { kpiUserAuthenticationsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_bar'; import { kpiUserAuthenticationsMetricSuccessLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentications_metric_success'; import { kpiUserAuthenticationsMetricFailureLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_user_authentication_metric_failure'; -import { useHostsKpiAuthentications } from '../../../containers/kpi_hosts/authentications'; +import { useHostsKpiAuthentications, ID } from '../../../containers/kpi_hosts/authentications'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -57,12 +58,17 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiAuthentications({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -77,6 +83,7 @@ const HostsKpiAuthenticationsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx index e3460ec22e73e..4296ae4984b95 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexItem, EuiLoadingSpinner, EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -42,10 +42,11 @@ interface KpiBaseComponentProps { from: string; to: string; narrowDateRange: UpdateDateRange; + setQuerySkip: (skip: boolean) => void; } export const KpiBaseComponent = React.memo( - ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange }) => { + ({ fieldsMapping, data, id, loading = false, from, to, narrowDateRange, setQuerySkip }) => { const { cases } = useKibana().services; const CasesContext = cases.ui.getCasesContext(); const userPermissions = useGetUserCasesPermissions(); @@ -57,13 +58,11 @@ export const KpiBaseComponent = React.memo( id, from, to, - narrowDateRange + narrowDateRange, + setQuerySkip, + loading ); - if (loading) { - return ; - } - return ( @@ -87,11 +86,3 @@ export const KpiBaseComponent = React.memo( KpiBaseComponent.displayName = 'KpiBaseComponent'; export const KpiBaseComponentManage = manageQuery(KpiBaseComponent); - -export const KpiBaseComponentLoader: React.FC = () => ( - - - - - -); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx new file mode 100644 index 0000000000000..49b6986515564 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiHosts } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/hosts'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Hosts KPI', () => { + const mockUseHostsKpiHosts = useHostsKpiHosts as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiHosts.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiHosts.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiHosts.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx index 79118b66a3f71..b29bdddd44e35 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/hosts/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiHostAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_area'; import { kpiHostMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; -import { useHostsKpiHosts } from '../../../containers/kpi_hosts/hosts'; +import { useHostsKpiHosts, ID } from '../../../containers/kpi_hosts/hosts'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -42,12 +43,17 @@ const HostsKpiHostsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiHosts({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -62,6 +68,7 @@ const HostsKpiHostsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index f515490252d40..0a86a9006b637 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -11,6 +11,7 @@ import { EuiHorizontalRule, EuiIcon, EuiPanel, + EuiLoadingSpinner, EuiTitle, EuiText, } from '@elastic/eui'; @@ -22,7 +23,6 @@ import { BUTTON_CLASS as INPECT_BUTTON_CLASS, } from '../../../../common/components/inspect'; -import { KpiBaseComponentLoader } from '../common'; import * as i18n from './translations'; import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; @@ -36,6 +36,13 @@ import { HoverVisibilityContainer } from '../../../../common/components/hover_vi import { KpiRiskScoreStrategyResponse, RiskSeverity } from '../../../../../common/search_strategy'; import { RiskScore } from '../../../../common/components/severity/common'; +const KpiBaseComponentLoader: React.FC = () => ( + + + + + +); const QUERY_ID = 'hostsKpiRiskyHostsQuery'; const HostCount = styled(EuiText)` diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx new file mode 100644 index 0000000000000..20de5db340b5e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { HostsKpiUniqueIps } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_hosts/unique_ips'); +jest.mock('../common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique IPs KPI', () => { + const mockUseHostsKpiUniqueIps = useHostsKpiUniqueIps as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostsKpiUniqueIps.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostsKpiUniqueIps.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostsKpiUniqueIps.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx index ef7bdfa1dc031..ef032d041db7d 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/unique_ips/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUniqueIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_area'; import { kpiUniqueIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_bar'; import { kpiUniqueIpsDestinationMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_destination_metric'; import { kpiUniqueIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_unique_ips_source_metric'; -import { useHostsKpiUniqueIps } from '../../../containers/kpi_hosts/unique_ips'; +import { useHostsKpiUniqueIps, ID } from '../../../containers/kpi_hosts/unique_ips'; import { KpiBaseComponentManage } from '../common'; import { HostsKpiProps, HostsKpiChartColors } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -57,12 +58,17 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useHostsKpiUniqueIps({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -77,6 +83,7 @@ const HostsKpiUniqueIpsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx index 2f3a414344cfc..5ff8696ae5be3 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.test.tsx @@ -5,16 +5,30 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import { TopHostScoreContributors } from '.'; import { TestProviders } from '../../../common/mock'; import { useHostRiskScore } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +jest.mock('../../../common/containers/query_toggle'); jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; - +const testProps = { + setQuery: jest.fn(), + deleteQuery: jest.fn(), + hostName: 'test-host-name', + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', +}; describe('Host Risk Flyout', () => { + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockSetToggle = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); + }); it('renders', () => { useHostRiskScoreMock.mockReturnValueOnce([ true, @@ -26,13 +40,7 @@ describe('Host Risk Flyout', () => { const { queryByTestId } = render( - + ); @@ -69,13 +77,7 @@ describe('Host Risk Flyout', () => { const { queryAllByRole } = render( - + ); @@ -83,4 +85,66 @@ describe('Host Risk Flyout', () => { expect(queryAllByRole('row')[2]).toHaveTextContent('second'); expect(queryAllByRole('row')[3]).toHaveTextContent('third'); }); + + describe('toggleQuery', () => { + beforeEach(() => { + useHostRiskScoreMock.mockReturnValue([ + true, + { + data: [], + isModuleEnabled: true, + }, + ]); + }); + + test('toggleQuery updates toggleStatus', () => { + const { getByTestId } = render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); + fireEvent.click(getByTestId('query-toggle-header')); + expect(mockSetToggle).toBeCalledWith(false); + expect(useHostRiskScoreMock.mock.calls[1][0].skip).toEqual(true); + }); + + test('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(false); + }); + + test('toggleStatus=true, render components', () => { + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topHostScoreContributors-table')).toBeTruthy(); + }); + + test('toggleStatus=false, do not render components', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const { queryByTestId } = render( + + + + ); + expect(queryByTestId('topHostScoreContributors-table')).toBeFalsy(); + }); + + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + render( + + + + ); + expect(useHostRiskScoreMock.mock.calls[0][0].skip).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx index 8811a6b64e7fc..a3b7022ee83ef 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/top_host_score_contributors/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiFlexGroup, @@ -27,6 +27,7 @@ import { HostsComponentsQueryProps } from '../../pages/navigation/types'; import { RuleLink } from '../../../detections/pages/detection_engine/rules/all/use_columns'; import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface TopHostScoreContributorsProps extends Pick { @@ -77,11 +78,27 @@ const TopHostScoreContributorsComponent: React.FC const sort = useMemo(() => ({ field: RiskScoreFields.timestamp, direction: Direction.desc }), []); + const { toggleStatus, setToggleStatus } = useQueryToggle(QUERY_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { data, refetch, inspect }] = useHostRiskScore({ filterQuery: hostName ? buildHostNamesFilter([hostName]) : undefined, timerange, onlyLatest: false, sort, + skip: querySkip, pagination: { querySize: 1, cursorStart: 0, @@ -119,24 +136,37 @@ const TopHostScoreContributorsComponent: React.FC - - - - - - - - - - - + {toggleStatus && ( + + + + )} + + {toggleStatus && ( + + + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap index a93c4062e8808..19a6018f6b680 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap @@ -205,6 +205,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={5} type="page" diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx index 29d3f110e8181..300abc60818cb 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.test.tsx @@ -36,21 +36,24 @@ describe('Uncommon Process Table Component', () => { const loadPage = jest.fn(); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'uncommonProcess', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: hostsModel.HostsType.page, + }; + describe('rendering', () => { test('it renders the default Uncommon process table', () => { const wrapper = shallow( - + ); @@ -60,17 +63,7 @@ describe('Uncommon Process Table Component', () => { test('it has a double dash (empty value) without any hosts at all', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(0).find('.euiTableRowCell').at(3).text()).toBe( @@ -81,17 +74,7 @@ describe('Uncommon Process Table Component', () => { test('it has a single host without any extra comma when the number of hosts is exactly 1', () => { const wrapper = mount( - + ); @@ -103,17 +86,7 @@ describe('Uncommon Process Table Component', () => { test('it has a single link when the number of hosts is exactly 1', () => { const wrapper = mount( - + ); @@ -125,17 +98,7 @@ describe('Uncommon Process Table Component', () => { test('it has a comma separated list of hosts when the number of hosts is greater than 1', () => { const wrapper = mount( - + ); @@ -147,17 +110,7 @@ describe('Uncommon Process Table Component', () => { test('it has 2 links when the number of hosts is equal to 2', () => { const wrapper = mount( - + ); @@ -169,17 +122,7 @@ describe('Uncommon Process Table Component', () => { test('it is empty when all hosts are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(3).find('.euiTableRowCell').at(3).text()).toBe( @@ -190,17 +133,7 @@ describe('Uncommon Process Table Component', () => { test('it has no link when all hosts are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect( @@ -211,17 +144,7 @@ describe('Uncommon Process Table Component', () => { test('it is returns two hosts when others are invalid because they do not contain an id and a name', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiTableRow').at(4).find('.euiTableRowCell').at(3).text()).toBe( diff --git a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx index 0af27bdb0ba18..cbdae1747e5f6 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/uncommon_process_table/index.tsx @@ -30,6 +30,7 @@ interface UncommonProcessTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: hostsModel.HostsType; @@ -72,6 +73,7 @@ const UncommonProcessTableComponent = React.memo( loading, loadPage, totalCount, + setQuerySkip, showMorePagesIndicator, type, }) => { @@ -125,6 +127,7 @@ const UncommonProcessTableComponent = React.memo( loading={loading} loadPage={loadPage} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} totalCount={fakeTotalCount} updateLimitPagination={updateLimitPagination} diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx new file mode 100644 index 0000000000000..1f6ee4cb276ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from './index'; +import { HostsType } from '../../store/model'; + +describe('authentications', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useAuthentications(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index f446380e54937..1ff27e4b29917 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -36,7 +36,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'hostsAuthenticationsQuery'; +export const ID = 'hostsAuthenticationsQuery'; export interface AuthenticationArgs { authentications: AuthenticationsEdges[]; @@ -215,5 +215,13 @@ export const useAuthentications = ({ }; }, [authenticationsRequest, authenticationsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, authenticationsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx new file mode 100644 index 0000000000000..df64f4cd6f81a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useAllHost } from './index'; +import { HostsType } from '../../store/model'; + +describe('useAllHost', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useAllHost(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx index 1a9e86755cf7d..c4259e8a5a737 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/hosts/index.tsx @@ -217,5 +217,13 @@ export const useAllHost = ({ }; }, [hostsRequest, hostsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx new file mode 100644 index 0000000000000..f62fc3a77786e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiAuthentications } from './index'; + +describe('kpi hosts - authentications', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiAuthentications(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx index c15c68d246f14..9fa38c14e2ea4 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/authentications/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiAuthenticationsQuery'; +export const ID = 'hostsKpiAuthenticationsQuery'; export interface HostsKpiAuthenticationsArgs extends Omit { @@ -165,5 +165,13 @@ export const useHostsKpiAuthentications = ({ }; }, [hostsKpiAuthenticationsRequest, hostsKpiAuthenticationsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiAuthenticationsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx new file mode 100644 index 0000000000000..f12b92f0661bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiHosts } from './index'; + +describe('kpi hosts - hosts', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiHosts(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx index fdce4dfe79591..63f0476c2b631 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/hosts/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiHostsQuery'; +export const ID = 'hostsKpiHostsQuery'; export interface HostsKpiHostsArgs extends Omit { id: string; @@ -155,5 +155,13 @@ export const useHostsKpiHosts = ({ }; }, [hostsKpiHostsRequest, hostsKpiHostsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiHostsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx deleted file mode 100644 index 8473d3971c66f..0000000000000 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './authentications'; -export * from './hosts'; -export * from './unique_ips'; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx new file mode 100644 index 0000000000000..ec8c73ad1d6a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useHostsKpiUniqueIps } from './index'; + +describe('kpi hosts - Unique Ips', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostsKpiUniqueIps(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx index 5b9eeb2710ff3..25a9f76daf40f 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/unique_ips/index.tsx @@ -26,7 +26,7 @@ import * as i18n from './translations'; import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'hostsKpiUniqueIpsQuery'; +export const ID = 'hostsKpiUniqueIpsQuery'; export interface HostsKpiUniqueIpsArgs extends Omit { @@ -163,5 +163,13 @@ export const useHostsKpiUniqueIps = ({ }; }, [hostsKpiUniqueIpsRequest, hostsKpiUniqueIpsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, hostsKpiUniqueIpsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx new file mode 100644 index 0000000000000..e334465fdbc1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useUncommonProcesses } from './index'; +import { HostsType } from '../../store/model'; + +describe('useUncommonProcesses', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: HostsType.page, + skip: false, + }; + const { rerender } = renderHook(() => useUncommonProcesses(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index 9548027520bd1..d196c4ea01af1 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -34,7 +34,7 @@ import { InspectResponse } from '../../../types'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'hostsUncommonProcessesQuery'; +export const ID = 'hostsUncommonProcessesQuery'; export interface UncommonProcessesArgs { id: string; @@ -202,5 +202,13 @@ export const useUncommonProcesses = ({ }; }, [uncommonProcessesRequest, uncommonProcessesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, uncommonProcessesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx new file mode 100644 index 0000000000000..9d31b477a851a --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from '../../containers/authentications'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { AuthenticationsQueryTabBody } from './authentications_query_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../containers/authentications'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Authentications query tab body', () => { + const mockUseAuthentications = useAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAuthentications.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx index 879f0fce02fd5..1096085b93016 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/authentications_query_tab_body.tsx @@ -6,7 +6,7 @@ */ import { getOr } from 'lodash/fp'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { AuthenticationTable } from '../../components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; import { useAuthentications } from '../../containers/authentications'; @@ -22,6 +22,7 @@ import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { authenticationLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/authentication'; import { LensAttributes } from '../../../common/components/visualization_actions/types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -76,6 +77,11 @@ const AuthenticationsQueryTabBodyComponent: React.FC startDate, type, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -84,7 +90,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -119,6 +125,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC loading={loading} loadPage={loadPage} refetch={refetch} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} setQuery={setQuery} totalCount={totalCount} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx new file mode 100644 index 0000000000000..8b3a05cc3d88c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useHostRiskScore, useHostRiskScoreKpi } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { HostRiskScoreQueryTabBody } from './host_risk_score_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Host risk score query tab body', () => { + const mockUseHostRiskScore = useHostRiskScore as jest.Mock; + const mockUseHostRiskScoreKpi = useHostRiskScoreKpi as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseHostRiskScoreKpi.mockReturnValue({ + loading: false, + severityCount: { + unknown: 12, + low: 12, + moderate: 12, + high: 12, + critical: 12, + }, + }); + mockUseHostRiskScore.mockReturnValue([ + false, + { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseHostRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseHostRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseHostRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseHostRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx index 11a422fa0cd3d..11ba8d154cd81 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_score_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { HostsComponentsQueryProps } from './types'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -18,6 +18,7 @@ import { useHostRiskScore, useHostRiskScoreKpi, } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable); @@ -43,15 +44,22 @@ export const HostRiskScoreQueryTabBody = ({ [activePage, limit] ); + const { toggleStatus } = useQueryToggle(HostRiskScoreQueryId.HOSTS_BY_RISK); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useHostRiskScore({ filterQuery, - skip, + skip: querySkip, pagination, sort, }); const { severityCount, loading: isKpiLoading } = useHostRiskScoreKpi({ filterQuery, + skip: querySkip, }); return ( @@ -65,6 +73,7 @@ export const HostRiskScoreQueryTabBody = ({ loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} severityCount={severityCount} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx new file mode 100644 index 0000000000000..487934f30e8d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAllHost } from '../../containers/hosts'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { HostsQueryTabBody } from './hosts_query_tab_body'; +import { HostsType } from '../../store/model'; + +jest.mock('../../containers/hosts'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('Hosts query tab body', () => { + const mockUseAllHost = useAllHost as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAllHost.mockReturnValue([ + false, + { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAllHost.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAllHost.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx index cc43cfed4619d..b72e6572849d1 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/hosts_query_tab_body.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useAllHost } from '../../containers/hosts'; +import React, { useEffect, useState } from 'react'; +import { useAllHost, ID } from '../../containers/hosts'; import { HostsComponentsQueryProps } from './types'; import { HostsTable } from '../../components/hosts_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HostsTableManage = manageQuery(HostsTable); @@ -25,8 +26,21 @@ export const HostsQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { hosts, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }] = - useAllHost({ docValueFields, endDate, filterQuery, indexNames, skip, startDate, type }); + useAllHost({ + docValueFields, + endDate, + filterQuery, + indexNames, + skip: querySkip, + startDate, + type, + }); return ( { + const mockUseUncommonProcesses = useUncommonProcesses as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: HostsType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUncommonProcesses.mockReturnValue([ + false, + { + uncommonProcesses: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseUncommonProcesses.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseUncommonProcesses.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx index 236b732a5af05..f6957fedd83c5 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useUncommonProcesses } from '../../containers/uncommon_processes'; +import React, { useEffect, useState } from 'react'; +import { useUncommonProcesses, ID } from '../../containers/uncommon_processes'; import { HostsComponentsQueryProps } from './types'; import { UncommonProcessTable } from '../../components/uncommon_process_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UncommonProcessTableManage = manageQuery(UncommonProcessTable); @@ -25,6 +26,11 @@ export const UncommonProcessQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { uncommonProcesses, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -33,7 +39,7 @@ export const UncommonProcessQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ export const UncommonProcessQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap index 8835a3ac390f3..966512170c156 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/__snapshots__/embeddable.test.tsx.snap @@ -3,6 +3,7 @@ exports[`Embeddable it renders 1`] = `
(({ children }) => ( -
+
{children} diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx index 4b8a5b6dd9940..2166d6b495e75 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.test.tsx @@ -109,7 +109,7 @@ describe('EmbeddedMapComponent', () => { beforeEach(() => { setQuery.mockClear(); - mockGetStorage.mockReturnValue(false); + mockGetStorage.mockReturnValue(true); }); afterEach(() => { @@ -190,36 +190,40 @@ describe('EmbeddedMapComponent', () => { }); test('map hidden on close', async () => { + mockGetStorage.mockReturnValue(false); const wrapper = mount( ); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false); + const container = wrapper.find('[data-test-subj="false-toggle-network-map"]').at(0); container.simulate('click'); await waitFor(() => { wrapper.update(); expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', true); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true); }); }); test('map visible on open', async () => { - mockGetStorage.mockReturnValue(true); - const wrapper = mount( ); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(true); const container = wrapper.find('[data-test-subj="true-toggle-network-map"]').at(0); container.simulate('click'); await waitFor(() => { wrapper.update(); expect(mockSetStorage).toHaveBeenNthCalledWith(1, 'network_map_visbile', false); + expect(wrapper.find('[data-test-subj="siemEmbeddable"]').first().exists()).toEqual(false); }); }); }); diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index 803688bf21343..083f858dc7742 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -245,6 +245,29 @@ export const EmbeddedMapComponent = ({ [storage] ); + const content = useMemo(() => { + if (!storageValue) { + return null; + } + return ( + + + + + + + {isIndexError ? ( + + ) : embeddable != null ? ( + + ) : ( + + )} + + + ); + }, [embeddable, isIndexError, portalNode, services, storageValue]); + return isError ? null : ( - - - - - - - {isIndexError ? ( - - ) : embeddable != null ? ( - - ) : ( - - )} - - + {content} ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx new file mode 100644 index 0000000000000..d5dee1b84f8d7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiDns } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/dns'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('DNS KPI', () => { + const mockUseNetworkKpiDns = useNetworkKpiDns as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiDns.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiDns.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiDns.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx index 6291e7fd4dc12..94e81c2d80d4a 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/dns/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiDnsQueriesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_dns_queries'; +import { useNetworkKpiDns, ID } from '../../../containers/kpi_network/dns'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiDns } from '../../../containers/kpi_network/dns'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -38,12 +39,17 @@ const NetworkKpiDnsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiDns({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -58,6 +64,7 @@ const NetworkKpiDnsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts index 6f35c4dead250..f5ed1ebde6992 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/mock.ts @@ -227,7 +227,9 @@ export const mockEnableChartsData = { ], from: '2019-06-15T06:00:00.000Z', id: 'statItem', + loading: false, statKey: 'UniqueIps', + setQuerySkip: jest.fn(), to: '2019-06-18T06:00:00.000Z', narrowDateRange: mockNarrowDateRange, areaChartLensAttributes: kpiUniquePrivateIpsAreaLensAttributes, diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx new file mode 100644 index 0000000000000..87f1a173740f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiNetworkEvents } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/network_events'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Network Events KPI', () => { + const mockUseNetworkKpiNetworkEvents = useNetworkKpiNetworkEvents as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiNetworkEvents.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiNetworkEvents.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiNetworkEvents.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx index ad2487b65f1de..52aa98a117afa 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/network_events/index.tsx @@ -5,16 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; import { StatItems } from '../../../../common/components/stat_items'; -import { useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; - +import { ID, useNetworkKpiNetworkEvents } from '../../../containers/kpi_network/network_events'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; import { kpiNetworkEventsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_network_events'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -43,12 +43,17 @@ const NetworkKpiNetworkEventsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiNetworkEvents({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -63,6 +68,7 @@ const NetworkKpiNetworkEventsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx new file mode 100644 index 0000000000000..28bf73eb6b2d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiTlsHandshakes } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/tls_handshakes'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('TLS Handshakes KPI', () => { + const mockUseNetworkKpiTlsHandshakes = useNetworkKpiTlsHandshakes as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiTlsHandshakes.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiTlsHandshakes.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiTlsHandshakes.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx index 0bdbd0a23d9f1..c25a4cd140108 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/tls_handshakes/index.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiTlsHandshakesLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_tls_handshakes'; +import { useNetworkKpiTlsHandshakes, ID } from '../../../containers/kpi_network/tls_handshakes'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiTlsHandshakes } from '../../../containers/kpi_network/tls_handshakes'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -37,12 +38,17 @@ const NetworkKpiTlsHandshakesComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiTlsHandshakes({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -57,6 +63,7 @@ const NetworkKpiTlsHandshakesComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx new file mode 100644 index 0000000000000..c1a28bdc28692 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiUniqueFlows } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/unique_flows'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique Flows KPI', () => { + const mockUseNetworkKpiUniqueFlows = useNetworkKpiUniqueFlows as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiUniqueFlows.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiUniqueFlows.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiUniqueFlows.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx index 5c3624130b36f..d6874818ab901 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_flows/index.tsx @@ -5,14 +5,15 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { StatItems } from '../../../../common/components/stat_items'; import { kpiUniqueFlowIdsLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_flow_ids'; +import { useNetworkKpiUniqueFlows, ID } from '../../../containers/kpi_network/unique_flows'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; -import { useNetworkKpiUniqueFlows } from '../../../containers/kpi_network/unique_flows'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; export const fieldsMapping: Readonly = [ { @@ -37,12 +38,17 @@ const NetworkKpiUniqueFlowsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiUniqueFlows({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -57,6 +63,7 @@ const NetworkKpiUniqueFlowsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx new file mode 100644 index 0000000000000..25807f3dc2cad --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.test.tsx @@ -0,0 +1,66 @@ +/* + * 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 { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/unique_private_ips'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { NetworkKpiUniquePrivateIps } from './index'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../containers/kpi_network/unique_private_ips'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Unique Private IPs KPI', () => { + const mockUseNetworkKpiUniquePrivateIps = useNetworkKpiUniquePrivateIps as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseNetworkKpiUniquePrivateIps.mockReturnValue([ + false, + { + id: '123', + inspect: { + dsl: [], + response: [], + }, + refetch: jest.fn(), + }, + ]); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseNetworkKpiUniquePrivateIps.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseNetworkKpiUniquePrivateIps.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx index e546deb7019e8..91791d09f8113 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/unique_private_ips/index.tsx @@ -5,11 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { euiPaletteColorBlind } from '@elastic/eui'; import { StatItems } from '../../../../common/components/stat_items'; -import { useNetworkKpiUniquePrivateIps } from '../../../containers/kpi_network/unique_private_ips'; +import { + useNetworkKpiUniquePrivateIps, + ID, +} from '../../../containers/kpi_network/unique_private_ips'; import { NetworkKpiProps } from '../types'; import * as i18n from './translations'; import { kpiUniquePrivateIpsSourceMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_source_metric'; @@ -17,6 +20,7 @@ import { kpiUniquePrivateIpsDestinationMetricLensAttributes } from '../../../../ import { kpiUniquePrivateIpsAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_area'; import { kpiUniquePrivateIpsBarLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/network/kpi_unique_private_ips_bar'; import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/common'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis2 = euiVisColorPalette[2]; @@ -62,12 +66,17 @@ const NetworkKpiUniquePrivateIpsComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { refetch, id, inspect, ...data }] = useNetworkKpiUniquePrivateIps({ filterQuery, endDate: to, indexNames, startDate: from, - skip, + skip: querySkip, }); return ( @@ -82,6 +91,7 @@ const NetworkKpiUniquePrivateIpsComponent: React.FC = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap index 0119859d37672..c43df33721bf1 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/__snapshots__/index.test.tsx.snap @@ -141,6 +141,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={80} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx index fc28067866146..2757baef2c1f4 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx @@ -34,6 +34,19 @@ describe('NetworkTopNFlow Table Component', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'dns', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; + beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -42,17 +55,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table', () => { const wrapper = shallow( - + ); @@ -64,17 +67,7 @@ describe('NetworkTopNFlow Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx index 016a40f7e2a17..a87908d27e63d 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.tsx @@ -32,6 +32,7 @@ interface NetworkDnsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -56,6 +57,7 @@ const NetworkDnsTableComponent: React.FC = ({ loading, loadPage, showMorePagesIndicator, + setQuerySkip, totalCount, type, }) => { @@ -153,6 +155,7 @@ const NetworkDnsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap index c5df0f6603fbf..c26c85d311959 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/__snapshots__/index.test.tsx.snap @@ -95,6 +95,7 @@ exports[`NetworkHttp Table Component rendering it renders the default NetworkHtt isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={false} totalCount={4} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx index 2a85b31791f5a..e8bac5e54765c 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx @@ -31,6 +31,18 @@ jest.mock('../../../common/components/link_to'); describe('NetworkHttp Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + id: 'http', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -44,17 +56,7 @@ describe('NetworkHttp Table Component', () => { test('it renders the default NetworkHttp table', () => { const wrapper = shallow( - + ); @@ -66,17 +68,7 @@ describe('NetworkHttp Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx index 2f0c4a105606c..5bdfd45951292 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.tsx @@ -23,6 +23,7 @@ interface NetworkHttpTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -46,6 +47,7 @@ const NetworkHttpTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -123,6 +125,7 @@ const NetworkHttpTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap index ecf7d2d0cd16f..cd13be9cef38b 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/__snapshots__/index.test.tsx.snap @@ -151,6 +151,7 @@ exports[`NetworkTopCountries Table Component rendering it renders the IP Details isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="details" @@ -308,6 +309,7 @@ exports[`NetworkTopCountries Table Component rendering it renders the default Ne isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx index a0727fad65f18..12dc41961bdf5 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx @@ -33,6 +33,24 @@ describe('NetworkTopCountries Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; const mount = useMountAppended(); + const defaultProps = { + data: mockData.NetworkTopCountries.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.NetworkTopCountries.pageInfo), + flowTargeted: FlowTargetSourceDest.source, + id: 'topCountriesSource', + indexPattern: mockIndexPattern, + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr( + false, + 'showMorePagesIndicator', + mockData.NetworkTopCountries.pageInfo + ), + totalCount: mockData.NetworkTopCountries.totalCount, + type: networkModel.NetworkType.page, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -45,23 +63,7 @@ describe('NetworkTopCountries Table Component', () => { test('it renders the default NetworkTopCountries table', () => { const wrapper = shallow( - + ); @@ -70,23 +72,7 @@ describe('NetworkTopCountries Table Component', () => { test('it renders the IP Details NetworkTopCountries table', () => { const wrapper = shallow( - + ); @@ -98,23 +84,7 @@ describe('NetworkTopCountries Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.page.queries.topCountriesSource.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx index 80de694f89484..00c9c7d0aaf30 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.tsx @@ -35,6 +35,7 @@ interface NetworkTopCountriesTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -62,6 +63,7 @@ const NetworkTopCountriesTableComponent: React.FC isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -170,6 +172,7 @@ const NetworkTopCountriesTableComponent: React.FC loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={{ field, direction: sort.direction }} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap index 07874f9f39f0b..7909eba5b0d88 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/__snapshots__/index.test.tsx.snap @@ -99,6 +99,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="details" @@ -204,6 +205,7 @@ exports[`NetworkTopNFlow Table Component rendering it renders the default Networ isInspect={false} loadPage={[MockFunction]} loading={false} + setQuerySkip={[MockFunction]} showMorePagesIndicator={true} totalCount={524} type="page" diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx index e2b9447b58806..b5df028f4d7a4 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx @@ -35,6 +35,19 @@ describe('NetworkTopNFlow Table Component', () => { const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); + const defaultProps = { + data: mockData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockData.pageInfo), + flowTargeted: FlowTargetSourceDest.source, + id: 'topNFlowSource', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockData.pageInfo), + totalCount: mockData.totalCount, + type: networkModel.NetworkType.page, + }; beforeEach(() => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); @@ -44,18 +57,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table on the Network page', () => { const wrapper = shallow( - + ); @@ -65,18 +67,7 @@ describe('NetworkTopNFlow Table Component', () => { test('it renders the default NetworkTopNFlow table on the IP Details page', () => { const wrapper = shallow( - + ); @@ -88,18 +79,7 @@ describe('NetworkTopNFlow Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.page.queries.topNFlowSource.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx index a612d3e4e1093..12895226a82eb 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.tsx @@ -31,6 +31,7 @@ interface NetworkTopNFlowTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -57,6 +58,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -166,6 +168,7 @@ const NetworkTopNFlowTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={showMorePagesIndicator} sorting={sorting} totalCount={fakeTotalCount} diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx index 3a1a5efef6b89..a54b219985817 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx @@ -29,7 +29,18 @@ jest.mock('../../../common/lib/kibana'); describe('Tls Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; - + const defaultProps = { + data: mockTlsData.edges, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockTlsData.pageInfo), + id: 'tls', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockTlsData.pageInfo), + totalCount: 1, + type: networkModel.NetworkType.details, + }; const { storage } = createSecuritySolutionStorageMock(); let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); const mount = useMountAppended(); @@ -42,17 +53,7 @@ describe('Tls Table Component', () => { test('it renders the default Domains table', () => { const wrapper = shallow( - + ); @@ -64,17 +65,7 @@ describe('Tls Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.details.queries?.tls.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx index 34a218db39fac..60079e50f27ce 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.tsx @@ -33,6 +33,7 @@ interface TlsTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -58,6 +59,7 @@ const TlsTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -135,6 +137,7 @@ const TlsTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} sorting={getSortField(sort)} totalCount={fakeTotalCount} updateActivePage={updateActivePage} diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx index 3861433b4dcb0..95e014332d42a 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx @@ -40,22 +40,25 @@ describe('Users Table Component', () => { store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); + const defaultProps = { + data: mockUsersData.edges, + flowTarget: FlowTarget.source, + fakeTotalCount: getOr(50, 'fakeTotalCount', mockUsersData.pageInfo), + id: 'user', + isInspect: false, + loading: false, + loadPage, + setQuerySkip: jest.fn(), + showMorePagesIndicator: getOr(false, 'showMorePagesIndicator', mockUsersData.pageInfo), + totalCount: 1, + type: networkModel.NetworkType.details, + }; + describe('Rendering', () => { test('it renders the default Users table', () => { const wrapper = shallow( - + ); @@ -67,18 +70,7 @@ describe('Users Table Component', () => { test('when you click on the column header, you should show the sorting icon', () => { const wrapper = mount( - + ); expect(store.getState().network.details.queries?.users.sort).toEqual({ diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx index 66c36208fd98a..efbe5b7d1d010 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.tsx @@ -38,6 +38,7 @@ interface UsersTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; showMorePagesIndicator: boolean; totalCount: number; type: networkModel.NetworkType; @@ -64,6 +65,7 @@ const UsersTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, showMorePagesIndicator, totalCount, type, @@ -141,6 +143,7 @@ const UsersTableComponent: React.FC = ({ loadPage={loadPage} onChange={onChange} pageOfItems={data} + setQuerySkip={setQuerySkip} sorting={getSortField(sort)} totalCount={fakeTotalCount} updateActivePage={updateActivePage} diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx new file mode 100644 index 0000000000000..44b8472a0606c --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiDns } from './index'; + +describe('kpi network - dns', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiDns(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx index 63fb751572b0b..89f58f547bd75 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/dns/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiDnsQuery'; +export const ID = 'networkKpiDnsQuery'; export interface NetworkKpiDnsArgs { dnsQueries: number; @@ -160,5 +160,13 @@ export const useNetworkKpiDns = ({ }; }, [networkKpiDnsRequest, networkKpiDnsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiDnsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx deleted file mode 100644 index 550cefcf13e92..0000000000000 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from './dns'; -export * from './network_events'; -export * from './tls_handshakes'; -export * from './unique_flows'; -export * from './unique_private_ips'; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx new file mode 100644 index 0000000000000..4171a86fae9cc --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiNetworkEvents } from './index'; + +describe('kpi network - network events', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiNetworkEvents(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx index 4ecf455a31724..51a5367446b6e 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/network_events/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiNetworkEventsQuery'; +export const ID = 'networkKpiNetworkEventsQuery'; export interface NetworkKpiNetworkEventsArgs { networkEvents: number; @@ -163,5 +163,13 @@ export const useNetworkKpiNetworkEvents = ({ }; }, [networkKpiNetworkEventsRequest, networkKpiNetworkEventsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiNetworkEventsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx new file mode 100644 index 0000000000000..bad0e6ad71512 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiTlsHandshakes } from './index'; + +describe('kpi network - tls handshakes', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiTlsHandshakes(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx index 2dbf909334b15..ba42d79ad0eed 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/tls_handshakes/index.tsx @@ -30,7 +30,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiTlsHandshakesQuery'; +export const ID = 'networkKpiTlsHandshakesQuery'; export interface NetworkKpiTlsHandshakesArgs { tlsHandshakes: number; @@ -163,5 +163,13 @@ export const useNetworkKpiTlsHandshakes = ({ }; }, [networkKpiTlsHandshakesRequest, networkKpiTlsHandshakesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiTlsHandshakesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx new file mode 100644 index 0000000000000..83cb2a40aabce --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiUniqueFlows } from './index'; + +describe('kpi network - unique flows', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiUniqueFlows(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx index 612aac175fd9a..130efc8d755a6 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_flows/index.tsx @@ -29,7 +29,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiUniqueFlowsQuery'; +export const ID = 'networkKpiUniqueFlowsQuery'; export interface NetworkKpiUniqueFlowsArgs { uniqueFlowId: number; @@ -84,7 +84,6 @@ export const useNetworkKpiUniqueFlows = ({ const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - searchSubscription$.current = data.search .search( request, @@ -155,5 +154,13 @@ export const useNetworkKpiUniqueFlows = ({ }; }, [networkKpiUniqueFlowsRequest, networkKpiUniqueFlowsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiUniqueFlowsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx new file mode 100644 index 0000000000000..370c4e671e886 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../../common/mock'; +import { useNetworkKpiUniquePrivateIps } from './index'; + +describe('kpi network - unique private ips', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkKpiUniquePrivateIps(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx index 42a8e30a8f906..b68c4fcb698c0 100644 --- a/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/kpi_network/unique_private_ips/index.tsx @@ -31,7 +31,7 @@ import { import { getInspectResponse } from '../../../../helpers'; import { InspectResponse } from '../../../../types'; -const ID = 'networkKpiUniquePrivateIpsQuery'; +export const ID = 'networkKpiUniquePrivateIpsQuery'; export interface NetworkKpiUniquePrivateIpsArgs { uniqueDestinationPrivateIps: number; @@ -175,5 +175,13 @@ export const useNetworkKpiUniquePrivateIps = ({ }; }, [networkKpiUniquePrivateIpsRequest, networkKpiUniquePrivateIpsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkKpiUniquePrivateIpsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx new file mode 100644 index 0000000000000..f303cdf85a5f8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkDns } from './index'; +import { NetworkType } from '../../store/model'; + +describe('useNetworkDns', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkDns(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx index 47e60f27a7dbd..86949777dd535 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_dns/index.tsx @@ -32,7 +32,7 @@ import { getInspectResponse } from '../../../helpers'; import { InspectResponse } from '../../../types'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkDnsQuery'; +export const ID = 'networkDnsQuery'; export interface NetworkDnsArgs { id: string; @@ -207,5 +207,13 @@ export const useNetworkDns = ({ }; }, [networkDnsRequest, networkDnsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkDnsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx new file mode 100644 index 0000000000000..b687896efcea4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkHttp } from './index'; +import { NetworkType } from '../../store/model'; + +describe('useNetworkHttp', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkHttp(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx index 98105f5cac25a..eba2b22f30e29 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_http/index.tsx @@ -31,7 +31,7 @@ import { InspectResponse } from '../../../types'; import { getInspectResponse } from '../../../helpers'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkHttpQuery'; +export const ID = 'networkHttpQuery'; export interface NetworkHttpArgs { id: string; @@ -94,7 +94,7 @@ export const useNetworkHttp = ({ const [networkHttpResponse, setNetworkHttpResponse] = useState({ networkHttp: [], - id: ID, + id, inspect: { dsl: [], response: [], @@ -116,11 +116,9 @@ export const useNetworkHttp = ({ if (request == null || skip) { return; } - const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); - searchSubscription$.current = data.search .search(request, { strategy: 'securitySolutionSearchStrategy', @@ -193,5 +191,13 @@ export const useNetworkHttp = ({ }; }, [networkHttpRequest, networkHttpSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkHttpResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx new file mode 100644 index 0000000000000..fe7507c85567a --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.test.tsx @@ -0,0 +1,33 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTopCountries } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTopCountries', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTopCountries(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index f64ee85ab7cf0..6110e84804fe3 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -32,7 +32,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTopCountriesQuery'; +export const ID = 'networkTopCountriesQuery'; export interface NetworkTopCountriesArgs { id: string; @@ -218,5 +218,13 @@ export const useNetworkTopCountries = ({ }; }, [networkTopCountriesRequest, networkTopCountriesSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTopCountriesResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx new file mode 100644 index 0000000000000..c31dec3ce0aed --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.test.tsx @@ -0,0 +1,33 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTopNFlow } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTopNFlow', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTopNFlow(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 0b4c164782f3d..022b76c315c17 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -32,7 +32,7 @@ import * as i18n from './translations'; import { useTransforms } from '../../../transforms/containers/use_transforms'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTopNFlowQuery'; +export const ID = 'networkTopNFlowQuery'; export interface NetworkTopNFlowArgs { id: string; @@ -215,5 +215,13 @@ export const useNetworkTopNFlow = ({ }; }, [networkTopNFlowRequest, networkTopNFlowSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTopNFlowResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx new file mode 100644 index 0000000000000..6b236d4ddfb20 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkTls } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +describe('useNetworkTls', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + flowTarget: FlowTargetSourceDest.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + ip: '1.1.1.1', + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkTls(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx index 754f0cac8868c..ed771455446c0 100644 --- a/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/tls/index.tsx @@ -29,7 +29,7 @@ import { getInspectResponse } from '../../../helpers'; import { FlowTargetSourceDest, PageInfoPaginated } from '../../../../common/search_strategy'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkTlsQuery'; +export const ID = 'networkTlsQuery'; export interface NetworkTlsArgs { id: string; @@ -196,5 +196,13 @@ export const useNetworkTls = ({ }; }, [networkTlsRequest, networkTlsSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkTlsResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx new file mode 100644 index 0000000000000..4a6c1fac4191c --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkUsers } from './index'; +import { NetworkType } from '../../store/model'; +import { FlowTarget } from '../../../../common/search_strategy'; + +describe('useNetworkUsers', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + docValueFields: [], + ip: '1.1.1.1', + flowTarget: FlowTarget.source, + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + type: NetworkType.page, + skip: false, + }; + const { rerender } = renderHook(() => useNetworkUsers(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx index d4be09f97591d..9ad2c59f6bb79 100644 --- a/x-pack/plugins/security_solution/public/network/containers/users/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/users/index.tsx @@ -31,7 +31,7 @@ import { InspectResponse } from '../../../types'; import { PageInfoPaginated } from '../../../../common/search_strategy'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -const ID = 'networkUsersQuery'; +export const ID = 'networkUsersQuery'; export interface NetworkUsersArgs { id: string; @@ -195,5 +195,13 @@ export const useNetworkUsers = ({ }; }, [networkUsersRequest, networkUsersSearch]); + useEffect(() => { + if (skip) { + setLoading(false); + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, networkUsersResponse]; }; diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx index 4a4004b9a5f0c..d615bd8264b4b 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_http_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { OwnProps } from './types'; -import { useNetworkHttp } from '../../containers/network_http'; +import { useNetworkHttp, ID } from '../../containers/network_http'; import { NetworkHttpTable } from '../../components/network_http_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -24,6 +25,11 @@ export const NetworkHttpQueryTable = ({ startDate, type, }: OwnProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, @@ -32,7 +38,7 @@ export const NetworkHttpQueryTable = ({ filterQuery, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -48,6 +54,7 @@ export const NetworkHttpQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx index 742f0f6ff9a9d..4243635ebb218 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_countries_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; -import { useNetworkTopCountries } from '../../containers/network_top_countries'; +import { useNetworkTopCountries, ID } from '../../containers/network_top_countries'; import { NetworkTopCountriesTable } from '../../components/network_top_countries_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopCountriesTableManage = manageQuery(NetworkTopCountriesTable); @@ -26,6 +27,11 @@ export const NetworkTopCountriesQueryTable = ({ type, indexPattern, }: NetworkWithIndexComponentsQueryTableProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopCountries, pageInfo, refetch, totalCount }, @@ -35,7 +41,7 @@ export const NetworkTopCountriesQueryTable = ({ filterQuery, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -53,6 +59,7 @@ export const NetworkTopCountriesQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx index 374dd6e6564e3..3df5397600c12 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/network_top_n_flow_query_table.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow, ID } from '../../containers/network_top_n_flow'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -25,6 +26,11 @@ export const NetworkTopNFlowQueryTable = ({ startDate, type, }: NetworkWithIndexComponentsQueryTableProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, @@ -34,7 +40,7 @@ export const NetworkTopNFlowQueryTable = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -51,6 +57,7 @@ export const NetworkTopNFlowQueryTable = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx index d3da639c8cf98..f4539e1ffc63d 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/tls_query_table.tsx @@ -6,11 +6,12 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { TlsTable } from '../../components/tls_table'; -import { useNetworkTls } from '../../containers/tls'; +import { ID, useNetworkTls } from '../../containers/tls'; import { TlsQueryTableComponentProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const TlsTableManage = manageQuery(TlsTable); @@ -25,6 +26,11 @@ export const TlsQueryTable = ({ startDate, type, }: TlsQueryTableComponentProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }] = useNetworkTls({ endDate, @@ -32,7 +38,7 @@ export const TlsQueryTable = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ export const TlsQueryTable = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx index a73835985d7c5..9eb27c399ffbf 100644 --- a/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/details/users_query_table.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { useNetworkUsers } from '../../containers/users'; +import { useNetworkUsers, ID } from '../../containers/users'; import { NetworkComponentsQueryProps } from './types'; import { UsersTable } from '../../components/users_table'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UsersTableManage = manageQuery(UsersTable); @@ -24,6 +25,11 @@ export const UsersQueryTable = ({ startDate, type, }: NetworkComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, networkUsers, totalCount, pageInfo, loadPage, refetch }, @@ -32,7 +38,7 @@ export const UsersQueryTable = ({ filterQuery, flowTarget, ip, - skip, + skip: querySkip, startDate, }); @@ -49,6 +55,7 @@ export const UsersQueryTable = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx index e4bb00d1cb632..b390ccdcfff82 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/countries_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopCountriesTable } from '../../components/network_top_countries_table'; -import { useNetworkTopCountries } from '../../containers/network_top_countries'; +import { useNetworkTopCountries, ID } from '../../containers/network_top_countries'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { IPsQueryTabBodyProps as CountriesQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopCountriesTableManage = manageQuery(NetworkTopCountriesTable); @@ -27,6 +28,11 @@ export const CountriesQueryTabBody = ({ indexPattern, flowTarget, }: CountriesQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopCountries, pageInfo, refetch, totalCount }, @@ -35,7 +41,7 @@ export const CountriesQueryTabBody = ({ flowTarget, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -53,6 +59,7 @@ export const CountriesQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx index 21404690438a0..0ad309522a3e5 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/dns_query_tab_body.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import React, { useEffect, useCallback, useMemo } from 'react'; +import React, { useEffect, useCallback, useMemo, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkDnsTable } from '../../components/network_dns_table'; -import { useNetworkDns } from '../../containers/network_dns'; +import { useNetworkDns, ID } from '../../containers/network_dns'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkComponentQueryProps } from './types'; @@ -24,6 +24,7 @@ import { MatrixHistogramType } from '../../../../common/search_strategy/security import { networkSelectors } from '../../store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { dnsTopDomainsLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/network/dns_top_domains'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const HISTOGRAM_ID = 'networkDnsHistogramQuery'; @@ -72,6 +73,11 @@ const DnsQueryTabBodyComponent: React.FC = ({ }; }, [deleteQuery]); + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { totalCount, networkDns, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -80,7 +86,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type, }); @@ -122,6 +128,7 @@ const DnsQueryTabBodyComponent: React.FC = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={type} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx index bf9b0079650b2..98570a2f2f740 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/http_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkHttpTable } from '../../components/network_http_table'; -import { useNetworkHttp } from '../../containers/network_http'; +import { ID, useNetworkHttp } from '../../containers/network_http'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { HttpQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkHttpTableManage = manageQuery(NetworkHttpTable); @@ -25,6 +26,11 @@ export const HttpQueryTabBody = ({ startDate, setQuery, }: HttpQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkHttp, pageInfo, refetch, totalCount }, @@ -32,7 +38,7 @@ export const HttpQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -48,6 +54,7 @@ export const HttpQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx index aa21fe6066415..a497a35fe3551 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; +import { ID, useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; import { IPsQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -26,6 +27,11 @@ export const IPsQueryTabBody = ({ setQuery, flowTarget, }: IPsQueryTabBodyProps) => { + const { toggleStatus } = useQueryToggle(`${ID}-${flowTarget}`); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, @@ -34,7 +40,7 @@ export const IPsQueryTabBody = ({ flowTarget, filterQuery, indexNames, - skip, + skip: querySkip, startDate, type: networkModel.NetworkType.page, }); @@ -51,6 +57,7 @@ export const IPsQueryTabBody = ({ loadPage={loadPage} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} totalCount={totalCount} type={networkModel.NetworkType.page} diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx index 58c6f755b9175..c06a26f5d9192 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/tls_query_tab_body.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { getOr } from 'lodash/fp'; import { manageQuery } from '../../../common/components/page/manage_query'; -import { useNetworkTls } from '../../../network/containers/tls'; +import { useNetworkTls, ID } from '../../containers/tls'; import { TlsTable } from '../../components/tls_table'; import { TlsQueryTabBodyProps } from './types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const TlsTableManage = manageQuery(TlsTable); @@ -25,6 +26,11 @@ const TlsQueryTabBodyComponent: React.FC = ({ startDate, type, }) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [loading, { id, inspect, isInspected, tls, totalCount, pageInfo, loadPage, refetch }] = useNetworkTls({ endDate, @@ -32,7 +38,7 @@ const TlsQueryTabBodyComponent: React.FC = ({ flowTarget, indexNames, ip, - skip, + skip: querySkip, startDate, type, }); @@ -49,6 +55,7 @@ const TlsQueryTabBodyComponent: React.FC = ({ showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} type={type} /> diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx index 1295693db506f..173710a7700e8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx @@ -21,9 +21,12 @@ import { import { OverviewHost } from '.'; import { createStore, State } from '../../../common/store'; import { useHostOverview } from '../../containers/overview_host'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to'); +jest.mock('../../../common/containers/query_toggle'); const startDate = '2020-01-20T20:49:57.080Z'; const endDate = '2020-01-21T20:49:57.080Z'; @@ -32,6 +35,7 @@ const testProps = { indexNames: [], setQuery: jest.fn(), startDate, + filterQuery: '', }; const MOCKED_RESPONSE = { overviewHost: { @@ -56,7 +60,7 @@ const MOCKED_RESPONSE = { jest.mock('../../containers/overview_host'); const useHostOverviewMock = useHostOverview as jest.Mock; -useHostOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('OverviewHost', () => { const state: State = mockGlobalState; @@ -65,7 +69,10 @@ describe('OverviewHost', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); const myState = cloneDeep(state); + useHostOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -103,4 +110,24 @@ describe('OverviewHost', () => { 'Showing: 16 events' ); }); + + test('toggleStatus=true, do not skip', () => { + const { queryByTestId } = render( + + + + ); + expect(useHostOverviewMock.mock.calls[0][0].skip).toEqual(false); + expect(queryByTestId('overview-hosts-stats')).toBeInTheDocument(); + }); + test('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + const { queryByTestId } = render( + + + + ); + expect(useHostOverviewMock.mock.calls[0][0].skip).toEqual(true); + expect(queryByTestId('overview-hosts-stats')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index 32585c8836cc3..1bf990b755f65 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { EuiFlexItem, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { ESQuery } from '../../../../common/typed_json'; @@ -23,6 +23,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface OwnProps { startDate: GlobalTimeArgs['from']; @@ -46,12 +47,26 @@ const OverviewHostComponent: React.FC = ({ const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { toggleStatus, setToggleStatus } = useQueryToggle(OverviewHostQueryId); + const [querySkip, setQuerySkip] = useState(filterQuery === undefined || !toggleStatus); + useEffect(() => { + setQuerySkip(filterQuery === undefined || !toggleStatus); + }, [filterQuery, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { overviewHost, id, inspect, refetch }] = useHostOverview({ endDate, filterQuery, indexNames, startDate, - skip: filterQuery === undefined, + skip: querySkip, }); const goToHost = useCallback( @@ -116,25 +131,29 @@ const OverviewHostComponent: React.FC = ({ return ( - + <>{hostPageButton} - - + {toggleStatus && ( + + )} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index dfc144be8e5bb..2293a0380f3a8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -21,6 +21,8 @@ import { OverviewNetwork } from '.'; import { createStore, State } from '../../../common/store'; import { useNetworkOverview } from '../../containers/overview_network'; import { SecurityPageName } from '../../../app/types'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; jest.mock('../../../common/components/link_to'); const mockNavigateToApp = jest.fn(); @@ -46,6 +48,7 @@ const startDate = '2020-01-20T20:49:57.080Z'; const endDate = '2020-01-21T20:49:57.080Z'; const defaultProps = { endDate, + filterQuery: '', startDate, setQuery: jest.fn(), indexNames: [], @@ -65,9 +68,10 @@ const MOCKED_RESPONSE = { }, }; +jest.mock('../../../common/containers/query_toggle'); jest.mock('../../containers/overview_network'); const useNetworkOverviewMock = useNetworkOverview as jest.Mock; -useNetworkOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); +const mockUseQueryToggle = useQueryToggle as jest.Mock; describe('OverviewNetwork', () => { const state: State = mockGlobalState; @@ -76,6 +80,9 @@ describe('OverviewNetwork', () => { let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); beforeEach(() => { + jest.clearAllMocks(); + useNetworkOverviewMock.mockReturnValue([false, MOCKED_RESPONSE]); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); const myState = cloneDeep(state); store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); @@ -143,4 +150,24 @@ describe('OverviewNetwork', () => { deepLinkId: SecurityPageName.network, }); }); + + it('toggleStatus=true, do not skip', () => { + const { queryByTestId } = render( + + + + ); + expect(useNetworkOverviewMock.mock.calls[0][0].skip).toEqual(false); + expect(queryByTestId('overview-network-stats')).toBeInTheDocument(); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + const { queryByTestId } = render( + + + + ); + expect(useNetworkOverviewMock.mock.calls[0][0].skip).toEqual(true); + expect(queryByTestId('overview-network-stats')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 7607a9eac4926..ce6c065d424d4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp'; import { EuiFlexItem, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; import { DEFAULT_NUMBER_FORMAT, APP_UI_ID } from '../../../../common/constants'; import { ESQuery } from '../../../../common/typed_json'; @@ -26,6 +26,7 @@ import { InspectButtonContainer } from '../../../common/components/inspect'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { SecurityPageName } from '../../../app/types'; import { LinkButton } from '../../../common/components/links'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; export interface OverviewNetworkProps { startDate: GlobalTimeArgs['from']; @@ -48,12 +49,26 @@ const OverviewNetworkComponent: React.FC = ({ const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { toggleStatus, setToggleStatus } = useQueryToggle(OverviewNetworkQueryId); + const [querySkip, setQuerySkip] = useState(filterQuery === undefined || !toggleStatus); + useEffect(() => { + setQuerySkip(filterQuery === undefined || !toggleStatus); + }, [filterQuery, toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + const [loading, { overviewNetwork, id, inspect, refetch }] = useNetworkOverview({ endDate, filterQuery, indexNames, startDate, - skip: filterQuery === undefined, + skip: querySkip, }); const goToNetwork = useCallback( @@ -121,26 +136,30 @@ const OverviewNetworkComponent: React.FC = ({ return ( - + <> {networkPageButton} - - + {toggleStatus && ( + + )} diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx new file mode 100644 index 0000000000000..53f07d5195c26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useHostOverview } from './index'; + +describe('useHostOverview', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useHostOverview(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx index 52b58439af0ab..b79169b1ac762 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_host/index.tsx @@ -146,5 +146,12 @@ export const useHostOverview = ({ }; }, [overviewHostRequest, overviewHostSearch]); + useEffect(() => { + if (skip) { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, overviewHostResponse]; }; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx new file mode 100644 index 0000000000000..64cc2e6bbd179 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.test.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { useNetworkOverview } from './index'; + +describe('useNetworkOverview', () => { + it('skip = true will cancel any running request', () => { + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + const localProps = { + startDate: '2020-07-07T08:20:18.966Z', + endDate: '2020-07-08T08:20:18.966Z', + indexNames: ['cool'], + skip: false, + }; + const { rerender } = renderHook(() => useNetworkOverview(localProps), { + wrapper: TestProviders, + }); + localProps.skip = true; + act(() => rerender()); + expect(abortSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx index dd98a0ff03632..c2683b74a5b1a 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_network/index.tsx @@ -147,5 +147,12 @@ export const useNetworkOverview = ({ }; }, [overviewNetworkRequest, overviewNetworkSearch]); + useEffect(() => { + if (skip) { + searchSubscription$.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, overviewNetworkResponse]; }; diff --git a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx index b04d9dd05f283..8c95a081b3e86 100644 --- a/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/risk_score/containers/all/index.tsx @@ -266,5 +266,13 @@ export const useRiskScore = { + if (skip) { + setLoading(false); + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + } + }, [skip]); + return [loading, riskScoreResponse]; }; diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx new file mode 100644 index 0000000000000..6425f40016fb9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import React from 'react'; +import { TotalUsersKpi } from './index'; +import { useSearchStrategy } from '../../../../common/containers/use_search_strategy'; + +jest.mock('../../../../common/containers/query_toggle'); +jest.mock('../../../../common/containers/use_search_strategy'); +jest.mock('../../../../hosts/components/kpi_hosts/common', () => ({ + KpiBaseComponentManage: () => , +})); + +describe('Total Users KPI', () => { + const mockUseSearchStrategy = useSearchStrategy as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + from: '2019-06-25T04:31:59.345Z', + to: '2019-06-25T06:31:59.345Z', + indexNames: [], + narrowDateRange: jest.fn(), + setQuery: jest.fn(), + skip: false, + }; + const mockSearch = jest.fn(); + beforeEach(() => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseSearchStrategy.mockReturnValue({ + result: [], + loading: false, + inspect: { + dsl: [], + response: [], + }, + search: mockSearch, + refetch: jest.fn(), + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(false); + expect(mockSearch).toHaveBeenCalled(); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseSearchStrategy.mock.calls[0][0].skip).toEqual(true); + expect(mockSearch).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx index 043c6b472497e..ffa5d851875ce 100644 --- a/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/kpi_users/total_users/index.tsx @@ -6,7 +6,7 @@ */ import { euiPaletteColorBlind } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { UsersQueries } from '../../../../../common/search_strategy/security_solution/users'; import { UpdateDateRange } from '../../../../common/components/charts/common'; @@ -17,6 +17,7 @@ import { KpiBaseComponentManage } from '../../../../hosts/components/kpi_hosts/c import { kpiTotalUsersMetricLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric'; import { kpiTotalUsersAreaLensAttributes } from '../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_area'; import * as i18n from './translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; const euiVisColorPalette = euiPaletteColorBlind(); const euiColorVis1 = euiVisColorPalette[1]; @@ -60,15 +61,21 @@ const TotalUsersKpiComponent: React.FC = ({ setQuery, skip, }) => { + const { toggleStatus } = useQueryToggle(UsersQueries.kpiTotalUsers); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const { loading, result, search, refetch, inspect } = useSearchStrategy({ factoryQueryType: UsersQueries.kpiTotalUsers, initialResult: { users: 0, usersHistogram: [] }, errorMessage: i18n.ERROR_USERS_KPI, + skip: querySkip, }); useEffect(() => { - if (!skip) { + if (!querySkip) { search({ filterQuery, defaultIndex: indexNames, @@ -79,7 +86,7 @@ const TotalUsersKpiComponent: React.FC = ({ }, }); } - }, [search, from, to, filterQuery, indexNames, skip]); + }, [search, from, to, filterQuery, indexNames, querySkip]); return ( = ({ narrowDateRange={narrowDateRange} refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} /> ); }; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx index 3faa96b436de0..c0cd2e351298e 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.test.tsx @@ -14,7 +14,7 @@ import { UsersType } from '../../store/model'; describe('UserRiskScoreTable', () => { const username = 'test_user_name'; - const defautProps = { + const defaultProps = { data: [ { '@timestamp': '1641902481', @@ -32,6 +32,7 @@ describe('UserRiskScoreTable', () => { isInspect: false, loading: false, loadPage: noop, + setQuerySkip: jest.fn(), severityCount: { Unknown: 0, Low: 0, @@ -46,7 +47,7 @@ describe('UserRiskScoreTable', () => { it('renders', () => { const { queryByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx index 9f782b7f28662..810525d4f1ca7 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx @@ -57,6 +57,7 @@ interface UserRiskScoreTableProps { isInspect: boolean; loading: boolean; loadPage: (newActivePage: number) => void; + setQuerySkip: (skip: boolean) => void; severityCount: SeverityCount; totalCount: number; type: usersModel.UsersType; @@ -74,6 +75,7 @@ const UserRiskScoreTableComponent: React.FC = ({ isInspect, loading, loadPage, + setQuerySkip, severityCount, totalCount, type, @@ -210,6 +212,7 @@ const UserRiskScoreTableComponent: React.FC = ({ loadPage={loadPage} onChange={onSort} pageOfItems={data} + setQuerySkip={setQuerySkip} showMorePagesIndicator={false} sorting={sort} split={true} diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx new file mode 100644 index 0000000000000..98b69d531c4dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useAuthentications } from '../../../hosts/containers/authentications'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { AllUsersQueryTabBody } from './all_users_query_tab_body'; +import { UsersType } from '../../store/model'; + +jest.mock('../../../hosts/containers/authentications'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('All users query tab body', () => { + const mockUseAuthentications = useAuthentications as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseAuthentications.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx index 6c494c9752c4f..8fa963ef179f2 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx @@ -6,12 +6,13 @@ */ import { getOr } from 'lodash/fp'; -import React from 'react'; -import { useAuthentications } from '../../../hosts/containers/authentications'; +import React, { useEffect, useState } from 'react'; +import { useAuthentications, ID } from '../../../hosts/containers/authentications'; import { UsersComponentsQueryProps } from './types'; import { AuthenticationTable } from '../../../hosts/components/authentications_table'; import { manageQuery } from '../../../common/components/page/manage_query'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const AuthenticationTableManage = manageQuery(AuthenticationTable); @@ -26,6 +27,11 @@ export const AllUsersQueryTabBody = ({ docValueFields, deleteQuery, }: UsersComponentsQueryProps) => { + const { toggleStatus } = useQueryToggle(ID); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); const [ loading, { authentications, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, @@ -34,7 +40,7 @@ export const AllUsersQueryTabBody = ({ endDate, filterQuery, indexNames, - skip, + skip: querySkip, startDate, // TODO Fix me // @ts-ignore @@ -55,6 +61,7 @@ export const AllUsersQueryTabBody = ({ refetch={refetch} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} setQuery={setQuery} + setQuerySkip={setQuerySkip} totalCount={totalCount} docValueFields={docValueFields} indexNames={indexNames} diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx new file mode 100644 index 0000000000000..6b5ec66f864bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { useUserRiskScore, useUserRiskScoreKpi } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { UserRiskScoreQueryTabBody } from './user_risk_score_tab_body'; +import { UsersType } from '../../store/model'; + +jest.mock('../../../risk_score/containers'); +jest.mock('../../../common/containers/query_toggle'); +jest.mock('../../../common/lib/kibana'); + +describe('All users query tab body', () => { + const mockUseUserRiskScore = useUserRiskScore as jest.Mock; + const mockUseUserRiskScoreKpi = useUserRiskScoreKpi as jest.Mock; + const mockUseQueryToggle = useQueryToggle as jest.Mock; + const defaultProps = { + indexNames: [], + setQuery: jest.fn(), + skip: false, + startDate: '2019-06-25T04:31:59.345Z', + endDate: '2019-06-25T06:31:59.345Z', + type: UsersType.page, + }; + beforeEach(() => { + jest.clearAllMocks(); + mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUserRiskScore.mockReturnValue([ + false, + { + authentications: [], + id: '123', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }, + ]); + mockUseUserRiskScoreKpi.mockReturnValue({ + loading: false, + severityCount: { + unknown: 12, + low: 12, + moderate: 12, + high: 12, + critical: 12, + }, + }); + }); + it('toggleStatus=true, do not skip', () => { + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + }); + it('toggleStatus=false, skip', () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); + render( + + + + ); + expect(mockUseUserRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseUserRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx index a19e7803cb90f..a479788ce0f41 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_score_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { UsersComponentsQueryProps } from './types'; @@ -20,6 +20,7 @@ import { useUserRiskScore, useUserRiskScoreKpi, } from '../../../risk_score/containers'; +import { useQueryToggle } from '../../../common/containers/query_toggle'; const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable); @@ -43,15 +44,22 @@ export const UserRiskScoreQueryTabBody = ({ [activePage, limit] ); + const { toggleStatus } = useQueryToggle(UserRiskScoreQueryId.USERS_BY_RISK); + const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); + useEffect(() => { + setQuerySkip(skip || !toggleStatus); + }, [skip, toggleStatus]); + const [loading, { data, totalCount, inspect, isInspected, refetch }] = useUserRiskScore({ filterQuery, - skip, + skip: querySkip, pagination, sort, }); const { severityCount, loading: isKpiLoading } = useUserRiskScoreKpi({ filterQuery, + skip: querySkip, }); return ( @@ -65,6 +73,7 @@ export const UserRiskScoreQueryTabBody = ({ loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store refetch={refetch} setQuery={setQuery} + setQuerySkip={setQuerySkip} severityCount={severityCount} totalCount={totalCount} type={type} From 7aa89aac3bb5f2ba972aa4349259c53845becdbb Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 23 Mar 2022 11:52:52 -0700 Subject: [PATCH 089/132] Fix typos in dev docs (#128400) --- dev_docs/contributing/standards.mdx | 2 +- dev_docs/key_concepts/kibana_platform_plugin_intro.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev_docs/contributing/standards.mdx b/dev_docs/contributing/standards.mdx index d2f31f3a4faa2..cef9199aee924 100644 --- a/dev_docs/contributing/standards.mdx +++ b/dev_docs/contributing/standards.mdx @@ -69,7 +69,7 @@ Every team should be collecting telemetry metrics on it’s public API usage. Th ### APM -Kibana server and client are instrumented with APM node and APM RUM clients respectively, tracking serveral types of transactions by default, such as `page-load`, `request`, etc. +Kibana server and client are instrumented with APM node and APM RUM clients respectively, tracking several types of transactions by default, such as `page-load`, `request`, etc. You may introduce custom transactions. Please refer to the [APM documentation](https://www.elastic.co/guide/en/apm/get-started/current/index.html) and follow these guidelines when doing so: - Use dashed syntax for transaction types and names: `my-transaction-type` and `my-transaction-name` diff --git a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx index 195e5c1f6f211..417d6e4983d4f 100644 --- a/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx +++ b/dev_docs/key_concepts/kibana_platform_plugin_intro.mdx @@ -153,7 +153,7 @@ plugins to customize the Kibana experience. Examples of extension points are: - core.overlays.showModal - embeddables.registerEmbeddableFactory - uiActions.registerAction -- core.saedObjects.registerType +- core.savedObjects.registerType ## Follow up material From 55e42cec93228a447b624c2b0001696712257efe Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Wed, 23 Mar 2022 20:26:00 +0100 Subject: [PATCH 090/132] [Cases] Allow custom toast title and content in cases hooks (#128145) --- .../cases/public/common/translations.ts | 15 +- .../public/common/use_cases_toast.test.tsx | 135 +++++++++++++++--- .../cases/public/common/use_cases_toast.tsx | 87 +++++++++-- .../use_cases_add_to_existing_case_modal.tsx | 16 ++- .../use_cases_add_to_new_case_flyout.tsx | 14 +- 5 files changed, 230 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 5c349a65dd869..10005b2c87bce 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -257,13 +257,22 @@ export const LINK_APPROPRIATE_LICENSE = i18n.translate('xpack.cases.common.appro export const CASE_SUCCESS_TOAST = (title: string) => i18n.translate('xpack.cases.actions.caseSuccessToast', { + values: { title }, + defaultMessage: '{title} has been updated', + }); + +export const CASE_ALERT_SUCCESS_TOAST = (title: string) => + i18n.translate('xpack.cases.actions.caseAlertSuccessToast', { values: { title }, defaultMessage: 'An alert has been added to "{title}"', }); -export const CASE_SUCCESS_SYNC_TEXT = i18n.translate('xpack.cases.actions.caseSuccessSyncText', { - defaultMessage: 'Alerts in this case have their status synched with the case status', -}); +export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( + 'xpack.cases.actions.caseAlertSuccessSyncText', + { + defaultMessage: 'Alerts in this case have their status synched with the case status', + } +); export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { defaultMessage: 'View Case', diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 9bd6a6675a5c1..517d1cfdd77b1 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -9,33 +9,97 @@ import { renderHook } from '@testing-library/react-hooks'; import { useToasts } from '../common/lib/kibana'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../common/mock'; import { CaseToastSuccessContent, useCasesToast } from './use_cases_toast'; -import { mockCase } from '../containers/mock'; +import { alertComment, basicComment, mockCase } from '../containers/mock'; import React from 'react'; import userEvent from '@testing-library/user-event'; +import { SupportedCaseAttachment } from '../types'; jest.mock('../common/lib/kibana'); const useToastsMock = useToasts as jest.Mock; describe('Use cases toast hook', () => { + const successMock = jest.fn(); + + function validateTitle(title: string) { + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.title(el); + expect(el).toHaveTextContent(title); + } + + function validateContent(content: string) { + const mockParams = successMock.mock.calls[0][0]; + const el = document.createElement('div'); + mockParams.text(el); + expect(el).toHaveTextContent(content); + } + + useToastsMock.mockImplementation(() => { + return { + addSuccess: successMock, + }; + }); + + beforeEach(() => { + successMock.mockClear(); + }); + describe('Toast hook', () => { - const successMock = jest.fn(); - useToastsMock.mockImplementation(() => { - return { - addSuccess: successMock, - }; - }); - it('should create a success tost when invoked with a case', () => { + it('should create a success toast when invoked with a case', () => { const { result } = renderHook( () => { return useCasesToast(); }, { wrapper: TestProviders } ); - result.current.showSuccessAttach(mockCase); + result.current.showSuccessAttach({ + theCase: mockCase, + }); expect(successMock).toHaveBeenCalled(); }); }); + + describe('toast title', () => { + it('should create a success toast when invoked with a case and a custom title', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ theCase: mockCase, title: 'Custom title' }); + validateTitle('Custom title'); + }); + + it('should display the alert sync title when called with an alert attachment ', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateTitle('An alert has been added to "Another horrible breach!!'); + }); + + it('should display a generic title when called with a non-alert attachament', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [basicComment as SupportedCaseAttachment], + }); + validateTitle('Another horrible breach!! has been updated'); + }); + }); describe('Toast content', () => { let appMockRender: AppMockRenderer; const onViewCaseClick = jest.fn(); @@ -44,20 +108,57 @@ describe('Use cases toast hook', () => { onViewCaseClick.mockReset(); }); - it('renders a correct successfull message with synced alerts', () => { - const result = appMockRender.render( - + it('should create a success toast when invoked with a case and a custom content', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } ); - expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent( - 'Alerts in this case have their status synched with the case status' + result.current.showSuccessAttach({ theCase: mockCase, content: 'Custom content' }); + validateContent('Custom content'); + }); + + it('renders an alert-specific content when called with an alert attachment and sync on', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateContent('Alerts in this case have their status synched with the case status'); + }); + + it('renders empty content when called with an alert attachment and sync off', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + result.current.showSuccessAttach({ + theCase: { ...mockCase, settings: { ...mockCase.settings, syncAlerts: false } }, + attachments: [alertComment as SupportedCaseAttachment], + }); + validateContent('View Case'); + }); + + it('renders a correct successful message content', () => { + const result = appMockRender.render( + ); + expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent('my content'); expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); - it('renders a correct successfull message with not synced alerts', () => { + it('renders a correct successful message without content', () => { const result = appMockRender.render( - + ); expect(result.queryByTestId('toaster-content-sync-text')).toBeFalsy(); expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); @@ -66,7 +167,7 @@ describe('Use cases toast hook', () => { it('Calls the onViewCaseClick when clicked', () => { const result = appMockRender.render( - + ); userEvent.click(result.getByTestId('toaster-content-case-view-link')); expect(onViewCaseClick).toHaveBeenCalled(); diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index 98cc7fa1d8faa..d02f792d601cf 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -9,10 +9,16 @@ import { EuiButtonEmpty, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { Case } from '../../common'; +import { Case, CommentType } from '../../common'; import { useToasts } from '../common/lib/kibana'; import { useCaseViewNavigation } from '../common/navigation'; -import { CASE_SUCCESS_SYNC_TEXT, CASE_SUCCESS_TOAST, VIEW_CASE } from './translations'; +import { CaseAttachments } from '../types'; +import { + CASE_ALERT_SUCCESS_SYNC_TEXT, + CASE_ALERT_SUCCESS_TOAST, + CASE_SUCCESS_TOAST, + VIEW_CASE, +} from './translations'; const LINE_CLAMP = 3; const Title = styled.span` @@ -28,46 +34,101 @@ const EuiTextStyled = styled(EuiText)` `} `; +function getToastTitle({ + theCase, + title, + attachments, +}: { + theCase: Case; + title?: string; + attachments?: CaseAttachments; +}): string { + if (title !== undefined) { + return title; + } + if (attachments !== undefined) { + for (const attachment of attachments) { + if (attachment.type === CommentType.alert) { + return CASE_ALERT_SUCCESS_TOAST(theCase.title); + } + } + } + return CASE_SUCCESS_TOAST(theCase.title); +} + +function getToastContent({ + theCase, + content, + attachments, +}: { + theCase: Case; + content?: string; + attachments?: CaseAttachments; +}): string | undefined { + if (content !== undefined) { + return content; + } + if (attachments !== undefined) { + for (const attachment of attachments) { + if (attachment.type === CommentType.alert && theCase.settings.syncAlerts) { + return CASE_ALERT_SUCCESS_SYNC_TEXT; + } + } + } + return undefined; +} + export const useCasesToast = () => { const { navigateToCaseView } = useCaseViewNavigation(); const toasts = useToasts(); return { - showSuccessAttach: (theCase: Case) => { + showSuccessAttach: ({ + theCase, + attachments, + title, + content, + }: { + theCase: Case; + attachments?: CaseAttachments; + title?: string; + content?: string; + }) => { const onViewCaseClick = () => { navigateToCaseView({ detailName: theCase.id, }); }; + const renderTitle = getToastTitle({ theCase, title, attachments }); + const renderContent = getToastContent({ theCase, content, attachments }); + return toasts.addSuccess({ color: 'success', iconType: 'check', - title: toMountPoint({CASE_SUCCESS_TOAST(theCase.title)}), + title: toMountPoint({renderTitle}), text: toMountPoint( - + ), }); }, }; }; + export const CaseToastSuccessContent = ({ - syncAlerts, onViewCaseClick, + content, }: { - syncAlerts: boolean; onViewCaseClick: () => void; + content?: string; }) => { return ( <> - {syncAlerts && ( + {content !== undefined ? ( - {CASE_SUCCESS_SYNC_TEXT} + {content} - )} + ) : null} { +type AddToExistingFlyoutProps = AllCasesSelectorModalProps & { + toastTitle?: string; + toastContent?: string; +}; + +export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps) => { const createNewCaseFlyout = useCasesAddToNewCaseFlyout({ attachments: props.attachments, onClose: props.onClose, @@ -25,6 +30,8 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps return props.onRowClick(theCase); } }, + toastTitle: props.toastTitle, + toastContent: props.toastContent, }); const { dispatch } = useCasesContext(); const casesToasts = useCasesToast(); @@ -53,7 +60,12 @@ export const useCasesAddToExistingCaseModal = (props: AllCasesSelectorModalProps closeModal(); createNewCaseFlyout.open(); } else { - casesToasts.showSuccessAttach(theCase); + casesToasts.showSuccessAttach({ + theCase, + attachments: props.attachments, + title: props.toastTitle, + content: props.toastContent, + }); if (props.onRowClick) { props.onRowClick(theCase); } diff --git a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx index 5422ab9be995d..c1c0793fe2340 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/use_cases_add_to_new_case_flyout.tsx @@ -12,7 +12,12 @@ import { CasesContextStoreActionsList } from '../../cases_context/cases_context_ import { useCasesContext } from '../../cases_context/use_cases_context'; import { CreateCaseFlyoutProps } from './create_case_flyout'; -export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { +type AddToNewCaseFlyoutProps = CreateCaseFlyoutProps & { + toastTitle?: string; + toastContent?: string; +}; + +export const useCasesAddToNewCaseFlyout = (props: AddToNewCaseFlyoutProps) => { const { dispatch } = useCasesContext(); const casesToasts = useCasesToast(); @@ -35,7 +40,12 @@ export const useCasesAddToNewCaseFlyout = (props: CreateCaseFlyoutProps) => { }, onSuccess: async (theCase: Case) => { if (theCase) { - casesToasts.showSuccessAttach(theCase); + casesToasts.showSuccessAttach({ + theCase, + attachments: props.attachments, + title: props.toastTitle, + content: props.toastContent, + }); } if (props.onSuccess) { return props.onSuccess(theCase); From 506648c917e44a3941fa166832f7292804018c1e Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 23 Mar 2022 15:41:33 -0400 Subject: [PATCH 091/132] Mark `elasticsearch.serviceAccountToken` setting as GA (#128420) --- docs/setup/settings.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 2b36e1fb66185..23487f1ff3d88 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -282,7 +282,7 @@ on the {kib} index at startup. {kib} users still need to authenticate with {es}, which is proxied through the {kib} server. |[[elasticsearch-service-account-token]] `elasticsearch.serviceAccountToken:` - | beta[]. If your {es} is protected with basic authentication, this token provides the credentials + | If your {es} is protected with basic authentication, this token provides the credentials that the {kib} server uses to perform maintenance on the {kib} index at startup. This setting is an alternative to `elasticsearch.username` and `elasticsearch.password`. From 838f3a67bf09b6de358e02ce5ff77858f9ed9b50 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 23 Mar 2022 14:25:39 -0600 Subject: [PATCH 092/132] [Security Solution] New landing page (#127324) --- .../security_solution/common/constants.ts | 15 +- .../cypress/screens/overview.ts | 2 +- .../public/app/deep_links/index.ts | 14 ++ .../public/app/home/home_navigations.ts | 8 + .../public/app/translations.ts | 4 + .../link_to/redirect_to_overview.tsx | 3 + .../common/components/navigation/types.ts | 1 + .../index.test.tsx | 13 +- .../use_navigation_items.tsx | 1 + .../common/components/url_state/constants.ts | 1 + .../common/components/url_state/helpers.ts | 3 + .../public/hosts/pages/hosts.test.tsx | 22 ++- .../public/network/pages/network.test.tsx | 19 ++- .../components/landing_cards/index.tsx | 156 ++++++++++++++++++ .../components/landing_cards/translations.tsx | 74 +++++++++ .../components/overview_empty/index.test.tsx | 88 +++------- .../components/overview_empty/index.tsx | 37 +---- .../public/overview/images/endpoint.png | Bin 0 -> 86401 bytes .../public/overview/images/siem.png | Bin 0 -> 345549 bytes .../public/overview/images/video.svg | 9 + .../public/overview/pages/landing.tsx | 25 +++ .../public/overview/pages/overview.test.tsx | 37 ++++- .../public/overview/routes.tsx | 17 +- .../public/users/pages/users_tabs.test.tsx | 14 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 26 files changed, 447 insertions(+), 120 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx create mode 100644 x-pack/plugins/security_solution/public/overview/images/endpoint.png create mode 100644 x-pack/plugins/security_solution/public/overview/images/siem.png create mode 100644 x-pack/plugins/security_solution/public/overview/images/video.svg create mode 100644 x-pack/plugins/security_solution/public/overview/pages/landing.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2fd412eb357b6..cc64b7e640f1f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -92,36 +92,38 @@ export enum SecurityPageName { detectionAndResponse = 'detection_response', endpoints = 'endpoints', eventFilters = 'event_filters', - hostIsolationExceptions = 'host_isolation_exceptions', events = 'events', exceptions = 'exceptions', explore = 'explore', + hostIsolationExceptions = 'host_isolation_exceptions', hosts = 'hosts', hostsAnomalies = 'hosts-anomalies', hostsExternalAlerts = 'hosts-external_alerts', hostsRisk = 'hosts-risk', - users = 'users', - usersAnomalies = 'users-anomalies', - usersRisk = 'users-risk', investigate = 'investigate', + landing = 'get_started', network = 'network', networkAnomalies = 'network-anomalies', networkDns = 'network-dns', networkExternalAlerts = 'network-external_alerts', networkHttp = 'network-http', networkTls = 'network-tls', - timelines = 'timelines', - timelinesTemplates = 'timelines-templates', overview = 'overview', policies = 'policies', rules = 'rules', + timelines = 'timelines', + timelinesTemplates = 'timelines-templates', trustedApps = 'trusted_apps', uncommonProcesses = 'uncommon_processes', + users = 'users', + usersAnomalies = 'users-anomalies', + usersRisk = 'users-risk', } export const TIMELINES_PATH = '/timelines' as const; export const CASES_PATH = '/cases' as const; export const OVERVIEW_PATH = '/overview' as const; +export const LANDING_PATH = '/get_started' as const; export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; @@ -140,6 +142,7 @@ export const HOST_ISOLATION_EXCEPTIONS_PATH = export const BLOCKLIST_PATH = `${MANAGEMENT_PATH}/blocklist` as const; export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}` as const; +export const APP_LANDING_PATH = `${APP_PATH}${LANDING_PATH}` as const; export const APP_DETECTION_RESPONSE_PATH = `${APP_PATH}${DETECTION_RESPONSE_PATH}` as const; export const APP_MANAGEMENT_PATH = `${APP_PATH}${MANAGEMENT_PATH}` as const; diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index e478f16e72844..42f16340e6ac6 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -144,7 +144,7 @@ export const OVERVIEW_HOST_STATS = '[data-test-subj="overview-hosts-stats"]'; export const OVERVIEW_NETWORK_STATS = '[data-test-subj="overview-network-stats"]'; -export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="empty-page"]'; +export const OVERVIEW_EMPTY_PAGE = '[data-test-subj="siem-landing-page"]'; export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timelines"]'; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 144095d0aa528..efb220467c9d0 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -32,9 +32,11 @@ import { TRUSTED_APPLICATIONS, POLICIES, ENDPOINTS, + GETTING_STARTED, } from '../translations'; import { OVERVIEW_PATH, + LANDING_PATH, DETECTION_RESPONSE_PATH, ALERTS_PATH, RULES_PATH, @@ -84,6 +86,18 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ ], order: 9000, }, + { + id: SecurityPageName.landing, + title: GETTING_STARTED, + path: LANDING_PATH, + navLinkStatus: AppNavLinkStatus.visible, + features: [FEATURE.general], + keywords: [ + i18n.translate('xpack.securitySolution.search.getStarted', { + defaultMessage: 'Getting started', + }), + ], + }, { id: SecurityPageName.detectionAndResponse, title: DETECTION_RESPONSE, diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index 0b06d02d46464..1ae5544dbd740 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -30,6 +30,7 @@ import { SecurityPageName, APP_HOST_ISOLATION_EXCEPTIONS_PATH, APP_USERS_PATH, + APP_LANDING_PATH, } from '../../../common/constants'; export const navTabs: SecurityNav = { @@ -40,6 +41,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'overview', }, + [SecurityPageName.landing]: { + id: SecurityPageName.landing, + name: i18n.GETTING_STARTED, + href: APP_LANDING_PATH, + disabled: false, + urlKey: 'get_started', + }, [SecurityPageName.detectionAndResponse]: { id: SecurityPageName.detectionAndResponse, name: i18n.DETECTION_RESPONSE, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 2e0743de69043..f0ebb711f1f38 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -22,6 +22,10 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { defaultMessage: 'Hosts', }); +export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { + defaultMessage: 'Getting started', +}); + export const NETWORK = i18n.translate('xpack.securitySolution.navigation.network', { defaultMessage: 'Network', }); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx index 3f34b857615fe..6a83edd7442de 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_overview.tsx @@ -6,6 +6,9 @@ */ import { appendSearch } from './helpers'; +import { LANDING_PATH } from '../../../../common/constants'; export const getAppOverviewUrl = (overviewPath: string, search?: string) => `${overviewPath}${appendSearch(search)}`; + +export const getAppLandingUrl = (search?: string) => `${LANDING_PATH}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 0a4f12e348eff..b1903ef869d3d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -47,6 +47,7 @@ export type SecurityNavKey = | SecurityPageName.detectionAndResponse | SecurityPageName.case | SecurityPageName.endpoints + | SecurityPageName.landing | SecurityPageName.policies | SecurityPageName.eventFilters | SecurityPageName.exceptions diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index a00ea4b6bf520..601794dd25917 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -125,6 +125,16 @@ describe('useSecuritySolutionNavigation', () => { "name": "Overview", "onClick": [Function], }, + Object { + "data-href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "data-test-subj": "navigation-get_started", + "disabled": false, + "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", + "id": "get_started", + "isSelected": false, + "name": "Getting started", + "onClick": [Function], + }, ], "name": "", }, @@ -286,8 +296,7 @@ describe('useSecuritySolutionNavigation', () => { () => useSecuritySolutionNavigation(), { wrapper: TestProviders } ); - - expect(result?.current?.items?.[0].items?.[1].id).toEqual( + expect(result?.current?.items?.[0].items?.[2].id).toEqual( SecurityPageName.detectionAndResponse ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index 677632d20e718..14b007be4764d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -78,6 +78,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { name: '', items: [ navTabs[SecurityPageName.overview], + navTabs[SecurityPageName.landing], // Temporary check for detectionAndResponse while page is feature flagged ...(navTabs[SecurityPageName.detectionAndResponse] != null ? [navTabs[SecurityPageName.detectionAndResponse]] diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index d8a2db30d4a7e..3b319b810a66e 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -31,6 +31,7 @@ export type UrlStateType = | 'cases' | 'detection_response' | 'exceptions' + | 'get_started' | 'host' | 'users' | 'network' diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 559dff64eec4b..e5ce8e4105cac 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -94,6 +94,9 @@ export const replaceQueryStringInLocation = ( export const getUrlType = (pageName: string): UrlStateType => { if (pageName === SecurityPageName.overview) { return 'overview'; + } + if (pageName === SecurityPageName.landing) { + return 'get_started'; } else if (pageName === SecurityPageName.hosts) { return 'host'; } else if (pageName === SecurityPageName.network) { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index 86dae3780e1ae..d82189ab1e3bb 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -25,6 +25,8 @@ import { Hosts } from './hosts'; import { HostsTabs } from './hosts_tabs'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { mockCasesContract } from '../../../../cases/public/mocks'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); @@ -39,7 +41,7 @@ jest.mock('../../common/components/query_bar', () => ({ jest.mock('../../common/components/visualization_actions', () => ({ VisualizationActions: jest.fn(() =>
), })); - +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -48,6 +50,10 @@ jest.mock('../../common/lib/kibana', () => { useKibana: () => ({ services: { ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, cases: { ...mockCasesContract(), }, @@ -79,19 +85,25 @@ const mockHistory = { }; const mockUseSourcererDataView = useSourcererDataView as jest.Mock; describe('Hosts - rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('it renders the Setup Instructions text when no index is available', async () => { mockUseSourcererDataView.mockReturnValue({ indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { @@ -99,14 +111,14 @@ describe('Hosts - rendering', () => { indicesExist: true, indexPattern: {}, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); }); test('it should render tab navigation', async () => { diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index 1407bf960843e..23cd7f707dfe8 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -25,6 +25,8 @@ import { inputsActions } from '../../common/store/inputs'; import { Network } from './network'; import { NetworkRoutes } from './navigation'; import { mockCasesContract } from '../../../../cases/public/mocks'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); @@ -76,6 +78,7 @@ const mockProps = { }; const mockMapVisibility = jest.fn(); +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -90,6 +93,7 @@ jest.mock('../../common/lib/kibana', () => { siem: { crud_alerts: true, read_alerts: true }, maps: mockMapVisibility(), }, + navigateToApp: mockNavigateToApp, }, storage: { get: () => true, @@ -112,20 +116,27 @@ describe('Network page - rendering', () => { beforeAll(() => { mockMapVisibility.mockReturnValue({ show: true }); }); + beforeEach(() => { + jest.clearAllMocks(); + }); test('it renders the Setup Instructions text when no index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it DOES NOT render the Setup Instructions text when an index is available', async () => { @@ -134,7 +145,7 @@ describe('Network page - rendering', () => { indicesExist: true, indexPattern: {}, }); - const wrapper = mount( + mount( @@ -142,7 +153,7 @@ describe('Network page - rendering', () => { ); await waitFor(() => { - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx b/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx new file mode 100644 index 0000000000000..d8852d8603518 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/landing_cards/index.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { + EuiButton, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLink, + EuiPageHeader, + EuiToolTip, +} from '@elastic/eui'; +import styled from 'styled-components'; +import * as i18n from './translations'; +import endpointPng from '../../images/endpoint.png'; +import siemPng from '../../images/siem.png'; +import videoSvg from '../../images/video.svg'; +import { ADD_DATA_PATH } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; + +const imgUrls = { + siem: siemPng, + video: videoSvg, + endpoint: endpointPng, +}; + +const StyledEuiCard = styled(EuiCard)` + span.euiTitle { + font-size: 36px; + line-height: 100%; + } +`; +const StyledEuiCardTop = styled(EuiCard)` + span.euiTitle { + font-size: 36px; + line-height: 100%; + } + max-width: 600px; + display: block; + margin: 20px auto 0; +`; +const StyledEuiPageHeader = styled(EuiPageHeader)` + h1 { + font-size: 18px; + } +`; + +const StyledEuiImage = styled(EuiImage)` + img { + display: block; + margin: 0 auto; + } +`; + +const StyledImgEuiCard = styled(EuiCard)` + img { + margin-top: 20px; + max-width: 400px; + } +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + background: ${({ theme }) => theme.eui.euiColorLightestShade}; + padding: 20px; + margin: -12px !important; +`; + +const ELASTIC_SECURITY_URL = `elastic.co/security`; + +export const LandingCards = memo(() => { + const { + http: { + basePath: { prepend }, + }, + } = useKibana().services; + + const tooltipContent = ( + + {ELASTIC_SECURITY_URL} + + ); + + const href = useMemo(() => prepend(ADD_DATA_PATH), [prepend]); + return ( + + + + + + + {i18n.SIEM_CTA} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + {i18n.SIEM_CTA} + + } + /> + + + ); +}); +LandingCards.displayName = 'LandingCards'; diff --git a/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx b/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx new file mode 100644 index 0000000000000..51da2e72c3bbd --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/landing_cards/translations.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SIEM_HEADER = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.header', + { + defaultMessage: 'Elastic Security', + } +); + +export const SIEM_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.title', + { + defaultMessage: 'Security at the speed of Elastic', + } +); +export const SIEM_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.desc', + { + defaultMessage: + 'Elastic Security equips teams to prevent, detect, and respond to threats at cloud speed and scale — securing business operations with a unified, open platform.', + } +); +export const SIEM_CTA = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siem.cta', + { + defaultMessage: 'Add security integrations', + } +); +export const ENDPOINT_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.endpoint.title', + { + defaultMessage: 'Endpoint security at scale', + } +); +export const ENDPOINT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.endpoint.desc', + { + defaultMessage: 'Prevent, collect, detect and respond -- all with Elastic Agent.', + } +); + +export const SIEM_CARD_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siemCard.title', + { + defaultMessage: 'SIEM for the modern SOC', + } +); +export const SIEM_CARD_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.siemCard.desc', + { + defaultMessage: 'Detect, investigate, and respond to evolving threats', + } +); + +export const UNIFY_TITLE = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.unify.title', + { + defaultMessage: 'Unify SIEM, endpoint security, and cloud security', + } +); +export const UNIFY_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.overview.landingCards.box.unify.desc', + { + defaultMessage: + 'Elastic Security modernizes security operations — enabling analytics across years of data, automating key processes, and bringing native endpoint security to every host.', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 36ecc3371c056..db157e9fc7135 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -6,71 +6,35 @@ */ import React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { shallow } from 'enzyme'; import { OverviewEmpty } from '.'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; - -const endpointPackageVersion = '0.19.1'; - -jest.mock('../../../common/lib/kibana'); -jest.mock('../../../management/pages/endpoint_hosts/view/hooks', () => ({ - useIngestUrl: jest - .fn() - .mockReturnValue({ appId: 'ingestAppId', appPath: 'ingestPath', url: 'ingestUrl' }), - useEndpointSelector: jest.fn().mockReturnValue({ endpointPackageVersion }), -})); - -jest.mock('../../../common/components/user_privileges', () => ({ - useUserPrivileges: jest - .fn() - .mockReturnValue({ endpointPrivileges: { loading: false, canAccessFleet: true } }), -})); - -jest.mock('../../../common/hooks/endpoint/use_navigate_to_app_event_handler', () => ({ - useNavigateToAppEventHandler: jest.fn(), -})); - -describe('OverviewEmpty', () => { - describe('When isIngestEnabled = true', () => { - let wrapper: ShallowWrapper; - beforeAll(() => { - wrapper = shallow(); - }); - - afterAll(() => { - (useUserPrivileges as jest.Mock).mockReset(); - }); - - it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', +import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; +import { getAppLandingUrl } from '../../../common/components/link_to/redirect_to_overview'; + +const mockNavigateToApp = jest.fn(); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, }, - }); - }); - }); - - describe('When isIngestEnabled = false', () => { - let wrapper: ShallowWrapper; - beforeAll(() => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - endpointPrivileges: { loading: false, canAccessFleet: false }, - }); - wrapper = shallow(); - }); + }, + }), + }; +}); - it('render with correct actions ', () => { - expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ - elasticAgent: { - category: 'security', - description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - title: 'Add a Security integration', - }, - }); +describe('Redirect to landing page', () => { + it('render with correct actions ', () => { + shallow(); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 023d010ec9a9b..91395aa21486f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -6,39 +6,18 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../common/lib/kibana'; -import { SOLUTION_NAME } from '../../../../public/common/translations'; - -import { - NoDataPage, - NoDataPageActionsProps, -} from '../../../../../../../src/plugins/kibana_react/public'; +import { APP_UI_ID, SecurityPageName } from '../../../../common/constants'; +import { getAppLandingUrl } from '../../../common/components/link_to/redirect_to_overview'; const OverviewEmptyComponent: React.FC = () => { - const { docLinks } = useKibana().services; - - const agentAction: NoDataPageActionsProps = { - elasticAgent: { - category: 'security', - title: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.title', { - defaultMessage: 'Add a Security integration', - }), - description: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.description', { - defaultMessage: - 'Use Elastic Agent to collect security events and protect your endpoints from threats.', - }), - }, - }; + const { navigateToApp } = useKibana().services.application; - return ( - - ); + navigateToApp(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); + return null; }; OverviewEmptyComponent.displayName = 'OverviewEmptyComponent'; diff --git a/x-pack/plugins/security_solution/public/overview/images/endpoint.png b/x-pack/plugins/security_solution/public/overview/images/endpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..073318f891fcf3e828ed3148a0d368c724825001 GIT binary patch literal 86401 zcmb4rgL_@g7jF7BZPch~+!&2*Hn#01C$^0?wrv}Y?exS}W83!0-RbY%Kj7{sPq1h9 z?3smky=%>!gviT^A;ROpLqS0yN{9<9LP0^(KtcU?0s9|t2Ma3u6Y%4mgQA!qROQ&$ z11Km$C<$Q!W!LoM6%Xw=wHEeg^zX7bpJ1#w|9<3|5v(AbldK1sW-K1)`=>Ci;ALU_ zt*6{tMu!kZUwIhPw>gnnCj$b1+S?SY=vqWvA`E}bOTNm zq|cwBKmX$K(f-}!utr3R0^7HCHyxF=J(gi;L^e`>q&r`qIAUJU^K^T*)_B&H z@>oktOKa^W156j{X9~(^V!7a`ao1xQY;68(M=fh%rbcKYu}JTi9(qW9L4ngF{fD=n z3>F3kxh@@-t?>~q9xX)d7+D9M!Ws)O{O+DG#3*k)l3g@4HGQt{BWr4qc~+uvhaF7> zBfq{pibb8-s#(1CfZ{Q@^Efl$Gn|uVhm}~U``|$Mc9&u8PoMDFlcSAz@bWZbGts@w z-k{N$4?_klDXDN~jU7llxUHbzM%t@=(lk{T1O?Uf^JiCZK@0-skk?D$O{cxB9~M4N zi+g=-S>DPt;=6My7g|k>lg_5uDUY_Qg=s50NhztL4zDMLB0layO#k>7tD*7HVGl@3 zkvAc`&q$zq%9_2#zRlY6RkyG2s}0x5JE$Mwv>G^WQpZ$EHsiBp8JX4yBgozz-i*Tr zAnOorEj24PWcL-uhOEqs&`4$3W1G$K}&vwyxBCs4&zpc6{6!X5mk76YI z4@wE+Yuo-U>k(ag8T0L}YYQ#I_%co-6)9<*>|K2k?tcv9&%1xrus|vaPaXG9-eC23 zpMj@Fqrm%*@Z)Cn*T(7etVn4lx$lVHA$(2@1h0L;`24%ev}1K@$z?l@TJ-9MvQ1f9 z`Z}gp#oC5QT!tD}!}$pQ+xQ3|nO6z7nad3`uzDBTUWPTvVPFj z{EIg=t^0a=ezfMjv)YIRr+$9@I6GdhhxfVZ`?b7=&emA36K(svw%6$HFy_h(m#raN zO@WZ0puJDJmHX|uBl}OX+On*_{Pi!$6j@bJnJ>5z+mS@X!Y8S$PK_KINm9U@SWj!p# zbWS8P1ITK)_Q~%$Y=&wIY#=|vSe^;7EjAk}LKobMPb<*&R-QW6F=|o$N&D8UIXNIV zPySjO)BZOS_Nt9`TPsg@1jzw`?|!Pa%&qOMD>Q4ywT&PT|0G}LQPuT$*=sqG-ms6WkLEbiqQ z-l-X@cH>*PIqo5-R%drR@nB~9v{!;x=k5LZu^?(9A|h@*w}%uHA4wM_w$+!ehBJ&W zZQI+^?|yWIg@$T2dJDO_`j?FNVTHj)nOa!Ps^UA6f6*XKs%mzy0$K(QOZn_9A1A{) zeZF5lwnlmynZ%>u<9F6)q!q3B*eEqw+Ad~ z@K6pi>gs*tSKfFS_yPxVY{&s(`MeEV{c^mxU%pDC<1m{5>a&=u)T%bWu?Xn2vU=Dx zcbT^kc^$;!=AW>8-AtIeLcZ0to!9E8(Jk)qX^;EX`P*%Da+g}HO=>cc>N{jiRULGxw0_?>~UHX|v}NAqsj) zao@{Ag1TD7vpAL3?c8u&DH*@>GG|M}2H7`z&RR9Ty7ntA4*4POjdOZG=B>$1AKlD; zixd+U=DGd5^_+3TPq62DPD|$B{Gz`( z^V}r4tg)p?0|PyPUfns3iy_l&L5a6~ZdU~2KmxGI00*I^5K?u4;PuTncNb zJD+$`knKe9p1Ql(hNU11e%`c+T7c9RA#3fQrXdOuAv{O&-AAYM+T3|A)cDX^H;vaB zbMWiYX;!k+pSi&k-JU$P9ebq}{Br5Mqg!FXi8%C4GChiYgRmDzRdMm)NmCyC?W3Bz z*TS*ayXffXjH>5I#zHz*g?o5P+_SNPdwpqXM*x4vdJHT(Vb_f?26yf(Tkia7?pj*= zuky`g^C~KwmKx6L>+88Pm!pS;aK>g`TwEF(>z$pP6rk< zOg-zD*49;aMuqa#`T3q%{MRiVZrfSh}>A@k}XiYuf`{TSxug1N?VP&3oR01-|z8 zJ_m?~2y*L{`ts^~C>md*+##Q7ZrGI(^B zyVRqR8XQcPl$N?La=K9G_G*$wWoO6%uYq+4&39ri_3a@gCWxj4ld?8 zK^KTG+(!AqWhlGt~-+kJKa^FTI#Fiae}3 z)si)CZ7lnr40%`7TVXQ$^gd$@f2O*GR8nv)ix#2d9o}F1Sndf7MBzUPfVa3!eK$uN zCGnSUe=@*w+Fx6ElkmT;VS^YMC)-80I$p96)*fqLvH*uoG{p#DOuYDuJiPk4Ucqm+ zd|UnQ2i?UTa&Ty9WhzHf$;NXttHm6bcMOT1*Mcp?(olH@5IHTzFpSX|B?v@|aRnfw zVK}34L}>UFJFDi1pG$c&GY=0j;SUz%(o^M%K|}bcVAyly;X%P6dd-jKcj3y#RT^p9YFeWt>78v?V&F{l9V6O^u$A8z zt6l_2wPmND1Qo3{sJNz9*_*ChrvW*;ZQd3Kmd!gKS7^9QZ*HeyF7XYIS~zsGj9Q{h zO)bZwRNsp4nG!uRmpxCZekyg;!ww7#)l#1ub=mvIQ^0b{c)ykbId!tyLWcREBle9Xq=Z98odE+pnT=EoJj1A8Za{Y=Wr?u)d zGBPq4wL5cv)xqj2JKY1t`^Ck@1bnxyDmw!09fJaDP(LwY;gJe+eALzbi>WdgogZfg z0U-~oRq}aXPt2ZH0v4ZQDg?yo!1z%gx2LEczb`@0Tl_n1s`px&B(ApE{1hDf?DXx5 z5J)StCYevi9u7#lqs!&&4>b_MMBX9t&I=2`jvw!AiLe~J}xVBQX{F0V0&Gw`gGGdw@Se?#bmtJF}~(5)f=U>I}3S%amIi^ z0KOs!0|e=zLfQ`>=GnE*4jIBlCAO6Cz>U72;#SQ`L+95ZA}q21lSae07&_HB--CM(f+6~KI zC%lNs{sC5(bHVK7MN6#G^r~+SD;4PJA?|QkW8;D(xj-r!m@vS9IJfZ zw{wuo#DtYK;PyeP3Ljo5sv<0JzmVE^k}eR+k<-Jm}!(U9L%&zfkeiP&pETjssdZw;|r) z)c|;GLppLzSX(=sxJHVx^>STICeP4reIV1oP{#*w^S!h$4G+B|ziaU>ACY`?JbV-l z&u8gZy`-XrZg#cE0Lq;LKLKPkK$<)jPCK?VHI*7`?*c?&;NB*hwMqDi=gv+>K&RY- ztBaJBg}C?tIKK73FmLk9=NgW!Y67f(ioL|Fs^WhVT)mQe1*v+yEF>rIdp+^@d!In* z76RzaeJY(^Ok)U8RfMG6nzHr^r?a3b~2$B;7k%S3ra=h%hbwaL|eA*m0wB_Z`8-k|U^DR4EH$Q$oeh>3d&&=$mJTB z8);9f@#=KAK*w2E-ZIFD*LU-?sz5+Xzx^ADh|5mE{aAn-1X;$jnAbTu&2_2<^4d3f z*|aNoR&QuOg1GTLDKZ%67VVpJk5@o?QJ8&=Elz@k<&_d%J~jL6xJz@#?XCrnl4ry| zoh}42@pLmZBLNw@3i`Q>KuQuh6a`r;9iVn=%0!>eae z`61h^&0C80P(MSK+YcaB$V({~(Zf3ceFI|CNzX&A&dN%7b@2JQ(-Hf?4iUiY#5%ZG z4!i?wNrH7Ta8a=Sx^@R-*uFDfOMJo?_ps~-4B{2?>f=z~YVl`iR{5u{%i%VVlP`k! zSMAptfW)^7{l%GlZtrC@tZHU9v)uJ{Wrtd=O5N!xaR$cM*Uzjfc1sMYzQLP$;tVn% zCJbaY(?>QAkms5fFeV=-*MnO)BRG{_FI_ROi?x;!&42~x{g{sO{R1|6GIEI44-VdC zsm8pWyM{VX9)t>@dI4WRN>;V@+itoYbpth=F>dg$E#!I6DT_{%)Mxw2Oga|h1CWoY zym;}G=~;PDy!@S{r`P4^r(*#BH5Hu$dzjGu7ZTY_M#Do4)H^p6@}ts+yS1VMH2TrTvZ100|j)-by> zYpT+eZyT5y_3G=@CmFHjYG*tU4F$#i{Um)99xsa!H<9d+X@lYO&W{DOJ2{*nEEwAH z=aXR!<$`hE+L%ZY-2pGY+d+aI9iBXbuj?=l1_nMe{LjacBk>WqAcNeaqjBQ@-u*lf z?|9~`}qOoDz)#Asw z7es&{zT;^(OHv*d3F`E7Esbo4y4`J^>wEg25+9(U$t&0Lx$XX1;sa$$s4fklJi~Mv zj2=KN*3qo!+)`a`O@;Xd#eXDH15ps*W!E*ySb38JNedsKY%%rtJ6SE*u_6NJY;YLk zq4xs({lOLCXUCQnFCV7~p{^8rrY54l`)p+wG&hc$cJS@Y3jh^69u$8lsF7lz+PJT^ znMf1Cf=mqiuIT>pfzjB?YR+egjfA`PT^Elpp`kM&>^rEl?Y%wBHu=*7)vjj-7RUBi z&8#A=cTf)h`9?1=N|k}$L5uqTX&pK{?+`pP0=Di9W&}gtAxVbAnSJW zv1}fwx^_~2B=SoGHrrdzsWJ$Br}5 z9e%WbnsMon6iDw3l6vd8{!#3`4$d8ugc%Dz(lnz!3h}YEkS@y61lM zHvL_UGTv(xrkcWDa=hWw)V5qV3byymL|1lBG_?vvs=@qLsVO z#t?kg)XER{PcYClD10K06Q(T~#CjBMR&09gS~NtL0giW1$ZKfpXEtnlXNOGMmX=bQ zt+Du<^agBtCgCBgf2tE3WM|t$rXSYHmGpqiaC@}JkHtX{My8O@YL*BaG$UMnG=aJ%x5n-5L}PmuJFCy&k#50x=ZHBNNLBf^C< zo)BeBy3&&9>=>qLX>$Mf{Nk2Yv>s)Xc3*=bsa^jejqn@F$ucg}kUvzh-j$mf0|ehu%<5HRGq5`X_E zTxa*;zo-LTWI@-0SK+0azrr5Y8E`34WuSloO!AfH(sIF89y)V3%j=qbGL|NO`*J?Q zAKlY(d|y$1XO|0l)HDzo3H5aN^$Q95zV>ge0or@Xy5mERfvE}(%iK>$5~XN(;qhD3 z!N;us6YF}d{xU7c+lQrvitAKe5`k}Sb|Sria^{(qro|1)P2zAcR+-IdsIG}dz@(ds zIE-u2u}Sn+hwKduq`9s+?<0XBz-m~oEpU3@SU+Xuoxx@2}zw&KvY!J%Hs#yggm_Ikd@!i;=!I9rp7Z# z^UF7 zU7p3kGS#mh+Z!+hZmzg1t4|jlfc%-jkJ6ipC_q8Jr4M=d^?9$;`+WY6ub5Bc34FvI zH@`5G-ge=>u^Po+_PBYj_xk7!IGs(K?!KYln{V;R)>-5@Hj^jg09b>1&)2R{b}rw%u-drtFf-`TZZTDug(kQS-lH zw~Mj_TB{7&iVV22gfafI=6Ysw^AIVOW8yN^isDi(OQst}Fkl3!(q4@f+LN{$1~-F? zdfPA05%UOf^A!nnp*Kl)eB!!E_j>f5_XM2}MmK|+8=IYPhT(ux@cdT?HEaQlFW#40 zBVjBk{DEjIjEp%2_vjx2FiuJdZX*CbDk$hXx_cx#U2k<~^&`B5?VBQ^dv@n*ii3Nq zoA^TX`9@I?cPpatl~Z0dzmbqSb&8(Vo;j0_73G3YFK1Tpz)FeP zS`MS>y4KbP|3kJfDRGkUH{c?82Ijo&KEa`C8gWgu>NPQzR?FzNB|-t=IiA=a_oGCm1$G{VvPh>(%Wt`L(v2| zQIETsSqv8&T{?Lhmf!hlpCa!`2;s(mBNW?9@`(etv$+{$huVDT*; zPuQT1Fr9%kz*Ub~bwJ@2!4Vjh?Mn*_+@{sRNNkPHp<`_`e=$&g|B&#{tsO=|YX?k8 zmVJM6jP5HKBi-8Lsj;x5Lxoyx zLI(h7&D^9S9bhF=t=QHNW5$qj00VAsZ3x`%1zP3a%qcl-u~qt=cg=Snf)uC(-w8zT z!j%G#Zi!7oVP(HD%d4t;tGkaxm^UX%-j%pvi$wst;{*H

&|faKRtvI|3faPilcT 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

W058t?hEBrj?ya{= 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 literal 0 HcmV?d00001 diff --git a/x-pack/plugins/security_solution/public/overview/images/siem.png b/x-pack/plugins/security_solution/public/overview/images/siem.png new file mode 100644 index 0000000000000000000000000000000000000000..e5d6bee86a6cf90883065d5b5794b0f261ef2af7 GIT binary patch literal 345549 zcmbrmby$?`yEgg&Djg~@0@5HT4blQ4(j|yUcL{=Y*DKvA(jWqYG)k8u-J*n)NJ|Mw z*S_Xm>-T+o9mm>#?PJDZW}bQO`-<~Aueb?QQ;{Xaqr^iHgiu~iS{*?yCn5+|JI)n& zW#`dzZTJVnNnKV7DIK6*MG!hfUi$uH_w@B9A8+07Nn2;SvK`^MT`ci=N~G>5`!sjN!P~Y_M-|Vt%uv zYJbKCi=d7B|M#=ekbcdN{>!7;DAP;X3ssE+jJ%Ca_cSw^cT^w6Xv_%e8*{HK%HUUKM27g4G3X+_ zNJ-#HKpu+FjnWN?z7io{tyqN=v8av+?R{Q|yR8pux=kP*amY@>xk2$2jRx zHTD9J_Lz)z;tQeTrz43TJ@vd_1*2%R2YH_>8V1-J=1zBeHZ~H#w^~TVy3nmPuo8XJ zv6T4{d(=~(kX3S4*4R|y!X313(UN#{qcX+O#ntt;OiM*jn{k!l4Qre=pUTKe!|PXz zU5rHm4G{hy!8&j;U~E1o4iw>`68jgY-*i3&D;?S`p+jMsyrxinW6r8eO^lT&V z2Q|Yxqu}dr-n=;0BNln_Bv%7Bq)ni0rCj+DMR{V?Ta~xAI}M3dr%oxw-zFxs9W(NX zNa+KWM*0+&j7u7LbKgjdEBZW*(eTjk+2y@?q`hQ)&u&#Wyj_m1PHR-((z2jGwPw*T zh+Wac_X(q1b%LgV4OOqN)$_F-&&pSA>bJ`Gd3yb9?!6AyfhBfVSB@s1&Q45NSM}KY z6qe`f`syti%a0Xd?_|b%nYYC5nQSk&)>^&?l=2ZNhvX}M0JTCf%FL*Od(V}5v z;~6sIDPD+4|GrXj?yeh&sysjJSu1EDCU0wh_OzQ;aP8hGt-|{N^L|-U*=0on=JyV_ zM>2YTvI+hu4o>?%Uf!dTX{enUZrqnNrcp<4d6!$kg0-W#3~M z&JY)ym**E+>jdpJ6jLbbCZfDXnz)l%{wB7tD>*bxNc0*!BYiYl&~vOKud~SIJo=&c zBqraQ?A2d|9(B97x+m0Y693EN0{g*5iO19580xMOrvz<{SWm5A^I>4p6<-8ZJjO|CS89YGwVZ8D`Dq+GF1Wq}n8ef|38U6D?#2g>|qQPJC?XZL-HL)vgc+F&Je z1tVrnosL=DJ*uCKViD5fBYb*1>9<{#H6K6XuDNMW0s;S9h=uoVDiE5h(xWYuMBm}W9##Zo_%6?S*)wQ6yMBv zA}K^Iuv_t})3eV?clhg96TXFtYA<aKWAik7^7_LJ->heCL$st0;uzI&lC1ZA4oE6mDthQ zSy))u&5tB>GhGY25t(e zcLf9(GE@@U<|IWj@)4qoavnrF0Dcl&&poyRV>^sHhlWEf&~j92pa% zVQR|w&nG(Al^6{%Tmuqdpo|KKJI-ypvtA36dw@$569R@0pJ<&eBSpKP~YUY^E2cL}8R_ay2PRJ&!pI2SR#K60R`R&^`CfEslGXn$DHQXxS zyUL9EWAbAz;nEBs8O8bFw-xW!>c9T@ar?36Ls?mD4F9pUJTqQ4^Ecbu4hk#@a3^(5 z^cUuW!$A-Xl4JS@-i}0BB`UG>t7OVCfu@q+l{I zC8_)*Ii^GqA85Gn{d+~&b79rW%8LJ^i?4?)3{rF?B*Ev503V>13Bk+%3q(n*U97&p z0Ie$6*t{23%U%u z8dHZaEm=(dYL6HOCy42M2{z5Ie0}Z(N?%_eRvz1N!^On~qhqCvU*~NADZ!QQxfiX6 z+vN478VqqHJJ=F&acHFYrw8R;*T}&%D&1Seg_L9kqSN8YB8Lsbzr=Db+dkr^RcETTYrCl z5a>pHzal25sYxHK7W7z25F4D7IKkZmOTG_gXIJbG&IPFD?Ci{%nja?3U`&~S&=CqD z%rbNcN+y;e>bD$|R8diCN3BCt-9%@z&k3G1(_;LU>)`Eu$ynAnJ-l5M5fv3YUEalv zzo-Q55_6B<78UXT3ACM^v}@?64n(r2Jl9IVIc~$dU?q9m+uQLIydbC9^ZlBjqN1J* zx0x{bY}DKB6gejFo!4A)m4up_%pq+7M%;4B%E}gJ;55H~|Ne|NIJlQ}q6X8ot2>wR z5i>)<<9o~r^58L_S17ZcSsCXs*1%y|x z$n&bI63pr_SiwUy02j=TqLPKt*d4nA?y4<7e^T@<8ScLA-uNeo%lkfBxNoE7CZf)j z!=$CUD7Z}pZ~z41E^=)`Bsj5cbFWn{!u}W9khOQw_tkIh#E&7a6_)?5q(u3{hYxbH zJU3omsjM?PQc2AZxY(|No0hXzWBpY^LtV__oc)2e6bQaZ`jz_ zT)(4LS{&MLl2BB1+p(JhS>vUAS9Ck04<|@!!CB-o(#7nEygRMcT(RO;kfCHC#bZNA zBHHml(mp;u;UlZ0ubB0B3;-2Ul!D2*4D`4M01?wXdLC&ZOD>;&Ro=YJdj9l%kq!!l zDvyecBxUc7%r3e;4cdE?oBML1sbhyNymNl(E?nMxy2u3pj05zK+u};s4mcQow$Sy@ zsVc0b>LwMhVM(MB?SaLj^No7y7E7i{?;FJ&!$^^?eeP1hHO+m(BmL?OX1}X=L8RKr z=6kmn)-fk)il3);n|1w=;kDPkl9FjVQduqW3^(s%`eE^YKhrA?rchjvtWeHtR-JHd$U(rBSR`_mv!EIcgcWe!ckOe?BUS|0|Da@%szWogl;5=fd9Fx?;87KrQOP zPn-CMF1@DFX^IHP1Cyr@OrL%>;qJqx%*^R)G~~9VqvFYQTdSbeXbg-^z`)f+oId2zAaB1-FYywy~SgOhaWMM;E=u< zW`$S_LFGP3Lq1QP0c?5NuJiD)mi>MC0`3E%3eUY~c|}D5v zZfuQNe=JTRZ)j?&SSUlq+0E^NrY8KI7L}}!*SGitiIroLqZB&wrU{Vr=S?9vI`geC z?gs!u7XWW^H@ekW@R_)LJW}4h#fl@V%6WhJ+?Z%==z3+LJ$N< z`?98{riJjE;dc{dAhJX~PTAR$nS(FaRmE}^c0D4aqUK=H;pm@ynYn7rs4< zj^BockS3u9jLK$>!o+p_|LFt&`Du&sjFy)6R!AELpf|+I_O-pQ5JBI_yrax*>GCa< zI~$Ff*g$@6yOU11E%uqMv72XB^l?8N&*@9wBw<%n`!P8g6lu(qO#AV}hiqUG@H1H* zL02e;3V1(Bq+JEjIf$ZPCd4t*E1ClhS zEMevB$1v)skpe&iK~34!#4F^H;01LpIQYrBMNLZ?ci2|JQzGfa&O+_$B&Jiogd>~c?nSRdXFrkJys!a zf&CId77!41nUV4&E_UERK+^crjZje#ls^E(pgr+6wjv#2=jNuJ0@F3_V;XR5^+Hl% zYNEV?f=>R*Jn^TK19mkJ##|Kg)VU4JSd$dOq@uZygz%g7||jATuVp3KlMD5>{n9uy662m|A?Va?R8OMIQAHJ3gY_7gur7KDnav1p&5NQD8+e=J-$_&ui>7A(@C4W$;2jwf z%UEDnla{Vhu(G;JUo%N){SL78dSsr_j;x-61P{-BNuDVg8=E`M`nR$Mu@MNk!tRmMOIOh2>c&QrVx5|n7IxNPNL<|2og;5ERQd_@cmy`T=s(_Mcvx(^p;!%$=;(iCn{-fL&3s}#Q)cl@jVNH!zb1Pkn(rqXN zL{SOeMt;5!O7s?21yBaW;s+&wG(H&(@z9E1H%T6v*1Cqq+XyzkgddT(7I~ zZ|)q4?fw0$#!4E$@vNg+ktG4D3bnffq7V5gKVE+M2{Ix`p`O5!R(}7!R`@s^aNJp6 z3s&*&{ri{GcF9~=phvS68n8AZG+@1TNLR=!ciqsLjJ_#KH(#7LVvQFh^%@Yux{)03 za%6P$?Mmgh^s_@lbdb0AQ=}Skvn~{;I*XKml(TDUDE~Y?d$UjHkFY5H* z`o6zjUS7BeV06{`?(!fwO^CXoVHvy52RXF9f}}72_`p}u7Iw6|Yl(oo9=L3~i!nw`S(&guOM>>^y?coYdGDH= z4Z1C=9&2di5)c!c0tCb0;xT0%$HwR=70tc+V zr5${H>^X4-@|OFfkHoWX0Uz3S*F)46c!ei6(JJU}2ENNi69bGft!<73{E6G*6YJjI z-Uq8;hPc6HYuG+8H+;y;x~h}*ZV_@P_dWyx!JSA$+U^4vxB>AkI3(nehQ@;ix2@o| zx$M+bO6IhFb%8Wy?J~sZE%WcSwIr~W9*ZhS_bZIsuAENKo@H>L2q2(Cem==^>z4IQ zqeu9(T>)IW7(`*9W`;uv*a&2W|@aY(M zwQfLSjOLE@rFQ^b8RFz5f4AgYJI+6Z0R1(E9q6m$`O)%&V@BX!^ntNd`xP1~N0Og! zW(k+DZsy54z8!RCut{YxJ6))*7NDp7@%;JoP-zBIeu1~KpAG|lIo_$0moYTVGM0?i zkEN)?CwoYLTT&@pUfouJgzl1LP`5l=c;BpaKopYgFP}3WIhc^3W^^5S=P#c~Pnt#u z@4wH)Ma-xrexq3E@m>d-R6R0wtUUb@@A-+J@r{B*x*RFDeE1s@cB(5~Vr1iLt#9ja zL*qiYcQ|@VSpgGbsc5^LF{spDr}J-Bd>czNe`0GNrJHgz87Wegs2HFud398*;C4ge z*}MA3wgSdo%#p72;|R{9#2`6RmRmuKzO3cLqZ~R8>J|S>C_lsrKxF^$`VO7sHT~ONRGK+kXmw~ ztL+Teppn*5fyw?7VRiY&*LNd|cLb9=^0M)ny--5H8!T;+L|lLFW-R3M95+TQ5|ouh1p&%dRYZ|EDq9hfVPYKPU7;Br#=t_eb-2nwgTd{usKr zVEQ$6)t;NWmw_hcI#~v3wY!*sctc7?S06T!D=S~YdC~h0XfEXje)K`@g$?JreH}NFpKhTKw-_ z{V}$LZZ&rD>j+&SYuBHY`ceu+4Zh2fR~HKQTAEZBCFcZ38O8%v=!}P)BrcYCfo6a(YRhEGjB75fjnaP{A^VcV;u8m`hkJte*_KUp2-TmXfm3sGmL3B;kLMQ z{p!Tm)ep`zP!xJ=t+V}%9xqPriym~fwV$gbrVZ45;gFh+#-ouHXMq9>J~vedyL;n%kxxVzGh`dW(|5MSz39e^;a+g#*1Is8JO2#f7~y zqkX5rA<>rp3`Ex_)KN)-kVs$ZjV>e&lO{zG&jVk9+k*D$ORA^a4{m(2w zQ`1dJhpJ{drZG=_88iFKyuK>sG!0sQJPGpGvQppckka^7nvN@_=u!c}$xZ%vcHc`*){DqW0qJ z``UaQcx{dLDSgX4V(A?}w=~c{bN8`XS|?E`zHdZj^qGv!y+n0I4SbN}izaz<5Yy;H zWmlA`AUYDp7Q5Zi4g7uorpyv6LhQ=}+GfjJb4EL*`AFt;`ds(*RPRHBK)VL`64c1OpHbq z1A#1u!=SO&$b}WlzjQ6Ak_pee84YBXOprZV#K)iC3XIL+q)U!LAU6<@SgYr}*@p=g z9)AvW)9lY!hL`$G@mBMi?)XqS-cd4kwtL2Ft90V!xPpLb!J7@-dl$hCw1@Yacc;Bn zf>CIE)%dJegRS_=z@H+^FEyi^!0B3r=3}GwAZ57=@gS^|gZ>Xf@UdHcn0`0Ky%(SH z>pKc(uFXU%OWc+TKDC(EDR#^7>+_OmI=Hps;dH@M=D~&DXqp!1L$h|1LmO(@j4cWN z;7viIUz-JPZ!U($dHDH_gqNr@j|&OV^mR6urb~03D12Kl+>J z-7T(4G`AW|;ACn`zw#&FyDyUd<#)w*w$J5&d&H|0K(EY<_XZ|%gcvrsmS9TCvm|_m z5=(&O&$k58-urwdwVPeSsFNRDacA-bAzQ;dCsBd{-~6as#JbDoqFYY+9&3rJ2DARR z5R$POA6P@o>65qM&FcKQecxnY+)rwQiZCJw*43Y5(1|ilx*&tH!rCIJGvE zBKD$%hAyrMoc(DjUk#a+9VxQlH~31BjS@U^*VA;G@#2{IFx6OzG40n(U>7_W&1I;uwNhiUwO6`Pp~<3fjgcx+7eYaunkmF?f}%Rrzst_}KTRTKYg zw@Mh768mA+ZJkyxynY@>wKDB+nzkE4r+z^1eRk{5%ZnLz-Y@@x=Jf_2av9>Nz7Hq@ zCmCLW%ulpO@FUAvBS^kQcIOLOn5s- z>`?k`eR|_2!Ihjfk?&(;30LtRAnn{JUgrXb3~qr{ECgM7PR&mW`C{~%5A$E&zdG}V zE|3lZHhM|&!^e+6d!T>`S?)Iw0J;~lP_l%j3!SBZTaNK9n^V_c73Tj4`@F@s*&-l z*SA}pfSAw(5Ft8)tI2;Z=l7>>vY5X(JF)*M zc|lcsAbJtm7Sf4DOzAdiw#PXC+mZI-VXx-vd2hG5lqI1IHQmzBgo3_!V+J)eij=Q1%~lI! zpV#39w7!`)a9cI+uidZTWA;-YK!2M~&53^R&kx@wy|ai*vd-6W^Ex3e^@-I?8%(v@ zS=tlK9v;scO^$YQi#<*c@^<%b6yiGyxd`%VIT=LJP=5J7R@|9tBhXj=P1fl=hyn}I zE7h5DJ<(Ew76rqWj|+wCelvo&{~CJVsk}Tlcj$F%fwWk7@S-%MJN{32d##6gn|%Dc z(DZaNoJ$A-c^+v@=R*MEGb^YJLh20j6!Z*1p)i#Oy;rN$D~$P-Q`q6g1FY_-OyNb8 zXl81_?HJCi zi#x?fRa`%h3##4~UV4?a)={KMF{d6wSS7pVHMy$RZ~o_mqIJvA|NI>57Q-q)9 zbQ+k>didGz!WOwr&WM@BNNq*7uu*ofzRl%TYD zmKM~u=So6&p$0csCh9Bpt`Iatw^P^h<##6wx2JTrA9d)3@wMRvrlLUxF{iz(R_*PL z`2q3#AV2)GuB`dNRv=V_WXS;)RneL14*6f<=$lh}vkrcguKRi1d)6n8Hm^iRp zeQ~CF>1A*K*5X(x_-gIZ(v!zpEt;FOr$&s>F>u)JFG2hpw(74V0s`ABOR^WA3j7by0$ zdc;j6q)8YXX=9MDN0u= zmb2Q@QV1|3$a57^~e{hFtuC#=Mt+S{%oQhJvvGQt?=mtvY1rkBd zA$8jYsk5zq8(A2DHD0&NOce47W=bjN9l!qyzoRu@XPNnV$?#s}Wb+}})Qgd2gw&En z;-F9C9T?&9?yEUPzY9V{QuV;~^fU+i&z}8M(A_b+)*0TA4W&89?{5ImZI9;;wjt7C zAc9kWg{S@$4jPZdG#ff@6dasisyQFdJ5039^RRVx#Jo6fxOwnQ$dxbR{~kFWmt~T2k2LNEyFVgyKMT#w@UzuC0#UOpl?? z={7=1MU@M4KS=ECR)JKGq-->`aLy2ipx@F+?H%7sxw6qT{M7dthU#Vm1_xR$n_Scs zsARvnLVC#3T(hj;y&ZQCL7KQ;8qVxq%q93WHZvJ)Z`53nF^u_P>YNDjh=_zkwh+GZ zlGv#Khl_0cUEh_QthtKL>esWAKbnKl#uleKgVW-NAwuho%pMKg%Xg|3x>HXZA|qSj z=Qq4MHLV}X$eg^U*R+%?hf>-6xnpiYx&7r1^rko*RM1KP78f~(4uOBZ3yY#-9XpNR z2YkUP_v_xxm+fsvxzwC#EO~REx@hwm_!d&UeDJ{i-h^c)af$7h_+WEDFIS*-4rhB= zUmg(O3)K^_9I%}^^Cp(MLzPpl7bNSu%?-`60YeA+{q>2cdkt=;(IeIa<>-$*(I1Ue zXt-Ii?1dnTuh`pqMZMyBEznm9@};mXmVKC1{H$Tt^GinS$&ZSq-yidp-gDY?Wc?n}}50IYvE-1%C5Kwq6;gUM1!H=_qpNbgDMW^Z;ex`Y1n>WK%Bh;Tr_)C$|H+Ac6a>fKXS zgBz_rP4;vNy;z7{T{%%!L-{6Zl|u|lLyztwt*xyHP@OARt_%UNKvoN#JQ&A;3TdKB z0U1<#poxSYc!c&d48Xk*vdt%pk_oDiqGAnBH!lEWY=wN2CAK0S*PlcRI=cEa$qr#S ztQ}0eu8)#>T^~X?gK>l)d^tOn3xunsuVpiq)8nfG^~y;7g$izhkE_WbPp zFjxP@m}j4j;q+O!@a|#y!-xXHmlY=MK~F1wN$oZeXc%)uXyz|8`{XMnCAk7iiZmyh z+cTqeZT^Ojook0Whj`z@e8e9C~VY;Hb5TIaWo zUc4YVuTOPj*0?zLJ@lcVH&}X1gnfIco8Lgb%*@;bXooJ4P;QzaF{XVV`oLc?kvfSWI!MBn3k zm#)Nu<+EmwAa-sXrL{Y^^*pWlT*aDhLd!TODGH%fEXDTMOcr1)w|dlFQTRe6gW0^x zjF)-L(n)xCx8k!rgxrDj?_xJ;^{dEXP^fFkk=eSbv5Eq&AJePBsM90za&2*(`jD=2 zD>(S;NKZf5Z@O>o)JGHzr-;#VeS~qefq+2So#j7s4(r}*!DJNV^Jg?GTq@j-br!yV zUIerd0j3Bf3x5tU6-|0?F(@;_++X{v=ll5n7gP7c9Bh@SR&sJ0#;JBjFTa?fM#`!3 zK5Rs7ZEC#Q4;8b1cJ_g2@tGHM(S~J)5%r>cUz#8>G$fJimX<7U@hs5443Dr!DaG%l=>^6to?CC3%7D+IU97-WpS00gOx|XAB zR~H1mGig>*2E+t%k+*y|(6y#7Fs1a|$Vd|I7ehoN7V0cQnB2QpYRM`~dwiqxB00u% zH=u^PCre*FU^Fkm%A;aM>5Bit7h`V+dupABmq*7qAL^d5Sha|So|tObcR0Cfu8IeI z|K6UW1PU(6Exmd3o~Lx}?k8+Def=ouUu5fme-B>8q97R6dTxkJou)kDV{!8HlMp|t zYjN+4Hizoa(pgM8M_PB|*$tURs{NZWN6nXUN^JStgkYrO*Pf5h>n&Y>JP7$%Xc%1B z3B#v9exx~F&%uuCw{QT0RsQRr2!u3aRMgcrqY5$IZ>AcFoEC7IUsG zi;vyQ7A(;A$=TLnl->J+l{TiRXaWf*vOwOY>hI0q>=PZn=%u6<7qb(I2{2JIUuE~( zOxbvsdXH+YtmSG5aZFnDJfy0pXUXT7T6%Sq=vNVyI*mHdCyO7JXZjYein`-ONlY0y zdY@CDIE;w#gj5o8HT}JIs5I8O1|Av?eMWC?E+t#>+hJhZKI_FnT3*6?e4s6Um*=#7IGbIwvP?>WUdFbcY zvYOJry!)0lJTiwJ8T#}R+uGe7Rkf_tqPO(aSz?#nP6E|-^HYR_+bPZ_PG-=ZLE*@Y zy;Gvo93edI&$K%pwx{|$EEV~_;uThUbKQOy{PUv)pH38rTNvTF5MyFe#!ce zDsgAO+lZSOTvu)UN3{8``R>W|#WBZ}_ilkh=j`dmeulq#Kh!kO_85o*xy|IJf7{Z&qNLNuoMG50P_0O#hNd^dqLE-k33`6-iH>#N)h`)P*dgC zMC@BTIR1xTpQF4#yW}MOQ1N6aPU?QIqcq3d_~^& z1O@1F_*JVpK(@ixE_fad6lkt|cg(hRu5F&VDy8P$Hp)V?AI(Zy6LbZ$zVhzTQl`0T zGz%w@sZrEB$j-lUOJ52zs=5zt;l-{v#@B-OndK9p%J;jc0IWaOhDDX{mhSVWE&WL&*!nALhCU z5onFPS*x#f+cLmFybd;DKt2o_@H`NoAl*Z=Ux-WJMn^M;SPd5v1m7&BV8@QDU9LHC zJ2V`W5YFS9=;@fAx?PDg+xsw#FR0IDp(I-x&`iar5-30*v$MB-FGf8&fY38$~ zor-q?7Vo-Gq8GWNZyDU@u06e(H;BgKX0-QH_pZ;(&+Q%o!PDqa6OX?$!0XyzGu&h< z>bmKTitJuLU+Id9<;Nq-8VXCkjfp1w z3EOIEmMnV=pvGf~s}-_-QQulI^cpL1A(FJ5wzuVLoT5{7JpnC(q_}>vk>16=dU`b5 zedstV4tD9AX!~#ZrfpybMb2LQK=Zq;lScEMQa!@twUvn;n^JA!Wgyva_R9*U6By(wroOq+Kk6U6o2OerXzJEj_Tmap>I$ZhdNO=gZqzAw{`0sCQ< zmleqzyl2m*!hAy9B)n0_%!zG+O^kso4Wxn z=~0lgNEGN3$|7J$G{9EzV1f}tvfE4@$ZFSFCX~Id5rr%os9i#Ud|ajv4DFof{dRdF z{CTkO^`AF-`q&Y~hofZjv#`2_PvQm^=oLjTE0c(%=dBL9=Mi&&2n>+KB3#F zb7VwR;b48n+%EONH5zOuC0fshhF7I3X|vMXP=I=3QOS*3QoeqqJF(ZifMb4W$Vi@Y zbnw6MEIAd2){zi;0F<`p-v=GCF9^SJX@_(9>`}RwdWpL3cc|U<5_qCVA~jO9 z6z^5mZ1G?i_w09{NMagi-Be<3(K7+NhO;~9R7c=!<>cv43GB5tK@*ti17?y6A4%7q zc9wVQW?zH~sKZH#pKH(J?CCR!C*PHC5+4Pdi`#mc;s}cGFudw>q1#)!#9JqS+L=l= zlAkQ({)@=`;Uj4=8Z87U0tdnhF-aY!Eql+>SxIoz1k!@0?I@r#3A8kVf=h-a;a|Q1 z5$7AfvQ=unbnYcf`fyhp!m>I^NG)*v^hbDL;yD4 z_1CPVGoyPKmF{db(=N=egy*QRzX#j}j)LcZezAKmzQ6G5Su;G`h;Uy@v3#VjPl(7B zU~m7hDVwWl*|=MFv|(>34aQ~byYApF*6tn|CXH!$^n0x0`J{&XdPQeNOUpM5bov(U zccE(QYm4vyD}KuGbm!{fnXg%u@R#dluXFf(PbMkiRESNS`Cs-!Qe-QD?avL_{FuR7 zn@w3#^Ef*7F2JVQ@;0lw-W7Rn+_P(M!TYW_JxI;;dVII|0z1{Xw)WR@(qscyVFUMb z;q}>;izd<(;~By64b4A&X)#zQ!b-Sp-fAcs8tF5S-tu2>BIb(4snA^F-t^>Lap|YZ zpLqNw_0$w)jhFDrN9Ve4(Y&mxzoPt}vl9XnUHPG&*y#|~(vmmdeL$Wm=iL%_U?}5B z<6++CI4ndl^S^Bb5jq0mf(qNC%jkB)6Ft=Dxow`mVhuN;X3kCHyVA+WQj;_m%v-w= zU#Qjp<2j!AFgNY;W38M@set;Ss1{vK;@NcVk^F;+ED4W)wi7TOwU!NtM7Yw3d!uM# zu;=IJO-TAD-(J>{D2vp=CmdO0B>0V=&$;fT-tc4(c`a`@TFL2gCG)iCLQob}32phGO&C9qU|r$mfS$85Pq4#dYh_& zug!;g*L}S7-6j1{@12h8WI^b}MB%CHc{}PJzoTBrSdk)ECnTt?=$6IrO)YLA{!3ow z%#c5D8cbZuvJa^6pq)!@IrNZ6;RYXNtLxvR=zXFWX_>X8y+p8kU#f$u6lkHSm+9fw zMI*K(k4z0La7O&<(VL(jPnp$B4QKQ!r~AvPB{61KVCc2j>a{e_5BXp2yJ%T_06GJg0Q z9ENM*Z$bfvp^-;%d~z~(aDsu{y?ZwS;f9Gc7|O%@S_a)y_jEW-iZ9IsXS~Ct6Rr4l z5c{6abov(r=1Y$m%&5&tALz#ZTd*jss;YuM@#?6qJsG+^1moT1Koz=PKY&6Tgb)O= z%<`!=IepqNVe_9^06|mI41!-_TG^Q!zPe)u`~{S`6Jv_e9GPAm0`|V%siGih)KXJ{ z7mUHwoL>Q^2b~KH=ti*H)o!zRJNI`Yh(wflXqJp_`{Ge zl3iA&I_#n!N40e|?u~WoS72ttRv&=6tNu0{K-Kc`6;NVI!qm~GCmyMr$X%LJnEZ6s zFM!&SldB_d3RBB0@jON}*fq>%WMlRcy+5yaQgsh@baA;$bGkTKzUfv*FYIvmdd#;# zJoLpLhS*-mEWM})PVY1OKvi!%pyq`)4F35y*(SGl3$7$thb?PaZt0v60II_XISsH&u>c~$Xk6wStQorhgMn!Nhe zugzj0A)TGuQ&p;Sixh^f0a*5ZwYFPV{GzaisY?H8u^13-c5`M3>B^r?Pb54-B$ZUo z+XJw=m7SIcg8D7)puFzWXQ)uXIU59;ExuQAF2yRb;D0hAsj}#Y^0o#7U3*Cf^2&5& z*_{Tx5+C(T=C5xAqch$wlsi&LHdN2{KUwiVGq{cy#7Z8~J<$nAQ2Mzk!0O@abe})x zAS5D!Y0A&(!cMlA)9;?SmTmb%fueTzd~1H*XWKfX=Xn70(~rw8P{$JbJg15vnXA6! z7kjf81!hk7(6c?l+U(k%7?l&ajm`Fh@`@TFNruyVc&;?2e4@d^BZYedozU6+wMdbb zX8i~nGxvWF>V|h{_CGDpCep0Bi9oZG-@wa-BGb#_#QN@t8)Opmf9AGt%%c97ZQG^y zoQ2GMvGm*jChx?8<^P(rCORhO6opRKSukLH5 zR*dMJ$3#l>8(z6?P8A~UQOi)NBqShU-CgRx4u$Osj8>Scg;^rSTa3Y#Ots!O&eu*Z z0yxNmE_!FqI%{1rVdg-bOZeB*YatR}7rQ4*R!cVGnz!Vr^R#Y-im87 zx1Wh!RRKdF$=F9#i9?O9=7W35gzPoRueJ-0vVJOZogeB8&NakZoG;X=oN=Y~vviv9 z5F#ISq6phsp%U|^X@?wYHE!$UIR{&_F?Dsq;PKV$Hi>z*lhv6iDWBHYi{0E@X|Jh~ z>xN!RY!}jZZ}hm)`+`~)qg6xoUOP4swHh>X6crWWOhqi5-F?b_8-dAXK+~1QWJ4Kt zat#Ahaf|#6jfT-J)9#%0mNSVzU5qWEg@tS%CZ9L%>)e<<+m!hAhxlU5vrGYpKCqTO zYc?jUepmd%q%y82Zbfvwpekdg+%G@cv#a&fdFahc;UF>VO*gh$+WqT9c|D7#L?YVC zGCJI%TdFq$%v8%}{hoPAo-Ll9ipl0a`nKls9Cam89ezE*uwBhbo_bjDjUJdxhO)J% zwRPD3*6i#c49aVh6SV!*C`~uk!A;q&sP#ru^#uS!zAMjqk`Zl<7T!AT@y~E-K zA+wR@#)9#**#L|%`@~ynlcTd~G=B(_{#=k~(lR@kr!J47R`Jx?6+nm4MjkQ*~W(01QBu-0PF^N!Uw8zC1*1Kxxn5-9|6j*&R(po%JU zPx-Rvc{AA9YRpBLCT2dE%5DWqvhXqH6%4bCZZiZq@-{Ff+4^bxgqLTR)n6*kA1(i~fjw>P{(%mH`T~b~;r5i5&@2fN4|2#UQ&%ki* zd3UV6_S*YZ2=K2p&VG6^Hl?|(?Ukf&MS0PyL85C?8bR$jNoi`Y=_$Qp=0s7=#yJHA z_Yj!Nyn61mElffsE1v;0<$r1o2Z41CNX}vzBGGOd=A^JpeZYU_zjH85|D7~sseJAH z1nO{_Y2`z7C^P+N>8(Bg$z%zpB1Z)y=lA9#ex)hn?Gz zmv3hA{A&pCI$YF1Ghvab0&65Hb!FHu`z@AH5`@A0x3seYMc{ozLJY-lDhO<#$qpe| zX-kNWH9{A0vg6&X=aUaH@drGt2M&XOO4YLw?hH=P5^@-1L{K>HYZ3j0vhgCMp_+fy@gv z^-cHJ*=8DOm|4z^3JD$Gy71pSs^?29?~$o=Je#%XwE53hiH zT6^S$vM!nM7ek;h{PK#57EN}ik*brU)$|D#wERsR_aw+4@yA9U!Z%4uz%# zjIEV*n<2=Z_z>+Vh+%)7Xg!NHxjjFc+ zsU&rjm#Wlc?Z95&Kfnii0aiO0jl!0r|6Czyy-gL~ImgHKn0)I(+GO3$AC)$J^i2@||&8s?lVfzlu&J}9j0wS)|@!tR9 z*J_lr8ZY_!c@K%jcFZYx+aizQ)5S&#rJRb|5VatkZToXcXW>x4Nm{QA@f>g!U`m^NB97%c2xXsqmTEc}x%R-e*mdK~LPOJL1znb99y#Q67)~i~oR(%S z&B!JEqUD#0Azc4j%HaM**s3FVk*K>ljHOgH9n^m*Z!|Nd{SCTc^T9WY%a;)ZB026FJF$lluXO48us0V2V}!!xRbhHP`q{95B1J29ytHho3Pi>YexR3vp>AyelN zaC2znm#VY7p>Us9k3s0XifE_zJrSfOAh%2J`|u9+pSv)Qa#wHISL&+C-k^g;Kbw18 ze0(#)p8F2o(38W#dLK3nRF8?jRf|eVBYK642Zw5j=J$6tvQJc^4DTK+AiZE1{|^KY zh3RlIO0TD!JVSXI-M5%V^I-lcSDm&`DeK8ia9ex;sHDBUy;qp5`D3GC&}uW~e`aII zHa}1AYDa*4?K5A%LXPq*Vtv~=UW67X8dw?tEkg*}l8K+lpe6W_St@I>=UdMkhu>X{ zp78tD%*6&(ra!i8!a2z1ZGbKs_(X<)){)8wa&OWAs2egeGQOG@w)h*=fJ+#1^HP&S zbz*4hLg8-b_&g>MBfacc;KsJ<#?DqjO!ms?f&6IBS)TtzS%Qlf$y&{yCv{8kQqKVF z0$LG*KcWvf4$tp71p@MXUaY8B_aPq84=`5e0guJR#KmL!^yxso_{)-ref>7R=yve6^ z=WMxy$4KKr{y-}{nNMe`ya%t-R9P_FzmaG1PO6Vnc{?ypvNu#^?bt8lk=rr7*XR5S z=;|9A(1OXC8d%&Cz*9RQwUVXfR3`d(<;`Ui)cUd``@d0lS z5}D3|0Z+^SqTv8b=JwZ7yGmV#@L^+YNCG-7u`oqB?Pd9DRcv4b6XZ?^LoT((bzD)e zfAGrRmJf@LhQ1cqQf@dFRX!=Q>djFS1+4r1ll#r+lsA%zHFZ4{@GMY)Lrs&Y#0rUf ztEJ^F1EkvXZ|eQM1@i_){FL{$q_bz=8jlKB4m~bm^4{6Epy7AQFMcu9W9M;HdE!>r z|KCGS-udinE7;cd@co0A7KPLehTK_w=HMw@ZORb&zXx(weSO&b^KT`@i#(^5ahDPb z!2`JA0|zPh=RqNJT;d5`+0L<|DYCNXh}`lBw*CDX$LE5rM*nQ7|J*{Iv+W^d^Ew#IVIu!& z&KJ@&l_X&c3$?Nbk^otcEIUDEW{!MpHU`k)Yami*M{8=TdkOEoe%|2iFVn9mS(up! zKH=n8p%EXLjIAq^1O>v{?T@~jI5W>nN%XrO6{@B)UJiUzshc`c7#-W5Lfbl!ID3b8 z?|+7m)U59EAmA#>vd>VRslIn_kN|21MDbBMsmAuoIeqU$hd5>BmiWc0TV>_MzU1myX-!5E6gw+nmNZ^&8%*~zH;#Bki1?` zFmE>Zm=q@tZS-k_c-KgX>0sZjdpYY2HVqXK>syp(6R5!hqV*3r?^)`m?$TK)hW2Q&7s zh2V5?2}k2}treX^YI=IkL-qlW=}ZtMPQK_KuZr_&Y&Bh--5=1x=v3l6e1>ek<75up z;GJKDG&MVK1J%9GhU=9uz;ADRW&wib4h>o%H{2CNkcpmzTy$bpVBr{?X}FX z*5&71TXB_dXS>>|rNO?Fp}|IEFOkhFSj%EO+30@7N!5`J(LL{7cTU3-x*OMQZuh%? z`xgLaKEwl1^yTBEbds++dn05qShPjpvs=09>9RKy35(t!C)|Sta7wJxe3MB426QL2 z6P2!T8o8R9O6P?&L@+r%ZBR+^EFl+vC^tBvWD zJ3+f=7q}12%*#<4S#1Mf!x(bcPDGDlld{$FLqE|(OHMM7T@_IqUYfUh?KH>!T+jRL z%-cZY?>@O~K@7)cXU7i9Dfmsqe67$qCp#Cd=?U~(Qx&E*_1Y3ggoGout2Y+Xis=rQ zoz#>tc4wA%N(DsK;18LOQSRmTmr*B%XJ;O-l+(-o^;WxWSUMF;LED(a$Ux=K-NFuW z1S-Z-Z4P@_ipF}e2QuHZ;92%o!#b`(N9@VT_tNrBhk>h_L-7oIG-YG35-2e7LbVO1 zux2dqg6~XJQmUiA4}J^9SD!gwNtT?Z1yMj9as_9`Lf!kekF~M+R$&zPqZTn-<3v>L(Qb5(*A9os##*IWac6BNpG>Rk-QG#tJ>vK*zDMwRW*(=G zZDAH>%16TaT0bhrOBa{F7*L3p-uoCRp^rO1UvH*ib2fFPR&cpvNFcM0CLis&8%*As zI{b*sJ*mQJftN^}V`A&u(PHVK7yT0I?vy?<}mlKMCDHVCJeN6INORpnsgih9JU0Y?>%NWUq;9IIHU*@~=; z)E8b{vt}$hk4;j3ji0R;c0>|1I4<>(p-R1Gf}y>3@RIaFn+AT7Rb95%AqDyAhvPQKJ1FoSExQxX zS^W$(#fkDUck=4p=A|pb`!t=5u3dXPpB;3z z6u|6vROZlE5ZCM-i4@usmbLngW2QhFh18IF^A3{9gv3jv+h6YYbIu%E9htc#vU`}@ z?M3m|IknO1z5%{^01ht(OH&~rrqI>UcvPq<&vO0@ZVihIUzF^T9$_=iEGF^o8a`{OHHMf3RdWQT26d=BfSl7z0?+*XPc z*Nd49S7Tc|k1wWU@%{CyF*~WM@1b%o%P*GZ`{L$8KaYq7=pA7=4R4m&o`Bwa%eQtzT+m4AU z2CA7`tuNGeU`3u_ND{J^C>2nkw(}x7IRx@MmT_xOXSj#xqxm%=?q@B!p?-s&Vs^H| zWXL^O`7M;`&oYImgOwqcec$-7xhG6MTT5jAKq+QJ3iQ% zI(C;YDJ1w6pMM(vkoB;H*M*$B1)MzX%+vViwipfT}_TJet6?dALTT{{A*cmK+b98?&Tti9DYLsI8m_g zRQ=KSlKNjEAB1`y zp2z8ZlkiE963t9y5qjtfC<6Og9;mr@bnI1(P6kBeL>mBxOWR~n$E8n600^zKqvJf! zrY|1hMS^uw#v=SbMW!hW-kb$151_e#{Gp}a>!4HyItbB^VHTh!MJc~+ow*IfP)Qa@ zl-`>0w&-%Xnw!gt&7uv9)Ce?EN6c))p61zE$102$87xmmhQ#}99zGh58w^%`+GVy9 zzSj~nQ(0r->`LWpHFYc3b~paO7SkZ~QL&V@1NHrhZBv;sYa|;7y|=@duvg`FzH{X> zdPpYLTlUVbrykcXTO^QK#!5IpQyQIoAF%2N|L?Y=&Fb;e2BRpV74^rO(~_a$J3NAi zNkSD4Tlm8b%qcH~`;DIy|9w?iI@Hwn>H*Bs1QP&NMgCsQbDtN~Q&5DHt|piQ)Il%z z0C)f}4O?z-T+Y}7bc>0Oo`*NplXG{>)F$EFqIAs08#|^r@mcBUCi-4p6){wRi3aUy zeBcl_hw`7QF(hRaG9zHQO0srI-B(Og!dMhr!d;1n!Z~Y-f8Kw(KYL3ua+)c&Qnx(Ig}#1a`5sMq!`G*;~ZI59BOgmuQqbt4C}{j zwr@m~x30Q%03!Li)WDXl3U6`VSu|zf=c;lodK*mSNdBNDvyh9=Q4FyZb)oRuzEJ&o z%w&yJQ*-NPsuBlFO94T#v*+>4QAb(U z4k$Mf`T*s(IO9u0`QOypCw&R&$D!IlxY#{wBTRshhVso-JG!N-x3p55CGK?u1!Ou7!Gp&#lE`+pU?=dNa>uajz%QXld4&|)? zFe-0Mm0}id;NxC^N3B5}Hx%arx?Cx>=hUoIrYR>)LqYEgvQfS2CuA4`o}lZ-HQ!jYIKn(b>!-3&7l{YdY>`+d3v#GbU)`c34QJrQW8k;a6(Wek`mEA2D%l z>b&k9{vX!z%p`v-YB=re)yRV9R6Y&E0bjkTOC&#>HrlAK~cj{Sv=&F3Jbe z)1bEu3~c|X+ntOZJ7)0!_{fc9(CXEOMHZgc2p&t55OjF55EMQ>c3A@WQ%g+A> z+rnU8U}10&2p@1V2)4q`89v2w-tVODi}DR1@3DDFIKCr>Ua3@DK`SB|*h=Vu;AEWP zBDTv6yNzRLNx*Hq;YGk@-XRlV(v4l!hU&yaHufrjjQPFo_7i$ACMNe)(p79&Pa$=U z9z+Oi_t_5HdtCU3-!9#jy%y9u518iVNVhWsy93ZOdr1MDdxu#bIJt+zF-`knI1WjV zzlzzCMO;Lhj0BoOD zZIK#_n`g@1GX0kUa7is=^Zcnbm!qVEi4R~noXo(GlzsTLJ>v44a!omNvi0 z_fA8KA@I@X61@4$fRX_EE>PV(?A4}w7(rr^dmR`HN*X3Z&aWcE!g57c**Dx_eG&XJ zh+%eZSccZk`8W8WntmiDUdL53w#sIjVvy*eENg|izqeF+{ioJbem6d~=~>a-_Jr@{ zMN!47 z{ixrKz>?N{3fY*RHXb8p2D%$~DJ&f&vf0L?>wlZ88hR)9wf(EiI04Ja`R5~L&&uZd|UQ9&QCTB&CTEX7e)*GEEOnou`;kr zQnSBbBq^cs2UtWo55GnCCpvBHtt&6yj`){g%pg7mEn%2QUN&KcF11bovB($-O|>^D=JEgbZNHcdW4@j5U3j666|Lr01Q?S2u3UN z7i@(2^YZNYl>KZ^LAEzEg_sQ1t|ZHiY#n&w`T(~GOak&ri52={Hz`T-Of=wU0=-Mu z^r%k%_7^G?lxwG18ckg+n!Y-`+8qI^QFYUB@*C))p+C){1Qdz|fld~V51uc= z-l|DwNq_)MgmVJ!9)X?I-In|UhhbH8XqH@~gK6n()7IR_2WzV~4=1Y!bep<)R;zfM zb%aybECfc`x3O*aKjxUhh9ZFXn$-OXP9CYi-~nFSJ@CY`!O&)P0u=`imJCQ#>e4-e z-o=f`)^(@^Kt*^OF*P+U0D4H32?P!NZ6r)Rf_maZ7P|_% zdoCV6YSNGfLs%=BG+hGBA)?I@c>|72o&7oV@ksLCH-j6v)Ulh81{pF{$~Z*kP&1n1Rezg%eo)(7M*XqK=akByD(=H)}@U>!9!m zt5N;behYE+_v(Qp0BV+`PCuJb4+Ouk>JyuRfIxp!Z2K4@yXxy3+V3mSL?1XL|=?io5v z@R9hL7gED}2Iq#}-B*JtvHzXJYv(W7HFfoT@bDqhNr;b&$XNs^Q{d7zEV3*8C*u1M zaB%`~dC(*nFyH=DDe{sVn40=Sv+A0#h9wm?*w?jD61?X_@~qGY@$9YN4-YHy*oh%? z!!8M&E6|`s3Y6cng%j-`iIrv%uYNv@K!X#gYmT=6yy~S(Q$hbv>tYRyOpwQ56&#lX z6E!nG7*K)>gK0H_Y#$ICELpaUVTNVW4tMBcP$)HIFpu3tBZ7$uq|*x^zmtXXBJ-X( zw%^yUq)UgHfqn$ChT94x*Iz0dGG?*nExnuJlh6dmC7^J|)*7_I(%7GPkqBo_&g_3y zB3WTY1bu+h0O`TeVP(<DlnlnQk#QMKx4zwT|W~IV@iU~_V(bsS>RdIB@>mZ z`^@*CmI4obR#~*G3s;sPA|yBeXE2f#JVb46_|T#wwP0Oz5y^u032jCo?m_eC2s9^2 z*etYSxV}bbK%)Z+)U&jLh$#VqvstT7pP}6Q4-~+dQ$Y&}+uB+lS%ICFl*k_qlMHky zp}-*VkxTxdwvSu$X{}QEEqq?+Sgu7yQQ+$7m zPm0`_0r&Sd!O&WJ2TiQVh5rni=FK*jX$B6{2JR1RQ<9h3B>4#gBLDE=78!$B5XhmR z-F>T7Ib>?f_wMJoDZv(E5L9S@tmKzmMr?dMoe3`%X!<}tF>SoRaEjZ1I~433Y6FlV z3Wdw2P1yg7TuQZ5?lC1^eg9fhUA<<$H}w)?U5o}+mXI$Zx>mGQtopGz=Gdk7=VCn5 z2habWPK%aoszU4(b-xmX%D{$#fmT>gX%EVuv?t$ID?99Htu!v<`7D-!X#nVgo-EhO zxPlc@)7C~JXC#)Zg3ZG13;G*5lt;gDn(S=X#_x8_ZGu$%9(*i_TFChE!D?z<02y2v zA(5>7C|vdw)a%xj+p319Ts*&)`4Ro^GJ|45xo)CYKFgiTA81=PY$aKw)7e><3-)YY ze=QM7b?}^nZ#J2Jv)?XlJHu-^*~?4VzSMSvqGB#Gu1PD{wcEt5>cUjauF+Zz+D(z8 zu!GIjdMyF>Wgx@V(FzX!*_n{+z60BByUmDJ9peh(Ifv5SL*=n-<8qGTOdCE_Yx|L* zeaVOA%E3Dv#a1?jqpiWN*R#Vn^*X$TPB_IFgigj{;!e^dUd5d}KW^RQoU4vI893PU zo}i>S?r2kz&D`@Wtj+T&CNh=Q7*aVm;JZH8`a|1_H!i@q=FP2%mXi->)>p_85Nyow&EO&EqDQP3 zK@1XX?iQQ!5A4NttEAtCW*b2=CpagNH_+|HAfI%z(_u}%u=C0jPWt%tQ$>5ZM$d8Y z;WNGHcU$8jP+M+2dVfuSO(ynq##mfHa*osnW>Y)}z~uEIWGY6naN>b!MiB+$Q&MVL;ogeV=okY_xB#dEuzgO^ahEw5;#o z;Q8vn%&c4P?uvZ2MqyQ$^ z0gf3*`994g(0c*s1gH}%?N`vRPydniv}LS%q3oD&+=8%Y{J)^+G-G3(^BYvQcJ=_? z=J?7})T+ml#*O5^J=%WF$3Os@7k=Xo`e)32wbISn`Ycv>HN-2g!g>*sFH|u<9CaXK zxJBJdg$87$L_W0tw)+E5=ZZx^pOP8TZR+iQ&5Qq_A{gAc5t_@(j ztcAG(KsN9dtUO=ZeP^i~WSOv98qVlW{MjhAv4ZRWbw^Lzxu##}RaGUpEwob#ftrMXAF7vfpG2NsEpd z7f*z{D72_(-`pz!8Q34;o>Zacb!6@Qh-~utOQ74s_6o4Af6v5+y|gAQ=ps^*^9!S@ zQVNV&PS1G*?U=MHLh*sr4w(v~=aCO1sZ#J&PHh8r6lW-; zTxHsDlRX0@16FN7Zs6n;5#`#R>IirR&N2>45(FL|^g<8_;WiI_B@8|E=t=4%z?C7r znx?>&UA6b!Tw?!iN&}*+57%G&$_;ck_`M>4Uyz_cA?Q}w0Tn9?GqVFMiH6&4Ha7Bb z34Eup;*{9fO&m%SSW>7q+Iv-gul{e*F?z~PT?%B^zLwZtkRZ%{0ImM8_X`+36ff{N z;j-7wu#f@4YT>wfM!XJ|)0>i)(Zv_=^wTCmAwn1eQ2-30;{V~bwIN|QAr<(0%!^76@=aT=boh8%_NSAj}P|&mzvhUJarE% z9pOefFS(NLeOxU2ro{St!U!Z$AecChz)f1KP!I{ys`i=pQ3Oi^F}|-De`m(M za7ndTQ9*w>pjOwx=~`3NCW$5k*TI2~R^8By5< zN| zaZ~Csw6i0>SFdS7t^4jxvRB@JuT#EXdtu^@I4Xc=Y-lOXlAIXx6R&)IFLbzK!lK7h z!a^Pa;T+tAFaSbrj)%QZp!|n-Z8=t@162u@r*pXD0LVq;U@0HW3lvrK{YnH}Z~gS6 z64`x;{~BVv+J+&<^P;|!P9(%FGs=f4prnI6_#l_Thc*A9o?;9D5Fr#4l$4xC%nCIN zjGE3OJz_{M%4?J!WRy`oUKdg;u#GsAGy zk;9fWeXR&LpFx_9B)nh6$K^>9OnGYY}8%tTx9C_1(N3>W(?nls^TB_?e~_eIU?20`i~K|^pI8sG*7)?NwJZ7f zqT;CkYtM^%E{SE_I3}}vxi24F>*0)Sy}|^7)nB~wAiVf)cWCtnSV=wth0UZgJzhUQ_gcAEJbs zPDhYXU+F+C0~H!$aH!#BXGFk)OGdN#EOQ>3kh&r-K!ksbon1+UepM}91>~ggj^Hv6 zd?Z5aTnxMq5_`?e=WrpR^4s*jZy*&*;)V}Z@qvOS?L_V5l*mc-*v=wr z9?kIyhJ}Xf>VHUGvVumI+>BC5wWSj{PuUwVK;<8<450(@fdVZ%L>}=w_#a#1%3Y z9-wq86PDFqg~Z}}&5EoSi5Nk}l0RhZ|5NGmf3*hrYE^Cu>X$R>o8S(CP%a5PJK8@;{kVZ!nzg^C)aTyAa#Nw z6-$E-d+v^4dwsA#Keaaz_)r*|gj!WNB#HRdcUH;VZ``PJS}&jrmb-4wl3mu|=daN+ z?P+qG3Y5h-u$zGwSp)6~^ls-6uobh+Ax9m!iK?QcK^4PN7Xm{A2 zLTqF3s*Qy=CwoN*Na&a-hf6s&%WNy2R&SZWS5*bAp415pSkbAa&nd7^@_4(`p6nSh zuRK%;rWaSWp4dHcJF9r?a9*+MWJEyv#;pV?mjnKnHYH^zFWs7cw=sxS&PFXcbc~ZF z97}oZ?~1+gbnmqhQb@HYc-j$1g?5}y*E_Iya#^=&)q5e#NMj7Wjr}C>i8%MQXA{cT zCYtDLt6eA6)>;`(D8<&_gk~<5ZOVj9XpJ4aw;fLOs;j%uovbwbVYX7m-jIp!{3y$s zGv8*>+rPa5Z^K~1acRi@s@UNmVRoz)s5b08S^3H^+r!m0(W_T34f#I-6*uN*v91)P zA3g64{^7!>2h5$%7a5r(ED#@Mzlv6{+Sgwhf~Da$A(_C*L!ExGq|*qH#zpLbpr{5q zd*(T1M7jD=46*m+$rxs=Y8C*pOzo749#Ruw!i~VJ7`P<-0@W-4F34Myb;Q|=Wex|+ z#dwI*@~>nZpXk1AFBLJb&#%3Ar|;5=>((2EdY&T_-MRvM=iZLFE%Zi`cv%*NitL;g zJ91aFYAF-=_5J(e4+bIsO84H9xrFi@XB%p`DsXJ7IANbR1jSmljY#FA_SnUCUvXxk zE4&WU6sO=-FLtkvy65!yH6v@B+X8FDZ|?JsscUSyYzEUO2fO4r#C-6yEea zJoa`!*#6nnE-d|2d~=8|6k;C1-qofoLHi?nJq(lAVw$2&{YB0N%sP_-Eoh$2hE-8= zokyJ%@5G-w^yT|i-wPGpIc+_hf84H40ViBsbwr2Mz?Derg8}rlRi4F}2X0x?)ki;9 zC*a9!2FnG4o9GX&>@1a%W1swZT260tDGfV7vp8usL%VXEf#n@O+lhJwfmCe1aFXS71P>z3oZ%04#_o##T5FnUkPO zhCBiMEj@E>Z65p1{#_g&D>U$H5NV@Qnujc%Tx1G=DqhxV>WkOCUIa_yDr<7`tYV>G z5i(_lK%W2#oOuVgt-AqJf=CU-NlGuokdgorbDtTny2Ub{4`8wEJ(?VHkLqIvUx5zY zjlI#6O$L7J8MSe;fVo=OOW9zxe)EXLfxLP?CRM* zoJ~-@@g_8Q)?-b;oAl3d*rQIEjxo`lp%JA&#T*mdy(oXW(~a$ebtR(QZ7*hMR-!nD zXZxm>(e7w72BA*T(2i&xLA%edZ9ETFWme_Zh(_`Bm)#@9pI;=-RvT#BeZE-L&pY;e zB9^l{q-Rn{q`;Yi{#Ut0`(|gndVTJ5SOtQKcYpVG)Mu844YSIr24f5Z-pAD~E*q&~ z7L67OT8;6|U9pYmhfT0osCqxWy2`R>DS1?llV9tr>pAMz1Q#+m<;?vj`_7naSpcOq1jVLOebW7+W%Pzaa zNi?lr$i8CV8E^B;Ig&;VAcRY?Y%oUxCQuEF;QWt>-w24J-dfh7OddbDUo`R9dy|?z zW>ly}*NaVtqJvRnzI=KkS=zZIq_9&U(v6ibf-{!fGi^ZEVk?IcrM5HqGwrbmA73&E zA-&b5s5C8rwi`Dud<%nvplZdk2wd#WaDC`$kWl{V|3f0ZwRn?0KZ>)UCJtkR;V1-nvwhV#& zZx6$SeVXZLD)Q%7c>dD@L^#hvw75x}Jr#=DgTAw0Z9Jx)<@lD^?jC1I9{I`m{K-oj zuOm*G6RP9kIl`e_2CpSQK?sXuUJJd0_C~a|;|d-tn|k%wYysaX=HK#tG3u!8B(UwI zbF@;XSOvBB{X|Tw3Y~XLLo!$CE`Gzaq=+w%QtABT?r|CDQxh%w;BMhA-<)1fl+yZj zqfKM+RI71E`B?{|0{ij!GdU@Vsn_qdD-rqLUb`$LMO69Y+6|&$@;B#STHdev5kWgv z;9b2N>X~aSEMOZ~FM=v0DJs}TySKJPY>#w$d9g(%rKS#$IN#wP4ejs0KO3+rzkj`1 zEwlW*qFCXDPAl(jl6Ul;mOKd_3lwHL1yJSToPX)im2|Qm9p9wb;g$jD7;RJ8P1r*pp&UlHN+r z!)3fSAa^1n)^72ZfKj<_>EKAbgqU89%3{_}wkN9sbu%&y=pgw}D{1rxVnBWGIzz|x zPR0nkit3*phxl1IF~zTdnz5^Ho$#D`>Gz8IDy!yKJh}`|!~F1))DOCB-qTXvx3@aQ zFE$oDlOSDjxLkePyJ~mOns-3;Q)$upF?10Hnf78+A4Ta}rIj6`IX=ASByPPQMA*5s z(%=|++^sDp#PyaiYkO)(j#IGR{afkwqJTlKQEcw*9FP9X6pa={@8Cr}*@?N0JL8e) zGgnmox`>~lY|M)=RIF^s(qwZw!pg<^AP_Skb9tU6u3%s|J3-f1GSSDoWRretYv+@E z_9UmLsKwn0j!ot8@dY{4k)pnI&KtsZjwuJn@w;$OCR+}?pmrlB-*=qT@8JbBE@t1K z3yGiYgoFJ~k{mphv#ybpSo_wy>SUCvT6n6oJA~!eYG$D$`5osa{44M+QPna=!>ePi zWE-yzGp4AU=x$-WQkQa5(e`NX;F^}`>@iZkYAU&Hqv*b0K7{mc>UvJ6%E~X8ta5jE z#AA+ioUi6QFLgcKul3wzDlxqT7a~2aP*BU#HaJ!Y+RfriS2Jjh(EAwNN@*0-{z`A9 zkBBNIpqY*{b$wZdgLgo^?Lc^!Vy*DU-HN=#<&r68!lw0RhRRo~8FvWDbotyDi$fya z#|u>wJ%5#XQ!z-+9g7@`PKz#J=s0(;l$79%)-3rxw|>$v;Pt+~S5GKE`>XQBQ<9h7 zDN9PgFar<>>HM*98O%-SYAmS9%HWl4zqFVAQX05Qkg0KeV0-VDm{iZ|Zr!}YP3da53k!QO(fM3L2- zo!OCIw1w)I`@+tXu#AO|^NmgQfdZm=`gz^Y!rC&{3mw$jmya)yXr_u;~0K+ zCbjtdg3MODh}ZeWCN;cUv$x@ktj`w2afi3(IoxKxF$0}h80}$#wkYk{0NH!DW7~1v zT021ElRdx&AQ)~iY@XZfo)g;@+8%m}x?Y)`_w#@?>Dt4~QRaKigBqdktog1+Tf{&( zL8@xBP{g?sd~?P{gkx&G#7^k6a@xm>l2M)!FnBHEf#D(`B%FZ@-R|DYgNw!0GgY|< zjyOIT$+TtT86`c~H8wrEb0O}**T_zbOWKS5I=&rr*|=RVv@E3joT$omXC8Kat4%;3 zTd314yyv{w)hpcRCsdMB@xrz}M#a>`GQ)zRN3;?%%+7Ea?C9vslG0er|GUpY)5H+m z?pN}tjTyrv9$Vz%-GIxyDEZ|})EV2en~(P6$|y<;=C)WpGYjV`?~*xq1*;Em6x=H9 zs(2{cUq~aM>mBdvx(nH^oxItk;Omzf7RB7JV zG$4GHA~B zo+iG2z`KlCvErDc%5mOsINy5y(SY;&RPxg9e}*@_ou- z)%iW&-u5e77#Ty2c9?$K~(A z1A47}%|YCavp=)8SR=|)HRT*C;-s5epCL7&PW0G6OgBD3YTowWfqzd+2R6~|M!MUZ zRSySWTDh|%YjKLKxzM*fvXhkXQ?Gu$n+b$XZ(Xi> z;&&hYI+gP*{xi!x^QXp z-J(qx4c!Ik(U9bJI#DH$1GVi}w4PYS`vq<@=cWT#LXN(g_(FHX?W{h*1b9O5oWZgvPtLI0gGhak5`4)kIGlLLn&&WJV@2amZ5!Cf|%#2d*)oI{rQYkmZbOx^IlCAvJ-VR zg1Re!h|<4huqkyD+O)jSmoy9Zbygq`fazDz*v+g_HQC83nf!TX)r>U)MxewJ1&uWb6Ym4#oyS~jK6 z`e%Yv&o!Ei_*^!mG6(}CTy&iR&{DPZF8U0G{Z?s9P1c95HS9ef_bpRP-~8&^Wm=@E#}Y( zf(nLqN&H3j32Fpmw^j29Z_@85sbu!^{gSzSO@{J9aW_)&5c-14oatO1~&gKA~tDCgCBz_A>gV_)qYn+Xl*y;m`&L%Cla zhSU3Xo*kpTp0{#hf8aPk&p7hL`53M`t0Un%Lu8 zdV77d3g|u<8*Bp1IWedBEKH@Ss7&9=OmM-Dq6>Gv$B9zWddR6!#g3RpLaUO8eI<9l>cPp;q7G%@ghBQ>^hRv_gg6pj1oQbBA@DB`@`F`ah^1*I;`@ zLr*Uam)L)1eG6NUdAYbgLCX?UZ#~OSay9H;br_7`yqbC|>`d2$saTksh9&e&vF$fO zzYPlMQw*c~0!0#bt;GDh(b0j!REPtRO8x7iH0dOu{oy3G|0 zs~wk|ZefP1y{m8Y@@jJt_t_o04NqOYh@` zym6Vu`>v&K$!bom>26MKBJQi1d7mNiF^*`1sHx@Uq$AT5Z7kOf3JW)MCPu zl-)a4edz+i{G)nBoyncgq@?hmP+1?|(ZlPyDP;r@`r9hDVo$GLyT5s?Rqb`5NTiTd ztH@Fco(hod?l|}pM`_&XD}|$70Uh-D<@!4DVb|gz<*Dwl(=SmT<}_7Xmol@>z~#1f zw#@IgXxbtR_|A<`ikmHrb$ub$-hqEn#DUOn^D$y;_H2Efe>48iYi--fx+IKD9mY8Q2RI{dxxi?o=2e9W!2{q@SA(4w6HigR2}z^Vq1>{;iCm}gHf znI>Nc6zAdl=ARnkqHOk;d(*?fb7XAo>g*0!oL4%hf^9hKe9Of`@SHtq*@hhl51Yi; zod+f&*W>#6jWX1go3#U`KdEYvAU171A%?CNbe|81XN{awgBNCTD~iky-G2mH9Lncg zD?*0oK60(dGf2}o<;l#gsrz%_CoInihlRRaf`YGMS^k;I*F)exDk^7?3SCaPF$cCI zy#qxGKpXHbSkMYP<4xc`YqsnPCZubBd+|P8DUd|UYDA2)YiqFCY-xpKR{0T{m@s6r zzm+O}>jeG*%UI))PoF>I!mY3%)G0=1s%QR+{smip&r7nxl0il$NmyL@&y8?cK{-=Y zebOQRL{(EsPi@mzfLAT9K2({o#oA)|ur8yOy7slpv(_bHD{;b>hSaLgjB-bEvEmh? ziX$i7`1tI}c7a{PnU+f!Z~jryr00FVx_nlOFR166s%T&?Xy+5nkLp^E9Wxb)pD<;R zl48I>p!Abq)T^oW3&6%=*bD5%KkR<&>cUfntv`E~{y9@a^5unvYGPR#5d`ct6le8| zTZ&dvY6I38TF%$`ho9#QC2)6;4(JhQ?}{Kbn0i6#Owy9Q6wZVXbIV7vyIY3(`UvdQ z{@q=zmv4IMxJk$Pqv#kQjPLL>*W!-j%An5gyn%R3PPKOR6%xn8LOTe~-xo#6tmHWA z<@Ps08NfK~*7|=$T?JT{+tPgrB~(B~q@+YjLb|(=P6-L=ZX^W-1eK5$Nl7V@77$R7 z2I+1o5fJHye?GnUzt6)_&(R~_yWhQM)~r3VmX7oK%tL!teB}JF-_hb$aZz{3ceu|h zF{MY<(yoHxGx|>5Otkp*uVg7_!gyzVFF_^sVxl20SfT7RqIO0(9cZYAnu7toA$Jr9 z2+JT;QM9(}Oecp&F23=H??biKRU&R>E$7SOM4QWsXySE_Tb8UJzuFcz7teIiT=T(S zt{R-%EH30^!-u_3e^dj|_=dWJ}tW~^8 z+2BhQpiMn7oRc!To$62F?d`MKf4|}D;q>;b@VSttjt;fgAA-r>(w{buR?ABY@_Ppc zUXeL|8ZHcVuVUqxxZK6#v9j8!>YW5gVY}xX8^|^;AKo;1fybY5AFt9F7?hs2tlQh) zv^lSz84Z51DSy(vn1I&1=a}yQYhN9r%+YE?D?rd7`2T0$YI&~jiq86rZV{(K5Y*FK zJD@!G#DbsY_IGF~W>DW559nIpBbwiGCwzhZW0B@hr}ktPYa=T=ya`4Z2~hi>xZy(`r6b_aNzcH& zBThrIB>yAVlh%QrN8P`)-haq%q!&432Xuv^R)zNJ&Wf6AUopfq)U-Cv#4lB`t*GI`A$dn8f@`zpB<%}kD_*e`rC>CEZvp7eb72PIHN3?DShIyLC|PB)wV#t zSs{0=XYE!ajqRP!l}rZJ2~nvWepJY?>E&O>-y++S_qD#s)I9Rx z+=u4ngfS;0OSaT8!JzM_7f$#EpQC6;Lw#=4e~CyKz%F(-wbE?%KVPJ_`%QX}vbNFh zku-77S~t{Z@5dZWS;HeD(5MKtUd+wS88z&r^)&Favp?P3bjCsOLNLH`x-=(8hTr$# zW7qPw6Ce4bnR-79{T^s}HY~mgV$smB0RF;kAaxvP#({&3g5M*FB4;JRpI3?9yiA7; zm%)|^h{`#q2RNAMm|(gS1|1~O>)<6sk!_|ff^vw}Cdb19GdpbR*yqnL-bv`xs;}M< zQ?YlyVGuFa6Pg;fFnZPC&yiz?!?y6|U9OFkv8GM@BbS(&28-bm<`Mr(9l2*m7>?KB zh2TfdSF|3cn^M|O;%oHSKnby?849t#Uug=v*k1ZrJ}6D}#qPXq6fLZ{mC=O2{267P zH}PD;z`j+7|$?sLyqbzqe9!U6H(TE%Q2hqTn|?YLv)Jwk z3vlBFefZL^B>8@|J@t=iY(sTalNmfA{ko?18KtcqUJPnW4$K!kB?3m;snvDk2YrbM zltxXsk6B=&9+(SZA@IyEVS5~XYhT0YHJyIsoD4Q>F!6T*;lQ0_t@%l_v%3q<#-RVB zMi+AxWX2bEy&2PblBhR-G{3rlfQ{)L(r_qrg1~7SCqOPq^(BURC6yDtIZts|qEge` z$DQL-4&uv9$gG<{_K;27Q!$hTQ)PKM$m@-Sg$zKi1yxdaM}56WV&`I3Y&5j!u?q7LGOL!Zsf{q4rc%pYXu*W)`K-^8q&0J{Nsp1ty; z-}Fov)MPyiKo7c(d(rDBb%0gVUw2FvHNok<7@hQa_-sQ`o zsi_p`#@{zE#k6&FI^*MsxS?`u<18IxMoS0JX*4P*-VmAhT_zm*_dZD^c9Ufa=n69C zDz#yWfuIn;VzV9Xz2j3Z9v;yBdjI}4YVc03YC;J*$biw{;pNq!ixFo{kOV*ty2+XX zcUKKOd#f%d{Pw<5{pfJ5Y^CC6CfjVt@#4sAXmjW9%uAwpor}EBSWga;?XlpgP>%+i zxcH(TQcnJupNE%F`YDqp5`*;Rc8OGQ*1-d-mliM-s1haXQHzn6Sr02(U5Xb5T=7qU@y-6%~+$ zO;2c=)w+{AATNQvHc^Golcc1;2;Mah1e5q$Ll$pbQSvlzZZ3P?alKN8R#GQc>#mO7 z3IF??Cl0*N{=EtK%(-D|DR%dc$S+aH8nDvCX~v@zv8$}JvbS&U?R|}aA|O>uOAAVh zj}W&Vbfx!{c$S_6=UECiY(GtVwx`)WY@E`&mcobqQ)$b!?_NW|CIcNg-rt}=Cv ztL}MUg?=#*Lv^a1Qq{UKW=Dh<5*W|=5LZjfoF_>OQGKV%cPIByn~Q5MT&1WbyVg+B z#m=qe6h8E}&)$C!rsi? zy`r$V_$K`A-a-`c4K5&)4pl$lyBzPWOZxkZUX7xqQ8O~~?@-?MviK-h;>}3~QGy2^yW~$twB2nkj@*rXfuO;B3{z86la+%59cnN`3>+NJ zMYS|FVG`-ZuU|HaaA%b zSTCMNiFLNcQD|i)1Rfy&`QUy(J?*uySu20M)4`vYEv68eN^%|U48PI0>4NW(E$bZK za2Q)CDRC(#8#5>w+HKkHj~l*jnnSM2Q)5Cm7WD6Ic?yB#8gN6P;kNAsUBxXu(C-D? zxvfofVW*xnEM@W2;)Nt$nPQYiIfWcF0)x&NL?tLVO?Jr8Q9dgd1I3YaO(yd_BRZOD zw-t3bwWTE`QO`p{VAb*cJ~U|5;LB8Osd&#w%a-~K^ALxXXskkgbr53jqq1+&^&+8u zH{nm^V9M`qSQvF){ILxW!)y%3y$Ex)VCn!Q|)2S7dFf_9mFOL1YXJ41`iT zdSE3ML3zpP^>HFBW~|+k#DYoAyiPo6IStco*s*A;_lUu(HMgXMos^O?7hWV6h#NT$KkwtC;qzzFMiH(LS8pb<26cRGcB|#L-AjD(TcZ|mfO9N>K zR2AeLeZ(EA$g~TFN*!lJa01pm1aE+~0%JB!w&vdF3Q-Ls!G@pT7X7Ti^ z9B!AzP33hzY!mK zJd6wiTtIsbcB(KK9li)1CeR)SLwC>eZFG{bsjUE>y>zaY^Yar)Pt|CFBZm%p!1ND|KArUR ztXvLLOU@6f7TXHN3aslSW|~Zl?neC;Ll06-)#H`QlF z2suM%hshlJIJdm(AW!Ffn)+S(V;&Y9y1NlEHNwQ6<;Au(?9F$H|DV3-B^9i{-^*bj zg6)EVta;R8B3fEEs4+pK3v^K~1dx-FX?!*;PE`8mr33T2aP<&_I<{7)N4pB}R$4Iq z!dM8&8Ojc2dJCdq>>M@{X22CP9#v+d7#9RsdkkGPwxLOr_zUp8WZB*eMv(`Muvt+U}slXCxRCUEdp{cSb)kQrf2{l zfLw;U4TMhyyA$*qsm8J_DY9;kuvZ$RRwvH68Y^eqP?h%D*BqrJDKI&Oc^cs5)Sz>@ zXIZl30oUzE0QZ2bKmgk#0WB>u=mC%gaK3z24F3NGTJy|`rRp!mq!v?GkXDfQzM8}( z@hGA5!oQP_36k#5z*hX)tLEAL<2aZnlB1P?WZ9DCD|2=gFQoke77!aM9j!|-gOTaz z1IiW32pD_@((e>g=^Ya&+_#kZHcXxMpfCmUOA#MgZi)P$jl1Tv5HWM}Y^I=eFt?0sy^WR*)e7A;oPQL%)IDNMl#V!LLc>SD zNNsQe6asBqzIzzJ(GHL8w|e|eOi&Wb3QHTuN;o8pqQje#2EaLhqFIkxm=;em1saEj zW^8gY%(X{&ggEJcE0RUb!WM*E7Q%5=o+cWY33Y67=+3B_a!7<}tc(l^bUhDt9v&G` zjf4weOay*bU{vd0lCXAkOdzK#3l73C=W&d1{=-E&oSBo;vKFBne6)yA;8D*IcH+lQ;Y`*0HACMq6})>7y-o^u-*hP2g?9& z=Ap-!IZc#Y8w9!)H-Tor@4->_un?(@b02lzP4@Mv9dVjk3O`{lJCypO=?^4d7IDlL zFiwE*)u{-+0CGb|KwlDRTlcsNoD*G46zt?Eb#-VHA!0KuynOlc>-_w9UlF5{^Qoq~ z>oL-bL@G0Q9REG%YilMY1msHG#3=+n*H`LJH$I#t(aXxn3<5;7=Sk8X93CHMf}8C= zSzY9{VR1rqd)8=;dqK^UQ*VxO!_QkCcQZ0GG4AMCXux1F_%r6(%aSBXqRtrC>8{0z z;+)f)RKGo44sVo52h1rz%D_gTs#H`~WF!-=ffPDe zR=AMIq=Ffd`jbVr0*E$!R)g5DFCgVrRVoeY6LxT=KNdm1XqtOz#fSrX?n%Srr&$4D z0d8tr-9--|zjdnu!2C1h%bXJo2JfKcp;ia4hVuI~N%d;hSUKE7C3Qm* zdI5m|PC6XJ=+Gb$x`fI9rIH|der`r1>Fj6lN4U7TpN7&Ruob?%SKadz0ujhiGY;$W z6)9#SPW^|(%`UL<0gy@K$L7`5rQD4fd)A1mNJ`Ddg-#2TG1H-{Lc{>ULm><^um~z9 z(L~*|RJnk_?w3|GV@-&WqRoyS932gXs3f0NclG}lC@ck=CAGI>Ad5f>)ewOLhtRC# zLC}`Rw%Y}Fa zUtr?oVrPe9C~RzOXc12#3Yf$PoCP~y9$OqbuuvymL0(;fl!77$e1s585t;w3jFk0= zfbEcV7K00Tf5O!#5&(5Bumk;2%kw00yWrx8MTCVhJQWiomUf0j4mAVRIq;SqRN9eL zR8`$S#kK$S^XKfhXDq~ja|xngDZqWEQu07?cyJJPkV#%O$AS(7{0ap3z~-cTQD0B5 z>nos(U}tDlMbQEOB18%%QzE^}`+RZ=ig?7hY|OZ7q6ARXCxZ|DC+HNBA@J4*^*t1X z<%nANU_uj;{&2BTF`_r2B8+8XCq65GIQltSUBXf8U3W$}c4AYb5o8e}S7DLG z732Z@ew$gMrg5ESXaxC}CE4D^$u5DEHgDai3z$lv8L zaOi;m9<6Y6QLX*_4l@4HByj6@?FJLu=ReQjh6)q8efzcoXtPAN2Erkc#ui5*>~@E$ znEZSq#ydFFO#cw1ez2?F+aS9ElU>kD6PbOb-sy-jKcu z9~cZxHEeBBYX1F;E?`$E2O%Np?OV;dso=|)=pXfpt|8Ei2NZ~fr=2FNuobiGw{B9vOKZfn=O$uM3SrECED{7y;8NV>CgksGwySSvRI{ zq#y38N4oqt@_(ODoAke=)EJObox|W%Rj7Rz1T0OeCN3aQ#H#7)GC~%B6i|?#FU65a z_=YL^`W?x)?@B0WB-4^ro)yH7p?`&~?`R=idR7Lo%AAW-3ju9m4N&1mq#G2a(7=!s z3;Zn*D9=<8s5$lE20Y&;I9A>3Z#_W@Sn$7ZcJu`aIr%fh#H=v1zA419!Fy_){!;KW zDjHyTf|}-DOJH+In4@$@`j@wH5Gxy-Yam7b)ws7%6}p)%dLZFn_Io7a6wIN|;HfUc z;~3WQ=oTorpi_a72H5LD?phojB_+xQJ^AtZE9+&sisDa8f8<+91~7g0%?&$W(jPV<(4_8_5$Ie)Yp;!$vC6dTBN?X z)M8RC3p=fswIdg;B2!q>nXj1SibjNXRIXS}_?4@E+HuY{B|Y{ocTaBfEDPuNo4B8l zvg)L*VDAUgeAGb_Qq7Px|RrUElWpU;MLcYng)~x*0-%2K7-59SV-WL}=FUtF!Md?#M z>vrV#y#J%K_a6g+hzy3M{p|g?X+u&9vUldzp8Udt%vYY=A8{-!c1!MGj9BzsO(NR9_0nE8M((G(XcfpsRg7le8mwigsp=VoPTuV*$wW;SHGNbeL3 z*^q~Vt%4L_;nsOIP~u<&S)N4~`H-e}pl1z?G$8Yr`T39mW49!oUuHcZ^xSa5Yo=+y z|NAym-XD!KF}tLmEOdupZ4uu}lL`*5y}+?&a*v(yo`^H1dj(uE2voOy!{$4drTe(a zOCr?A9WY6p7WVcMRMiG`g*f{LmDsQJZ7ekX{Ry%ob(l4(R_z7vq+e)gS`N|Ft=3+*ET78?~$2U=f_`Q=G60^mW{#ZWVGR`~o zVHPQIXWKhH?%9!O)_&QKrIi;2T5kB9#`HBDDbCtn>l1x|S9U0(F4S%TB?2G}L@9(uOi< zfnG_FI0Hu>j0Av7^q$Qur|!sI?_kEa)f@TR4bKn9rRYYFGZJa4D7IDt1|PwN&3Pz&p-LO{{stW(Z<#>yVfGJ8nbf$ z;c^<=jNksl?K1!mFz^ibD=R3X2Ny>3R6=hWdZGcOYI(#CI~+>caF0|3X_#TFa+tC) zqX1|!K!0MoX4h;@n52?LxfcGIo`wb>&@}`&J|Mz6Ad-dy1OZqtl7??stV5a*55Cs~ zQRNdAypiwTQ6mAd>T|DT|C?>Pb613;V*=-oZlTjwMAk1A+TVG{)*b4x>#Puur64)c z&mQLF5ifsuuj2cbyVX~gj%PZ$-aX~q@x_n#^ut1l&2Rst+`qp!Fj|^NT9zjp|NDC4 zgoy2WrJ+F_IkBku^&MSf=%B?TnSXniykkI$ZeyE-yl zFqQSDisWgwB`)1(@%Q5Dx{w; z@O@gv-G@P?V}+pfkx^f72s;``a!jN}oa{)vy1JSKxhEtP2FUqTOqkNgdC!Y2rP=?~ z13&eN2Nvod!-%u18`)*KK4t%T<7 zJ(qn@cBb)BX(v z2i$}~+(K&uwi5?+gG9P6xEm%JuiRDz5zq!|Xs(3p!LSsINy)@+dM2Dm90aObK+-@Q zflxRIf``30v@$abg7yo5x?MnW38X+MM}-Wa;Ne#7K4W%t&vLhog@{nk&bhRyr|pb5 z5-_9(I_GRtA)*_CL6NM&_~+|C7T1CQ?$D<0p(VaAzrQ{BNha9Iy5L3Lui3m- z#c-nN}?Bk;XS?!5rCppV2`B z7j=+`F_XG2SfPDHUX9Qi{AHld`LXLg1h=)@c@Prerd1XgCUmjzzw%w?va(nx==`(U zJ&c7IMS3X7pPOyd`3-9TDPqm_!RWIm`=PH0G|=iA8LsQ>;3E*Ge9OtMAjwB_ zAMZC1UUx#;IZsC%_h*azuu4WJUg|d-@lT#K-*w);S^Q1O=<_v7eQ%Mo&l?1(=RG6J z`xgIU0Y-HTxJH@RV=iykMk|=CqiLKz%cgi9?@OXp6CrR(WL!PChUt~pu`KCb=Vc?F zCOh$WI)};?UA^N9z2zO2W!b@M4-NVa8!y-GPP{iu%&R2P=v1NXyquQj>uY*0zStRm zDs(^T)mUXN*F7S2{DIwHfAIAV3c}x=Ht;d-GV7B9cZ+jqT=D?OanNa709lJ@Ioj7? z3keOFk*(PSD}+#6A>%>5o$)N0aHToc*C#H+e#`XI1+0#>a>x;(QZ!J=$K5&w4)T>0 z4K>vMP|U}|?KiMn64<|1R@5M;LTxBTTclk;PC_EyZmoRr?Dg)t$Hetr)n7xJQWvPvBT~NnQ+sm1b zPVy5t)|K82SKd|>k1Jf@Wi4oJFmPx+6({?BqImmkUyrOYfouMn%I=`T{T4RcOD20# zGKYRkH@nvP#IW|z; z(Wa2O$AW5}2AvLzGI4BiFboFdL-0(9B2tr38vV=6+4>CWX-sz!95Mwc7oc2p3~G0+ zEDB3u{Cg$SLRR?Xbv(o>1TInAjPE`0aDhukQ(s?lXp{Y0P2j>UsY8`MBs4J}OSPS) zy}{mIs%j85#E>sqh6bnIl1$d^6&1hB{=Y>+MITdsYmHA8HA?!6?DyW4)LIgX`k5lU zMde9VcU-R@Y&w>GIxSAlb?zJ;%Q%<2+Tg$6wlW=7_B~frJtV&7@Rd+lPwh(%{V7B1 zij6+MtiERxI(mJ)`9F*vd99H#pd;iJxbfq6ow}p-%7&WPE@-)h9p|uZi+8E8CvbV2 zDcWA*(7C>uIKOiJ1Wio(jyy^k33cs;K*7K%rcEe~OU(dQvQP}8iq0|7F<|e-Y}fP7 zfth-sz!>h8#aC{8=^{*GiOAE?2y3$XqFfKRSlHy%y4-uM1c?W%(@4RcnyHS z5I;E{I*Nh?e3F=unVjP%U}}CG;{0Lasf_KZT0-83{BPAoc%Kt~3;y=Rz~EO@RP0)$ zWA)_b9)b@hFqQNbf>eoDESZM$R1e-8QA+aUFTHPGRz%Ww#3w$PczcP|Y2Wp;yiY>N$=jG^mt?ER&^K+4-w~A3HNJ)cH^gyBl_{hm!GydhxhBk;rA1KZ2v^bbRJWfvepR} z{t%5Z{Yfd@Q6k&8)?H}c$++_4~W3G1}F3cv2{p&=IKM+Ws*Bs#cBz3xftk@f2B2eX(M&%%4u&l0v*{*Es?)apwVppVF2R#NaWeDhAuiN zLps)L00IE<=TB1($USc&AnZe+s0M{8b6`@_ejOt%^bx06Ht+5|o0~fkvhQvLwl)HK z=`^)AvqDhNFQmEm{mWZexSnep-{De#PxZ_1X|>6n_B`Chsk+RAmDvEViQ;Ke370z(u4FcigDT4PpGJayVb@v@Mr!=O9Qrin$f}cJcj4tf|!2Z&cNCo z#UDaG1@%})@PHPh3V^>AMThNr_f1W~2`a_{pMV3sBxn-$mESUNk7ZuPNxHxb(!lcR*pOsl+LqEq8496Mn`!+rA?~TpA*K%sF9`a=5 z=b0BN8rMe;D#c4!axGLD-+y|3t0QkSDF0dL0`T6|GPxSIDrQ~lz-|GfTvk|JF2E1; z2xw9XxcCD|Twh{zD^or*b{Zu<1xD7W&7%O-vV4n=wC`H@?S4{3=X}7Gd4D ziaNB8>&qDdcP^(|R?N0#eS2r(-r^@qcjrj9IjlHl$mQbm_UDlgBB!1O?pPn>_%EfE zIeFyIU&zld$cA>Dw{HPOkf4=jut6KkD`14>venHc0fUo-%a~N?p{ne6DlM|`@NvKi zy1BVInSCXS<=hW#Bw~7D{mzAfFaxE>#z27G3!UT8vjN2XHRNS8P4$!mdO2qxq?OFf z%yTz1rdOR+{? zeaD4ud#!`zo>ZTHQ|^%mfBHxKI3*VQ?;X1Bnpmtgb^Qd5*iI|U^D~BsWGUO2?7a>W z`+Gzgd7nq$%k%Vmzlm#iW#><0mS?1+ql2lvjB7COLC!ou{_iI1A!`Casc!95U~Vlc ziNa+Tflh$yt9Mnk$W<@|57amO7#svYcx5X&=sh6_m!g3>wBO2*5*rif?%UudAK7Y3 z@dmCm07Vf<^unkfn6YbskO_eV0}P71X_7a*8T2b(!FSHJU~{KT@Q+c$RNzB@1>=&D zE6C5)o;am%`^Ă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

1g36m(;5aplTVvH7?(eAkO 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­emE;@dU{)dQ(OtbyFhm?$H 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

sPYFb%?qj9u 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¤`WifSX_us`U3IaG}o&x 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 Ymj1c5(1$asK&e!bRJ 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

    M@46n@D_MZK*gpO8HD;GOpUHV7A z`8MXf8(G=juqARgWp+5i>~{vK z?J-_wre0}qLvvIrHJ+$4rkwie(RUsS*Z7dPZ6wiyqe}f@%E|%ump=u)eRKHC&e{~@ z{KsY^>_ABYF~xam?KnG*^kC4SgKtX1*;hOrj=%^49_XVx{;|k+-!~vPn>17!bQ)FW zU1rON_8qu5pHSF-`KrbY@|aHxJD-fC$yJx^DRr0CppDs=)y*ST5W(7_}>zg&gYkZ1Dcxn!T + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/overview/pages/landing.tsx b/x-pack/plugins/security_solution/public/overview/pages/landing.tsx new file mode 100644 index 0000000000000..0554f1f51c28a --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/pages/landing.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { SecurityPageName } from '../../../common/constants'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; +import { LandingCards } from '../components/landing_cards'; + +export const LandingPage = memo(() => { + return ( + <> + + + + + + ); +}); + +LandingPage.displayName = 'LandingPage'; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index da36e19d20a55..e5be86a1c9f91 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -27,8 +27,30 @@ import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experime import { initialUserPrivilegesState } from '../../common/components/user_privileges/user_privileges_context'; import { EndpointPrivileges } from '../../../common/endpoint/types'; import { useHostRiskScore } from '../../risk_score/containers'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; +import { mockCasesContract } from '../../../../cases/public/mocks'; -jest.mock('../../common/lib/kibana'); +const mockNavigateToApp = jest.fn(); +jest.mock('../../common/lib/kibana', () => { + const original = jest.requireActual('../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, + cases: { + ...mockCasesContract(), + }, + }, + }), + }; +}); jest.mock('../../common/containers/source'); jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/containers/use_global_time', () => ({ @@ -129,6 +151,9 @@ describe('Overview', () => { }); describe('rendering', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('it DOES NOT render the Getting started text when an index is available', () => { mockUseSourcererDataView.mockReturnValue({ selectedPatterns: [], @@ -146,7 +171,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + expect(mockNavigateToApp).not.toHaveBeenCalled(); wrapper.unmount(); }); @@ -279,14 +304,18 @@ describe('Overview', () => { }); it('renders the Setup Instructions text', () => { - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/routes.tsx b/x-pack/plugins/security_solution/public/overview/routes.tsx index c4fc3a6678c51..b4aa19e1e9bc1 100644 --- a/x-pack/plugins/security_solution/public/overview/routes.tsx +++ b/x-pack/plugins/security_solution/public/overview/routes.tsx @@ -7,9 +7,15 @@ import React from 'react'; import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; -import { OVERVIEW_PATH, DETECTION_RESPONSE_PATH, SecurityPageName } from '../../common/constants'; +import { + LANDING_PATH, + OVERVIEW_PATH, + DETECTION_RESPONSE_PATH, + SecurityPageName, +} from '../../common/constants'; import { SecuritySubPluginRoutes } from '../app/types'; +import { LandingPage } from './pages/landing'; import { StatefulOverview } from './pages/overview'; import { DetectionResponse } from './pages/detection_response'; @@ -24,6 +30,11 @@ const DetectionResponseRoutes = () => ( ); +const LandingRoutes = () => ( + + + +); export const routes: SecuritySubPluginRoutes = [ { @@ -34,4 +45,8 @@ export const routes: SecuritySubPluginRoutes = [ path: DETECTION_RESPONSE_PATH, render: DetectionResponseRoutes, }, + { + path: LANDING_PATH, + render: LandingRoutes, + }, ]; diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx index 41cb19e48e94d..e3807f359a0ff 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.test.tsx @@ -15,6 +15,8 @@ import { SecuritySolutionTabNavigation } from '../../common/components/navigatio import { Users } from './users'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { mockCasesContext } from '../../../../cases/public/mocks/mock_cases_context'; +import { APP_UI_ID, SecurityPageName } from '../../../common/constants'; +import { getAppLandingUrl } from '../../common/components/link_to/redirect_to_overview'; jest.mock('../../common/containers/sourcerer'); jest.mock('../../common/components/search_bar', () => ({ @@ -26,6 +28,7 @@ jest.mock('../../common/components/query_bar', () => ({ jest.mock('../../common/components/visualization_actions', () => ({ VisualizationActions: jest.fn(() =>
    ), })); +const mockNavigateToApp = jest.fn(); jest.mock('../../common/lib/kibana', () => { const original = jest.requireActual('../../common/lib/kibana'); @@ -34,6 +37,10 @@ jest.mock('../../common/lib/kibana', () => { useKibana: () => ({ services: { ...original.useKibana().services, + application: { + ...original.useKibana().services.application, + navigateToApp: mockNavigateToApp, + }, cases: { ui: { getCasesContext: jest.fn().mockReturnValue(mockCasesContext), @@ -71,14 +78,17 @@ describe('Users - rendering', () => { indicesExist: false, }); - const wrapper = mount( + mount( ); - expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); + expect(mockNavigateToApp).toHaveBeenCalledWith(APP_UI_ID, { + deepLinkId: SecurityPageName.landing, + path: getAppLandingUrl(), + }); }); test('it should render tab navigation', async () => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f1ab772dbb243..2395df6d2d901 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25211,8 +25211,6 @@ "xpack.securitySolution.pages.common.solutionName": "セキュリティ", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "{ conflicts } {conflicts, plural, other {アラート}}を更新できませんでした。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } {updated, plural, other {アラート}}が正常に更新されましたが、{ conflicts }は更新できませんでした。\n { conflicts, plural, other {}}すでに修正されています。", - "xpack.securitySolution.pages.emptyPage.beatsCard.description": "Elasticエージェントを使用して、セキュリティイベントを収集し、エンドポイントを脅威から保護してください。", - "xpack.securitySolution.pages.emptyPage.beatsCard.title": "セキュリティ統合を追加", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "ページが見つかりません", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "ページごとの行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "表示中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 51c4915baab29..6d4465ae16487 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25241,8 +25241,6 @@ "xpack.securitySolution.pages.common.solutionName": "安全", "xpack.securitySolution.pages.common.updateAlertStatusFailed": "无法更新{ conflicts } 个{conflicts, plural, other {告警}}。", "xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed": "{ updated } 个{updated, plural, other {告警}}已成功更新,但是 { conflicts } 个无法更新,\n 因为{ conflicts, plural, other {其}}已被修改。", - "xpack.securitySolution.pages.emptyPage.beatsCard.description": "使用 Elastic 代理来收集安全事件并防止您的终端受到威胁。", - "xpack.securitySolution.pages.emptyPage.beatsCard.title": "添加安全集成", "xpack.securitySolution.pages.fourohfour.pageNotFoundDescription": "未找到页面", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "每页行数", "xpack.securitySolution.paginatedTable.showingSubtitle": "正在显示", From 416580cfa44be0564136d8e1413f7959a1a4946a Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 23 Mar 2022 21:34:00 +0100 Subject: [PATCH 093/132] [Monitor management] Added inline errors (#124838) --- x-pack/plugins/observability/public/plugin.ts | 2 + .../get_app_data_view.ts | 25 +++ .../observability_data_views.ts | 3 + .../common/runtime_types/monitor/state.ts | 4 +- .../monitor_management/monitor_types.ts | 4 +- .../uptime/common/runtime_types/ping/ping.ts | 11 ++ .../common/runtime_types/ping/synthetics.ts | 3 + .../journeys/monitor_management.journey.ts | 2 +- .../e2e/page_objects/monitor_management.tsx | 2 +- x-pack/plugins/uptime/kibana.json | 1 + x-pack/plugins/uptime/public/apps/plugin.ts | 2 + .../common/header/action_menu_content.tsx | 2 +- .../action_bar/action_bar.tsx | 2 +- .../hooks/use_inline_errors.test.tsx | 83 ++++++++++ .../hooks/use_inline_errors.ts | 110 +++++++++++++ .../hooks/use_inline_errors_count.test.tsx | 79 ++++++++++ .../hooks/use_inline_errors_count.ts | 48 ++++++ .../hooks/use_invalid_monitors.tsx | 47 ++++++ .../monitor_list/actions.test.tsx | 2 +- .../monitor_list/actions.tsx | 25 ++- .../monitor_list/all_monitors.tsx | 34 ++++ .../monitor_list/delete_monitor.test.tsx | 4 +- .../monitor_list/inline_error.test.tsx | 62 ++++++++ .../monitor_list/inline_error.tsx | 51 ++++++ .../monitor_list/invalid_monitors.tsx | 53 +++++++ .../monitor_list/list_tabs.test.tsx | 35 +++++ .../monitor_list/list_tabs.tsx | 122 +++++++++++++++ .../monitor_list/monitor_list.test.tsx | 1 + .../monitor_list/monitor_list.tsx | 13 +- .../monitor_list/stderr_logs_popover.tsx | 55 +++++++ .../browser/browser_test_results.tsx | 4 +- .../test_now_mode/test_result_header.tsx | 12 +- .../columns/monitor_status_column.tsx | 27 ++-- .../columns/status_badge.test.tsx | 47 ++++++ .../monitor_list/columns/status_badge.tsx | 70 +++++++++ .../overview/monitor_list/monitor_list.tsx | 5 +- .../synthetics/check_steps/stderr_logs.tsx | 148 ++++++++++++++++++ .../check_steps/use_std_error_logs.ts | 65 ++++++++ .../contexts/uptime_refresh_context.tsx | 4 +- .../monitor_management/monitor_management.tsx | 58 +++++-- .../use_monitor_management_breadcrumbs.tsx | 3 +- x-pack/plugins/uptime/public/routes.tsx | 2 +- .../search/refine_potential_matches.ts | 2 + .../hydrate_saved_object.ts | 17 +- 44 files changed, 1302 insertions(+), 49 deletions(-) create mode 100644 x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx create mode 100644 x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx create mode 100644 x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx create mode 100644 x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 3d2505ed80513..9d483b63ac0a9 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -47,6 +47,7 @@ import { updateGlobalNavigation } from './update_global_navigation'; import { getExploratoryViewEmbeddable } from './components/shared/exploratory_view/embeddable'; import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils'; import { createUseRulesLink } from './hooks/create_use_rules_link'; +import getAppDataView from './utils/observability_data_views/get_app_data_view'; export type ObservabilityPublicSetup = ReturnType; @@ -280,6 +281,7 @@ export class Plugin PageTemplate, }, createExploratoryViewUrl, + getAppDataView: getAppDataView(pluginsStart.dataViews), ExploratoryViewEmbeddable: getExploratoryViewEmbeddable(coreStart, pluginsStart), useRulesLink: createUseRulesLink(config.unsafe.rules.enabled), }; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts new file mode 100644 index 0000000000000..4b4b03412c0c7 --- /dev/null +++ b/x-pack/plugins/observability/public/utils/observability_data_views/get_app_data_view.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AppDataType } from '../../components/shared/exploratory_view/types'; +import type { DataViewsPublicPluginStart } from '../../../../../../src/plugins/data_views/public'; + +const getAppDataView = (data: DataViewsPublicPluginStart) => { + return async (appId: AppDataType, indexPattern?: string) => { + try { + const { ObservabilityDataViews } = await import('./observability_data_views'); + + const obsvIndexP = new ObservabilityDataViews(data); + return await obsvIndexP.getDataView(appId, indexPattern); + } catch (e) { + return null; + } + }; +}; + +// eslint-disable-next-line import/no-default-export +export default getAppDataView; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts index 8a74482bb14ca..86ce6cd587213 100644 --- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts +++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts @@ -176,3 +176,6 @@ export class ObservabilityDataViews { } } } + +// eslint-disable-next-line import/no-default-export +export default ObservabilityDataViews; diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts index d43fd5ad001f2..74a3bba6ae027 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/state.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { PingType } from '../ping/ping'; +import { PingErrorType, PingType } from '../ping/ping'; export const StateType = t.intersection([ t.type({ @@ -27,6 +27,7 @@ export const StateType = t.intersection([ monitor: t.intersection([ t.partial({ name: t.string, + checkGroup: t.string, duration: t.type({ us: t.number }), }), t.type({ @@ -47,6 +48,7 @@ export const StateType = t.intersection([ service: t.partial({ name: t.string, }), + error: PingErrorType, }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts index c63f5eb838d60..e0205b9362e23 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor_management/monitor_types.ts @@ -222,7 +222,9 @@ export const SyntheticsMonitorWithIdCodec = t.intersection([ export type SyntheticsMonitorWithId = t.TypeOf; export const MonitorManagementListResultCodec = t.type({ - monitors: t.array(t.interface({ id: t.string, attributes: SyntheticsMonitorCodec })), + monitors: t.array( + t.interface({ id: t.string, attributes: SyntheticsMonitorCodec, updated_at: t.string }) + ), page: t.number, perPage: t.number, total: t.union([t.number, t.null]), diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index e78f026277d3a..6208e42868d9e 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -180,8 +180,14 @@ export const PingType = t.intersection([ }), }), observer: t.partial({ + hostname: t.string, + ip: t.array(t.string), + mac: t.array(t.string), geo: t.partial({ name: t.string, + continent_name: t.string, + city_name: t.string, + country_iso_code: t.string, location: t.union([ t.string, t.partial({ lat: t.number, lon: t.number }), @@ -221,6 +227,11 @@ export const PingType = t.intersection([ name: t.string, }), config_id: t.string, + data_stream: t.interface({ + namespace: t.string, + type: t.string, + dataset: t.string, + }), }), ]); diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts index a143063d221b9..c95f9c281dc92 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/synthetics.ts @@ -79,6 +79,9 @@ export const JourneyStepType = t.intersection([ }), }), synthetics: SyntheticsDataType, + error: t.type({ + message: t.string, + }), }), t.type({ _id: t.string, diff --git a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts index 1d6270c00df65..309cc5eb0ec6d 100644 --- a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts +++ b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts @@ -202,7 +202,7 @@ journey('Monitor Management breadcrumbs', async ({ page, params }: { page: Page; step('edit http monitor and check breadcrumb', async () => { await uptime.editMonitor(); // breadcrumb is available before edit page is loaded, make sure its edit view - await page.waitForSelector(byTestId('monitorManagementMonitorName')); + await page.waitForSelector(byTestId('monitorManagementMonitorName'), { timeout: 60 * 1000 }); const breadcrumbs = await page.$$('[data-test-subj=breadcrumb]'); expect(await breadcrumbs[1].textContent()).toEqual('Monitor management'); const lastBreadcrumb = await (await uptime.findByTestSubj('"breadcrumb last"')).textContent(); diff --git a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx index a19f14fa1a6d1..b56cd8a361684 100644 --- a/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx +++ b/x-pack/plugins/uptime/e2e/page_objects/monitor_management.tsx @@ -23,7 +23,7 @@ export function monitorManagementPageProvider({ const remotePassword = process.env.SYNTHETICS_REMOTE_KIBANA_PASSWORD; const isRemote = Boolean(process.env.SYNTHETICS_REMOTE_ENABLED); const basePath = isRemote ? remoteKibanaUrl : kibanaUrl; - const monitorManagement = `${basePath}/app/uptime/manage-monitors`; + const monitorManagement = `${basePath}/app/uptime/manage-monitors/all`; const addMonitor = `${basePath}/app/uptime/add-monitor`; const overview = `${basePath}/app/uptime`; return { diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 28a49067b6698..0ae53fe56b1a4 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -7,6 +7,7 @@ "alerting", "cases", "embeddable", + "discover", "encryptedSavedObjects", "features", "inspector", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index a5e2a85953d65..bf7c5336a8b0f 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -16,6 +16,7 @@ import { from } from 'rxjs'; import { map } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { SharePluginSetup, SharePluginStart } from '../../../../../src/plugins/share/public'; +import { DiscoverStart } from '../../../../../src/plugins/discover/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../../src/core/public'; import { @@ -61,6 +62,7 @@ export interface ClientPluginsSetup { export interface ClientPluginsStart { fleet?: FleetStart; data: DataPublicPluginStart; + discover: DiscoverStart; inspector: InspectorPluginStart; embeddable: EmbeddableStart; observability: ObservabilityPublicStart; diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 985b1ae9146f2..0c059580b5461 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -85,7 +85,7 @@ export function ActionMenuContent({ config }: { config: UptimeConfig }): React.R color="text" data-test-subj="management-page-link" href={history.createHref({ - pathname: MONITOR_MANAGEMENT_ROUTE, + pathname: MONITOR_MANAGEMENT_ROUTE + '/all', })} > + ) : ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx new file mode 100644 index 0000000000000..369aa1461c425 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.test.tsx @@ -0,0 +1,83 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { MockRedux } from '../../../lib/helper/rtl_helpers'; +import { useInlineErrors } from './use_inline_errors'; +import * as obsvPlugin from '../../../../../observability/public/hooks/use_es_search'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('useInlineErrors', function () { + it('it returns result as expected', async function () { + mockNow('2022-01-02T00:00:00.000Z'); + const spy = jest.spyOn(obsvPlugin, 'useEsSearch'); + + const { result } = renderHook(() => useInlineErrors({ onlyInvalidMonitors: true }), { + wrapper: MockRedux, + }); + + expect(result.current).toEqual({ + loading: true, + }); + + expect(spy).toHaveBeenNthCalledWith( + 3, + { + body: { + collapse: { field: 'config_id' }, + query: { + bool: { + filter: [ + { exists: { field: 'summary' } }, + { exists: { field: 'error' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match_phrase: { 'error.message': 'journey did not finish executing' } }, + { match_phrase: { 'error.message': 'ReferenceError:' } }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + gte: '2022-01-01T23:55:00.000Z', + lte: '2022-01-02T00:00:00.000Z', + }, + }, + }, + { bool: { must_not: { exists: { field: 'run_once' } } } }, + ], + }, + }, + size: 1000, + sort: [{ '@timestamp': 'desc' }], + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + }, + [ + 'heartbeat-8*,heartbeat-7*,synthetics-*', + { + error: { monitorList: null, serviceLocations: null }, + list: { monitors: [], page: 1, perPage: 10, total: null }, + loading: { monitorList: false, serviceLocations: false }, + locations: [], + }, + 1641081600000, + true, + '@timestamp', + 'desc', + ], + { name: 'getInvalidMonitors' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts new file mode 100644 index 0000000000000..3753d95b8e858 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors.ts @@ -0,0 +1,110 @@ +/* + * 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 { useSelector } from 'react-redux'; +import moment from 'moment'; +import { useMemo } from 'react'; +import { monitorManagementListSelector, selectDynamicSettings } from '../../../state/selectors'; +import { useEsSearch } from '../../../../../observability/public'; +import { Ping } from '../../../../common/runtime_types'; +import { EXCLUDE_RUN_ONCE_FILTER } from '../../../../common/constants/client_defaults'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { useInlineErrorsCount } from './use_inline_errors_count'; + +const sortFieldMap: Record = { + name: 'monitor.name', + urls: 'url.full', + '@timestamp': '@timestamp', +}; + +export const getInlineErrorFilters = () => [ + { + exists: { + field: 'summary', + }, + }, + { + exists: { + field: 'error', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'error.message': 'journey did not finish executing', + }, + }, + { + match_phrase: { + 'error.message': 'ReferenceError:', + }, + }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + lte: moment().toISOString(), + gte: moment().subtract(5, 'minutes').toISOString(), + }, + }, + }, + EXCLUDE_RUN_ONCE_FILTER, +]; + +export function useInlineErrors({ + onlyInvalidMonitors, + sortField = '@timestamp', + sortOrder = 'desc', +}: { + onlyInvalidMonitors?: boolean; + sortField?: string; + sortOrder?: 'asc' | 'desc'; +}) { + const monitorList = useSelector(monitorManagementListSelector); + + const { settings } = useSelector(selectDynamicSettings); + + const { lastRefresh } = useUptimeRefreshContext(); + + const configIds = monitorList.list.monitors.map((monitor) => monitor.id); + + const doFetch = configIds.length > 0 || onlyInvalidMonitors; + + const { data, loading } = useEsSearch( + { + index: doFetch ? settings?.heartbeatIndices : '', + body: { + size: 1000, + query: { + bool: { + filter: getInlineErrorFilters(), + }, + }, + collapse: { field: 'config_id' }, + sort: [{ [sortFieldMap[sortField]]: sortOrder }], + }, + }, + [settings?.heartbeatIndices, monitorList, lastRefresh, doFetch, sortField, sortOrder], + { name: 'getInvalidMonitors' } + ); + + const { count, loading: countLoading } = useInlineErrorsCount(); + + return useMemo(() => { + const errorSummaries = data?.hits.hits.map(({ _source: source }) => ({ + ...(source as Ping), + timestamp: (source as any)['@timestamp'], + })); + + return { loading: loading || countLoading, errorSummaries, count }; + }, [count, countLoading, data, loading]); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx new file mode 100644 index 0000000000000..c4c864e7720cd --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { MockRedux } from '../../../lib/helper/rtl_helpers'; +import { useInlineErrorsCount } from './use_inline_errors_count'; +import * as obsvPlugin from '../../../../../observability/public/hooks/use_es_search'; + +function mockNow(date: string | number | Date) { + const fakeNow = new Date(date).getTime(); + return jest.spyOn(Date, 'now').mockReturnValue(fakeNow); +} + +describe('useInlineErrorsCount', function () { + it('it returns result as expected', async function () { + mockNow('2022-01-02T00:00:00.000Z'); + const spy = jest.spyOn(obsvPlugin, 'useEsSearch'); + + const { result } = renderHook(() => useInlineErrorsCount(), { + wrapper: MockRedux, + }); + + expect(result.current).toEqual({ + loading: true, + }); + + expect(spy).toHaveBeenNthCalledWith( + 2, + { + body: { + aggs: { total: { cardinality: { field: 'config_id' } } }, + query: { + bool: { + filter: [ + { exists: { field: 'summary' } }, + { exists: { field: 'error' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match_phrase: { 'error.message': 'journey did not finish executing' } }, + { match_phrase: { 'error.message': 'ReferenceError:' } }, + ], + }, + }, + { + range: { + 'monitor.timespan': { + gte: '2022-01-01T23:55:00.000Z', + lte: '2022-01-02T00:00:00.000Z', + }, + }, + }, + { bool: { must_not: { exists: { field: 'run_once' } } } }, + ], + }, + }, + size: 0, + }, + index: 'heartbeat-8*,heartbeat-7*,synthetics-*', + }, + [ + 'heartbeat-8*,heartbeat-7*,synthetics-*', + { + error: { monitorList: null, serviceLocations: null }, + list: { monitors: [], page: 1, perPage: 10, total: null }, + loading: { monitorList: false, serviceLocations: false }, + locations: [], + }, + 1641081600000, + ], + { name: 'getInvalidMonitorsCount' } + ); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts new file mode 100644 index 0000000000000..adda7c433b29c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_inline_errors_count.ts @@ -0,0 +1,48 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { monitorManagementListSelector, selectDynamicSettings } from '../../../state/selectors'; +import { useEsSearch } from '../../../../../observability/public'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { getInlineErrorFilters } from './use_inline_errors'; + +export function useInlineErrorsCount() { + const monitorList = useSelector(monitorManagementListSelector); + + const { settings } = useSelector(selectDynamicSettings); + + const { lastRefresh } = useUptimeRefreshContext(); + + const { data, loading } = useEsSearch( + { + index: settings?.heartbeatIndices, + body: { + size: 0, + query: { + bool: { + filter: getInlineErrorFilters(), + }, + }, + aggs: { + total: { + cardinality: { field: 'config_id' }, + }, + }, + }, + }, + [settings?.heartbeatIndices, monitorList, lastRefresh], + { name: 'getInvalidMonitorsCount' } + ); + + return useMemo(() => { + const errorSummariesCount = data?.aggregations?.total.value; + + return { loading, count: errorSummariesCount }; + }, [data, loading]); +} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx new file mode 100644 index 0000000000000..98e882e543a87 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/hooks/use_invalid_monitors.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { useFetcher } from '../../../../../observability/public'; +import { Ping, SyntheticsMonitor } from '../../../../common/runtime_types'; +import { syntheticsMonitorType } from '../../../../common/types/saved_objects'; + +export const useInvalidMonitors = (errorSummaries?: Ping[]) => { + const { savedObjects } = useKibana().services; + + const ids = (errorSummaries ?? []).map((summary) => ({ + id: summary.config_id!, + type: syntheticsMonitorType, + })); + + return useFetcher(async () => { + if (ids.length > 0) { + const response = await savedObjects?.client.bulkResolve(ids); + if (response) { + const { resolved_objects: resolvedObjects } = response; + return resolvedObjects + .filter((sv) => { + if (sv.saved_object.updatedAt) { + const errorSummary = errorSummaries?.find( + (summary) => summary.config_id === sv.saved_object.id + ); + if (errorSummary) { + return moment(sv.saved_object.updatedAt).isBefore(moment(errorSummary.timestamp)); + } + } + + return !Boolean(sv.saved_object.error); + }) + .map(({ saved_object: savedObject }) => ({ + ...savedObject, + updated_at: savedObject.updatedAt!, + })); + } + } + }, [errorSummaries]); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx index ec58ac7ee5010..f60d54e9cb4f6 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.test.tsx @@ -15,7 +15,7 @@ describe('', () => { const onUpdate = jest.fn(); it('navigates to edit monitor flow on edit pencil', () => { - render(); + render(); expect(screen.getByLabelText('Edit monitor')).toHaveAttribute( 'href', diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx index 9d84263f3701e..47a0b8547ea8c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/actions.tsx @@ -8,19 +8,37 @@ import React, { useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import moment from 'moment'; import { UptimeSettingsContext } from '../../../contexts'; import { DeleteMonitor } from './delete_monitor'; +import { InlineError } from './inline_error'; +import { MonitorManagementListResult, Ping } from '../../../../common/runtime_types'; interface Props { id: string; name: string; isDisabled?: boolean; onUpdate: () => void; + errorSummaries?: Ping[]; + monitors: MonitorManagementListResult['monitors']; } -export const Actions = ({ id, name, onUpdate, isDisabled }: Props) => { +export const Actions = ({ id, name, onUpdate, isDisabled, errorSummaries, monitors }: Props) => { const { basePath } = useContext(UptimeSettingsContext); + let errorSummary = errorSummaries?.find((summary) => summary.config_id === id); + + const monitor = monitors.find((monitorT) => monitorT.id === id); + + if (errorSummary && monitor) { + const summaryIsBeforeUpdate = moment(monitor.updated_at).isBefore( + moment(errorSummary.timestamp) + ); + if (!summaryIsBeforeUpdate) { + errorSummary = undefined; + } + } + return ( @@ -35,6 +53,11 @@ export const Actions = ({ id, name, onUpdate, isDisabled }: Props) => { + {errorSummary && ( + + + + )} ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx new file mode 100644 index 0000000000000..550d3b487a4ae --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/all_monitors.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list'; +import { monitorManagementListSelector } from '../../../state/selectors'; +import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management'; +import { Ping } from '../../../../common/runtime_types'; + +interface Props { + pageState: MonitorManagementListPageState; + monitorList: MonitorManagementListState; + onPageStateChange: (state: MonitorManagementListPageState) => void; + onUpdate: () => void; + errorSummaries: Ping[]; +} +export const AllMonitors = ({ pageState, onPageStateChange, onUpdate, errorSummaries }: Props) => { + const monitorList = useSelector(monitorManagementListSelector); + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx index 2e69196c86cff..f8a81a6efce0b 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/delete_monitor.test.tsx @@ -40,7 +40,7 @@ describe('', () => { it('calls set refresh when deletion is successful', () => { const id = 'test-id'; const name = 'sample monitor'; - render(); + render(); userEvent.click(screen.getByLabelText('Delete monitor')); @@ -54,7 +54,7 @@ describe('', () => { status: FETCH_STATUS.LOADING, refetch: () => {}, }); - render(); + render(); expect(screen.getByLabelText('Deleting monitor...')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx new file mode 100644 index 0000000000000..1cf05d7697e60 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { InlineError } from './inline_error'; + +describe('', () => { + it('calls delete monitor on monitor deletion', () => { + render(); + + expect( + screen.getByLabelText( + 'journey did not finish executing, 0 steps ran. Click for more details.' + ) + ).toBeInTheDocument(); + }); +}); + +const errorSummary = { + docId: 'testDoc', + summary: { up: 0, down: 1 }, + agent: { + name: 'cron-686f0ce427dfd7e3-27465864-sqs5l', + id: '778fe9c6-bbd1-47d4-a0be-73f8ba9cec61', + type: 'heartbeat', + ephemeral_id: 'bc1a961f-1fbc-4253-aee0-633a8f6fc56e', + version: '8.1.0', + }, + synthetics: { type: 'heartbeat/summary' }, + monitor: { + name: 'Browser monitor', + check_group: 'f5480358-a9da-11ec-bced-6274e5883bd7', + id: '3a5553a0-a949-11ec-b7ca-c3b39fffa2af', + timespan: { lt: '2022-03-22T12:27:02.563Z', gte: '2022-03-22T12:24:02.563Z' }, + type: 'browser', + status: 'down', + }, + error: { type: 'io', message: 'journey did not finish executing, 0 steps ran' }, + url: {}, + observer: { + geo: { + continent_name: 'North America', + city_name: 'Iowa', + country_iso_code: 'US', + name: 'North America - US Central', + location: '41.8780, 93.0977', + }, + hostname: 'cron-686f0ce427dfd7e3-27465864-sqs5l', + ip: ['10.1.9.110'], + mac: ['62:74:e5:88:3b:d7'], + }, + ecs: { version: '8.0.0' }, + config_id: '3a5553a0-a949-11ec-b7ca-c3b39fffa2af', + data_stream: { namespace: 'default', type: 'synthetics', dataset: 'browser' }, + timestamp: '2022-03-22T12:24:02.563Z', +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx new file mode 100644 index 0000000000000..187c81ff8c6e9 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/inline_error.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Ping } from '../../../../common/runtime_types'; +import { StdErrorPopover } from './stderr_logs_popover'; + +export const InlineError = ({ errorSummary }: { errorSummary: Ping }) => { + const [isOpen, setIsOpen] = useState(false); + + const errorMessage = + errorSummary.monitor.type === 'browser' + ? getInlineErrorLabel(errorSummary.error?.message) + : errorSummary.error?.message; + + return ( + + setIsOpen(true)} + color="danger" + /> + + } + /> + ); +}; + +export const getInlineErrorLabel = (message?: string) => { + return i18n.translate('xpack.uptime.monitorList.statusColumn.error.message', { + defaultMessage: '{message}. Click for more details.', + values: { message }, + }); +}; + +export const ERROR_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.error.logs', { + defaultMessage: 'Error logs', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx new file mode 100644 index 0000000000000..4b524a2b52312 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/invalid_monitors.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list'; +import { MonitorManagementListResult, Ping } from '../../../../common/runtime_types'; + +interface Props { + loading: boolean; + pageState: MonitorManagementListPageState; + monitorSavedObjects?: MonitorManagementListResult['monitors']; + onPageStateChange: (state: MonitorManagementListPageState) => void; + onUpdate: () => void; + errorSummaries: Ping[]; + invalidTotal: number; +} +export const InvalidMonitors = ({ + loading: summariesLoading, + pageState, + onPageStateChange, + onUpdate, + errorSummaries, + invalidTotal, + monitorSavedObjects, +}: Props) => { + const { pageSize, pageIndex } = pageState; + + const startIndex = (pageIndex - 1) * pageSize; + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx new file mode 100644 index 0000000000000..bfac60de96bc7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { MonitorListTabs } from './list_tabs'; + +describe('', () => { + it('calls delete monitor on monitor deletion', () => { + const onPageStateChange = jest.fn(); + render( + + ); + + expect(screen.getByText('All monitors')).toBeInTheDocument(); + expect(screen.getByText('Invalid monitors')).toBeInTheDocument(); + + expect(onPageStateChange).toHaveBeenCalledTimes(1); + expect(onPageStateChange).toHaveBeenCalledWith({ + pageIndex: 1, + pageSize: 10, + sortField: 'name', + sortOrder: 'asc', + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx new file mode 100644 index 0000000000000..1aad6d4d888e5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/list_tabs.tsx @@ -0,0 +1,122 @@ +/* + * 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 { + EuiTabs, + EuiTab, + EuiNotificationBadge, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState, Fragment, useEffect } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { useUptimeRefreshContext } from '../../../contexts/uptime_refresh_context'; +import { MonitorManagementListPageState } from './monitor_list'; +import { ConfigKey } from '../../../../common/runtime_types'; + +export const MonitorListTabs = ({ + invalidTotal, + onUpdate, + onPageStateChange, +}: { + invalidTotal: number; + onUpdate: () => void; + onPageStateChange: (state: MonitorManagementListPageState) => void; +}) => { + const [selectedTabId, setSelectedTabId] = useState('all'); + + const { refreshApp } = useUptimeRefreshContext(); + + const history = useHistory(); + + const { type: viewType } = useParams<{ type: 'all' | 'invalid' }>(); + + useEffect(() => { + setSelectedTabId(viewType); + onPageStateChange({ pageIndex: 1, pageSize: 10, sortOrder: 'asc', sortField: ConfigKey.NAME }); + }, [viewType, onPageStateChange]); + + const tabs = [ + { + id: 'all', + name: ALL_MONITORS_LABEL, + content: , + href: history.createHref({ pathname: '/manage-monitors/all' }), + disabled: false, + }, + { + id: 'invalid', + name: INVALID_MONITORS_LABEL, + append: ( + + {invalidTotal} + + ), + href: history.createHref({ pathname: '/manage-monitors/invalid' }), + content: , + disabled: invalidTotal === 0, + }, + ]; + + const onSelectedTabChanged = (id: string) => { + setSelectedTabId(id); + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + append={tab.append} + > + {tab.name} + + )); + }; + + return ( + + + {renderTabs()} + + + { + onUpdate(); + refreshApp(); + }} + > + {REFRESH_LABEL} + + + + ); +}; + +export const REFRESH_LABEL = i18n.translate('xpack.uptime.monitorList.refresh', { + defaultMessage: 'Refresh', +}); + +export const INVALID_MONITORS_LABEL = i18n.translate('xpack.uptime.monitorList.invalidMonitors', { + defaultMessage: 'Invalid monitors', +}); + +export const ALL_MONITORS_LABEL = i18n.translate('xpack.uptime.monitorList.allMonitors', { + defaultMessage: 'All monitors', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx index d9fb207f4fa20..ff5d9ebf13ccf 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.test.tsx @@ -20,6 +20,7 @@ describe('', () => { for (let i = 0; i < 12; i++) { monitors.push({ id: `test-monitor-id-${i}`, + updated_at: '123', attributes: { name: `test-monitor-${i}`, enabled: true, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx index 5d18fdcaca6fe..8bae4160f6b0c 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/monitor_list.tsx @@ -21,6 +21,7 @@ import { FetchMonitorManagementListQueryArgs, ICMPSimpleFields, MonitorFields, + Ping, ServiceLocations, SyntheticsMonitorWithId, TCPSimpleFields, @@ -47,6 +48,7 @@ interface Props { monitorList: MonitorManagementListState; onPageStateChange: (state: MonitorManagementListPageState) => void; onUpdate: () => void; + errorSummaries?: Ping[]; } export const MonitorManagementList = ({ @@ -58,13 +60,18 @@ export const MonitorManagementList = ({ }, onPageStateChange, onUpdate, + errorSummaries, }: Props) => { const { basePath } = useContext(UptimeSettingsContext); const isXl = useBreakpoints().up('xl'); const { total } = list as MonitorManagementListState['list']; const monitors: SyntheticsMonitorWithId[] = useMemo( - () => list.monitors.map((monitor) => ({ ...monitor.attributes, id: monitor.id })), + () => + list.monitors.map((monitor) => ({ + ...monitor.attributes, + id: monitor.id, + })), [list.monitors] ); @@ -90,7 +97,7 @@ export const MonitorManagementList = ({ pageIndex: pageIndex - 1, // page index for EuiBasicTable is base 0 pageSize, totalItemCount: total || 0, - pageSizeOptions: [10, 25, 50, 100], + pageSizeOptions: [5, 10, 25, 50, 100], }; const sorting: EuiTableSortingType = { @@ -188,6 +195,8 @@ export const MonitorManagementList = ({ name={fields[ConfigKey.NAME]} isDisabled={!canEdit} onUpdate={onUpdate} + errorSummaries={errorSummaries} + monitors={list.monitors} /> ), }, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx new file mode 100644 index 0000000000000..c50cd33b13b1f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_list/stderr_logs_popover.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { StdErrorLogs } from '../../synthetics/check_steps/stderr_logs'; + +export const StdErrorPopover = ({ + checkGroup, + button, + isOpen, + setIsOpen, + summaryMessage, +}: { + isOpen: boolean; + setIsOpen: (val: boolean) => void; + checkGroup: string; + summaryMessage?: string; + button: JSX.Element; +}) => { + return ( + setIsOpen(false)} button={button}> + + + + + ); +}; + +const Container = styled.div` + width: 650px; + height: 400px; + overflow: scroll; +`; + +export const getInlineErrorLabel = (message?: string) => { + return i18n.translate('xpack.uptime.monitorList.statusColumn.error.messageLabel', { + defaultMessage: '{message}. Click for more details.', + values: { message }, + }); +}; + +export const ERROR_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.statusColumn.error.logs', { + defaultMessage: 'Error logs', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx index c6074626bad1e..3e798dd3fbe62 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx @@ -75,7 +75,9 @@ export const BrowserTestRunResult = ({ monitorId, isMonitorSaved, onDone }: Prop initialIsOpen={true} > {isStepsLoading && {LOADING_STEPS}} - {isStepsLoadingFailed && {FAILED_TO_RUN}} + {isStepsLoadingFailed && ( + {summaryDoc?.error?.message ?? FAILED_TO_RUN} + )} {stepEnds.length > 0 && stepListData?.steps && ( 0) { summaryDocs.forEach((sDoc) => { - duration += sDoc.monitor.duration!.us; + duration += sDoc.monitor.duration?.us ?? 0; }); } + const summaryDoc = summaryDocs?.[0] as Ping; + return ( @@ -48,7 +50,9 @@ export function TestResultHeader({ doc, title, summaryDocs, journeyStarted, isCo {isCompleted ? ( - {COMPLETED_LABEL} + 0 ? 'danger' : 'success'}> + {summaryDoc?.summary?.down! > 0 ? FAILED_LABEL : COMPLETED_LABEL} + @@ -98,6 +102,10 @@ const COMPLETED_LABEL = i18n.translate('xpack.uptime.monitorManagement.completed defaultMessage: 'COMPLETED', }); +const FAILED_LABEL = i18n.translate('xpack.uptime.monitorManagement.failed', { + defaultMessage: 'FAILED', +}); + export const IN_PROGRESS_LABEL = i18n.translate('xpack.uptime.monitorManagement.inProgress', { defaultMessage: 'IN PROGRESS', }); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx index 60baedaa7830c..896ab6bc662bb 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_status_column.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback } from 'react'; import moment, { Moment } from 'moment'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -14,14 +14,13 @@ import { EuiFlexItem, EuiText, EuiToolTip, - EuiBadge, EuiSpacer, EuiHighlight, EuiHorizontalRule, } from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; import { parseTimestamp } from '../parse_timestamp'; -import { DataStream, Ping } from '../../../../../common/runtime_types'; +import { DataStream, Ping, PingError } from '../../../../../common/runtime_types'; import { STATUS, SHORT_TIMESPAN_LOCALE, @@ -29,22 +28,24 @@ import { SHORT_TS_LOCALE, } from '../../../../../common/constants'; -import { UptimeThemeContext } from '../../../../contexts'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../../../common/translations'; import { MonitorProgress } from './progress/monitor_progress'; import { refreshedMonitorSelector } from '../../../../state/reducers/monitor_list'; import { testNowRunSelector } from '../../../../state/reducers/test_now_runs'; import { clearTestNowMonitorAction } from '../../../../state/actions'; +import { StatusBadge } from './status_badge'; interface MonitorListStatusColumnProps { configId?: string; monitorId?: string; + checkGroup?: string; status: string; monitorType: string; timestamp: string; duration?: number; summaryPings: Ping[]; + summaryError?: PingError; } const StatusColumnFlexG = styled(EuiFlexGroup)` @@ -167,15 +168,13 @@ export const MonitorListStatusColumn = ({ monitorId, status, duration, + checkGroup, + summaryError, summaryPings = [], timestamp: tsString, }: MonitorListStatusColumnProps) => { const timestamp = parseTimestamp(tsString); - const { - colors: { dangerBehindText }, - } = useContext(UptimeThemeContext); - const { statusMessage, locTooltip } = getLocationStatus(summaryPings, status); const dispatch = useDispatch(); @@ -204,12 +203,12 @@ export const MonitorListStatusColumn = ({ stopProgressTrack={stopProgressTrack} /> ) : ( - - {getHealthMessage(status)} - + )} diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx new file mode 100644 index 0000000000000..992defffc5552 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { StatusBadge } from './status_badge'; +import { render } from '../../../../lib/helper/rtl_helpers'; + +describe('', () => { + it('render no error for up status', () => { + render(); + + expect(screen.getByText('Up')).toBeInTheDocument(); + }); + + it('renders errors for downs state', () => { + render( + + ); + + expect(screen.getByText('Down')).toBeInTheDocument(); + expect( + screen.getByLabelText('journey did not run. Click for more details.') + ).toBeInTheDocument(); + }); + + it('renders errors for downs state for http monitor', () => { + render( + + ); + + expect(screen.getByText('Down')).toBeInTheDocument(); + expect(screen.getByLabelText('journey did not run')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx new file mode 100644 index 0000000000000..fe2c7730275db --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/status_badge.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import React, { useContext, useState } from 'react'; +import { STATUS } from '../../../../../common/constants'; +import { getHealthMessage } from './monitor_status_column'; +import { UptimeThemeContext } from '../../../../contexts'; +import { PingError } from '../../../../../common/runtime_types'; +import { getInlineErrorLabel } from '../../../monitor_management/monitor_list/inline_error'; +import { StdErrorPopover } from '../../../monitor_management/monitor_list/stderr_logs_popover'; + +export const StatusBadge = ({ + status, + checkGroup, + summaryError, + monitorType, +}: { + status: string; + monitorType: string; + checkGroup?: string; + summaryError?: PingError; +}) => { + const { + colors: { dangerBehindText }, + } = useContext(UptimeThemeContext); + const [isOpen, setIsOpen] = useState(false); + + if (status === STATUS.UP) { + return ( + + {getHealthMessage(status)} + + ); + } + + const errorMessage = + monitorType !== 'browser' ? summaryError?.message : getInlineErrorLabel(summaryError?.message); + + const button = ( + + setIsOpen(true)} + onClickAriaLabel={errorMessage} + > + {getHealthMessage(status)} + + + ); + + if (monitorType !== 'browser') { + return button; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index a2d823cd90af1..552256a6aff1a 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -123,7 +123,8 @@ export const MonitorListComponent: ({ state: { timestamp, summaryPings, - monitor: { type, duration }, + monitor: { type, duration, checkGroup }, + error: summaryError, }, configId, }: MonitorSummary @@ -137,6 +138,8 @@ export const MonitorListComponent: ({ monitorType={type} duration={duration?.us} monitorId={monitorId} + checkGroup={checkGroup} + summaryError={summaryError} /> ); }, diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx new file mode 100644 index 0000000000000..cef4ff550a23d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/stderr_logs.tsx @@ -0,0 +1,148 @@ +/* + * 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 { + EuiBasicTableColumn, + EuiButtonEmpty, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHighlight, + EuiLink, + EuiSpacer, + EuiTitle, + formatDate, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiInMemoryTable } from '@elastic/eui'; +import moment from 'moment'; +import { useSelector } from 'react-redux'; +import { useStdErrorLogs } from './use_std_error_logs'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ClientPluginsStart } from '../../../apps/plugin'; +import { useFetcher } from '../../../../../observability/public'; +import { selectDynamicSettings } from '../../../state/selectors'; +import { Ping } from '../../../../common/runtime_types'; + +export const StdErrorLogs = ({ + configId, + checkGroup, + timestamp, + title, + summaryMessage, +}: { + configId?: string; + checkGroup?: string; + timestamp?: string; + title?: string; + summaryMessage?: string; +}) => { + const columns = [ + { + field: '@timestamp', + name: TIMESTAMP_LABEL, + sortable: true, + render: (date: string) => formatDate(date, 'dateTime'), + }, + { + field: 'synthetics.payload.message', + name: 'Message', + render: (message: string) => ( + + {message} + + ), + }, + ] as Array>; + + const { items, loading } = useStdErrorLogs({ configId, checkGroup }); + + const { discover, observability } = useKibana().services; + + const { settings } = useSelector(selectDynamicSettings); + + const { data: discoverLink } = useFetcher(async () => { + if (settings?.heartbeatIndices) { + const dataView = await observability.getAppDataView('synthetics', settings?.heartbeatIndices); + return discover.locator?.getUrl({ + query: { language: 'kuery', query: `monitor.check_group: ${checkGroup}` }, + indexPatternId: dataView?.id, + columns: ['synthetics.payload.message', 'error.message'], + timeRange: timestamp + ? { + from: moment(timestamp).subtract(10, 'minutes').toISOString(), + to: moment(timestamp).add(5, 'minutes').toISOString(), + } + : undefined, + }); + } + return ''; + }, [checkGroup, timestamp]); + + const search = { + box: { + incremental: true, + }, + }; + + return ( + <> + + + +

    {title ?? TEST_RUN_LOGS_LABEL}

    +
    +
    + + + + {VIEW_IN_DISCOVER_LABEL} + + + +
    + + +

    {summaryMessage}

    +
    + + + + + + ); +}; + +export const TIMESTAMP_LABEL = i18n.translate('xpack.uptime.monitorList.timestamp', { + defaultMessage: 'Timestamp', +}); + +export const ERROR_SUMMARY_LABEL = i18n.translate('xpack.uptime.monitorList.errorSummary', { + defaultMessage: 'Error summary', +}); + +export const VIEW_IN_DISCOVER_LABEL = i18n.translate('xpack.uptime.monitorList.viewInDiscover', { + defaultMessage: 'View in discover', +}); + +export const TEST_RUN_LOGS_LABEL = i18n.translate('xpack.uptime.monitorList.testRunLogs', { + defaultMessage: 'Test run logs', +}); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts new file mode 100644 index 0000000000000..fa563b2ef2728 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/use_std_error_logs.ts @@ -0,0 +1,65 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { createEsParams, useEsSearch } from '../../../../../observability/public'; +import { selectDynamicSettings } from '../../../state/selectors'; +import { Ping } from '../../../../common/runtime_types'; + +export const useStdErrorLogs = ({ + configId, + checkGroup, +}: { + configId?: string; + checkGroup?: string; +}) => { + const { settings } = useSelector(selectDynamicSettings); + const { data, loading } = useEsSearch( + createEsParams({ + index: !configId && !checkGroup ? '' : settings?.heartbeatIndices, + body: { + size: 1000, + query: { + bool: { + filter: [ + { + term: { + 'synthetics.type': 'stderr', + }, + }, + ...(configId + ? [ + { + term: { + config_id: configId, + }, + }, + ] + : []), + ...(checkGroup + ? [ + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + ] + : []), + ], + }, + }, + }, + }), + [settings?.heartbeatIndices], + { name: 'getStdErrLogs' } + ); + + return { + items: data?.hits.hits.map((hit) => ({ ...(hit._source as Ping), id: hit._id })) ?? [], + loading, + }; +}; diff --git a/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx index 7f81628129d3e..12d904ae3c4b5 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_refresh_context.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { createContext, useMemo, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; interface UptimeRefreshContext { lastRefresh: number; @@ -35,3 +35,5 @@ export const UptimeRefreshContextProvider: React.FC = ({ children }) => { return ; }; + +export const useUptimeRefreshContext = () => useContext(UptimeRefreshContext); diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx index 0ad9dbd6b06e7..d826db82517fc 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/monitor_management.tsx @@ -7,15 +7,18 @@ import React, { useEffect, useReducer, useCallback, Reducer } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import { useTrackPageview } from '../../../../observability/public'; import { ConfigKey } from '../../../common/runtime_types'; import { getMonitors } from '../../state/actions'; import { monitorManagementListSelector } from '../../state/selectors'; -import { - MonitorManagementList, - MonitorManagementListPageState, -} from '../../components/monitor_management/monitor_list/monitor_list'; +import { MonitorManagementListPageState } from '../../components/monitor_management/monitor_list/monitor_list'; import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs'; +import { useInlineErrors } from '../../components/monitor_management/hooks/use_inline_errors'; +import { MonitorListTabs } from '../../components/monitor_management/monitor_list/list_tabs'; +import { AllMonitors } from '../../components/monitor_management/monitor_list/all_monitors'; +import { InvalidMonitors } from '../../components/monitor_management/monitor_list/invalid_monitors'; +import { useInvalidMonitors } from '../../components/monitor_management/hooks/use_invalid_monitors'; export const MonitorManagementPage: React.FC = () => { const [pageState, dispatchPageAction] = useReducer( @@ -47,17 +50,48 @@ export const MonitorManagementPage: React.FC = () => { const { pageIndex, pageSize, sortField, sortOrder } = pageState as MonitorManagementListPageState; + const { type: viewType } = useParams<{ type: 'all' | 'invalid' }>(); + const { errorSummaries, loading, count } = useInlineErrors({ + onlyInvalidMonitors: viewType === 'invalid', + sortField: pageState.sortField, + sortOrder: pageState.sortOrder, + }); + useEffect(() => { - dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder })); - }, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder]); + if (viewType === 'all') { + dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder })); + } + }, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder, viewType]); + + const { data: monitorSavedObjects, loading: objectsLoading } = useInvalidMonitors(errorSummaries); return ( - + <> + + {viewType === 'all' ? ( + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx index e5784591a00fc..834752c996153 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/use_monitor_management_breadcrumbs.tsx @@ -25,7 +25,8 @@ export const useMonitorManagementBreadcrumbs = ({ useBreadcrumbs([ { text: MONITOR_MANAGEMENT_CRUMB, - href: isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}` : undefined, + href: + isAddMonitor || isEditMonitor ? `${appPath}/${MONITOR_MANAGEMENT_ROUTE}/all` : undefined, }, ...(isAddMonitor ? [ diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 5d7e0a46a29d3..e68f25fcbb134 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -237,7 +237,7 @@ const getRoutes = (config: UptimeConfig, canSave: boolean): RouteProps[] => { defaultMessage: 'Manage Monitors | {baseTitle}', values: { baseTitle }, }), - path: MONITOR_MANAGEMENT_ROUTE, + path: MONITOR_MANAGEMENT_ROUTE + '/:type', component: MonitorManagementPage, dataTestSubj: 'uptimeMonitorManagementListPage', telemetryId: UptimePage.MonitorManagement, diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts index 5a714fd2514d8..6359a122638f2 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts @@ -90,6 +90,7 @@ export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => { name: latest.monitor?.name, type: latest.monitor?.type, duration: latest.monitor?.duration, + checkGroup: latest.monitor?.check_group, }, url: latest.url ?? {}, summary: { @@ -104,6 +105,7 @@ export const summaryPingsToSummary = (summaryPings: Ping[]): MonitorSummary => { geo: { name: summaryPings.map((p) => p.observer?.geo?.name ?? '').filter((n) => n !== '') }, }, service: summaryPings.find((p) => p.service?.name)?.service, + error: latest.error, }, }; }; diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts index 3d132e74d24d5..f240652b27691 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts @@ -43,11 +43,14 @@ export const hydrateSavedObjects = async ({ missingInfoIds ); - const updatedObjects = monitors + const updatedObjects: SyntheticsMonitorSavedObject[] = []; + monitors .filter((monitor) => missingInfoIds.includes(monitor.id)) - .map((monitor) => { + .forEach((monitor) => { let resultAttributes: Partial = monitor.attributes; + let isUpdated = false; + esDocs.forEach((doc) => { // to make sure the document is ingested after the latest update of the monitor const documentIsAfterLatestUpdate = moment(monitor.updated_at).isBefore( @@ -57,15 +60,21 @@ export const hydrateSavedObjects = async ({ if (doc.config_id !== monitor.id) return monitor; if (doc.url?.full) { + isUpdated = true; resultAttributes = { ...resultAttributes, urls: doc.url?.full }; } if (doc.url?.port) { + isUpdated = true; resultAttributes = { ...resultAttributes, ['url.port']: doc.url?.port }; } }); - - return { ...monitor, attributes: resultAttributes }; + if (isUpdated) { + updatedObjects.push({ + ...monitor, + attributes: resultAttributes, + } as SyntheticsMonitorSavedObject); + } }); await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); From 70e0133691cf30dc2ba5d2815f3a48d08db8fc03 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 23 Mar 2022 16:37:48 -0400 Subject: [PATCH 094/132] [Response Ops] Change search strategy to private (#127792) * Privatize * Add test * Fix types * debug for ci * try fetching version * Use this Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/get_is_kibana_request.test.ts | 34 +++++++++++ .../server/lib/get_is_kibana_request.ts | 17 ++++++ x-pack/plugins/rule_registry/server/plugin.ts | 4 +- .../server/search_strategy/index.ts | 2 +- .../search_strategy/search_strategy.test.ts | 55 ++++++++++++++++- .../server/search_strategy/search_strategy.ts | 15 ++++- x-pack/test/common/services/bsearch_secure.ts | 40 ++++++++++-- .../tests/basic/search_strategy.ts | 61 +++++++++++++++---- 8 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts create mode 100644 x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts new file mode 100644 index 0000000000000..7dc0f51f15f08 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { getIsKibanaRequest } from './get_is_kibana_request'; + +describe('getIsKibanaRequest', () => { + it('should ensure the request has a kbn version and referer', () => { + expect( + getIsKibanaRequest({ + 'kbn-version': 'foo', + referer: 'somwhere', + }) + ).toBe(true); + }); + + it('should return false if the kbn version is missing', () => { + expect( + getIsKibanaRequest({ + referer: 'somwhere', + }) + ).toBe(false); + }); + + it('should return false if the referer is missing', () => { + expect( + getIsKibanaRequest({ + 'kbn-version': 'foo', + }) + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts new file mode 100644 index 0000000000000..c0961b84c7c28 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/lib/get_is_kibana_request.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Headers } from 'kibana/server'; + +/** + * Taken from + * https://github.com/elastic/kibana/blob/ec30f2aeeb10fb64b507935e558832d3ef5abfaa/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts#L113-L118 + */ +export const getIsKibanaRequest = (headers?: Headers): boolean => { + // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. + // We can't be 100% certain, but this is a reasonable attempt. + return !!(headers && headers['kbn-version'] && headers.referer); +}; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 292e987879d58..df32abcc80865 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -29,7 +29,7 @@ import { AlertsClientFactory } from './alert_data_client/alerts_client_factory'; import { AlertsClient } from './alert_data_client/alerts_client'; import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types'; import { defineRoutes } from './routes'; -import { ruleRegistrySearchStrategyProvider } from './search_strategy'; +import { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy'; export interface RuleRegistryPluginSetupDependencies { security?: SecurityPluginSetup; @@ -115,7 +115,7 @@ export class RuleRegistryPlugin ); plugins.data.search.registerSearchStrategy( - 'ruleRegistryAlertsSearchStrategy', + RULE_SEARCH_STRATEGY_NAME, ruleRegistrySearchStrategy ); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/index.ts b/x-pack/plugins/rule_registry/server/search_strategy/index.ts index 63f39430a5522..d6364983f2d26 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/index.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ruleRegistrySearchStrategyProvider } from './search_strategy'; +export { ruleRegistrySearchStrategyProvider, RULE_SEARCH_STRATEGY_NAME } from './search_strategy'; diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts index 2ea4b4c191c0d..f5f7d8d164b48 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.test.ts @@ -8,7 +8,11 @@ import { of } from 'rxjs'; import { merge } from 'lodash'; import { loggerMock } from '@kbn/logging-mocks'; import { AlertConsumers } from '@kbn/rule-data-utils'; -import { ruleRegistrySearchStrategyProvider, EMPTY_RESPONSE } from './search_strategy'; +import { + ruleRegistrySearchStrategyProvider, + EMPTY_RESPONSE, + RULE_SEARCH_STRATEGY_NAME, +} from './search_strategy'; import { ruleDataServiceMock } from '../rule_data_plugin_service/rule_data_plugin_service.mock'; import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks'; import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server'; @@ -18,6 +22,9 @@ import { spacesMock } from '../../../spaces/server/mocks'; import { RuleRegistrySearchRequest } from '../../common/search_strategy'; import { IndexInfo } from '../rule_data_plugin_service/index_info'; import * as getAuthzFilterImport from '../lib/get_authz_filter'; +import { getIsKibanaRequest } from '../lib/get_is_kibana_request'; + +jest.mock('../lib/get_is_kibana_request'); const getBasicResponse = (overwrites = {}) => { return merge( @@ -89,6 +96,10 @@ describe('ruleRegistrySearchStrategyProvider()', () => { return of(response); }); + (getIsKibanaRequest as jest.Mock).mockImplementation(() => { + return true; + }); + getAuthzFilterSpy = jest .spyOn(getAuthzFilterImport, 'getAuthzFilter') .mockImplementation(async () => { @@ -377,4 +388,46 @@ describe('ruleRegistrySearchStrategyProvider()', () => { (data.search.searchAsInternalUser.search as jest.Mock).mock.calls[0][0].params.body.sort ).toStrictEqual([{ test: { order: 'desc' } }]); }); + + it('should reject, to the best of our ability, public requests', async () => { + (getIsKibanaRequest as jest.Mock).mockImplementation(() => { + return false; + }); + const request: RuleRegistrySearchRequest = { + featureIds: [AlertConsumers.LOGS], + sort: [ + { + test: { + order: 'desc', + }, + }, + ], + }; + const options = {}; + const deps = { + request: {}, + }; + + const strategy = ruleRegistrySearchStrategyProvider( + data, + ruleDataService, + alerting, + logger, + security, + spaces + ); + + let err = null; + try { + await strategy + .search(request, options, deps as unknown as SearchStrategyDependencies) + .toPromise(); + } catch (e) { + err = e; + } + expect(err).not.toBeNull(); + expect(err.message).toBe( + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` + ); + }); }); diff --git a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts index 8cd0a0d410c9b..da32d68a85f86 100644 --- a/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts +++ b/x-pack/plugins/rule_registry/server/search_strategy/search_strategy.ts @@ -5,6 +5,7 @@ * 2.0. */ import { map, mergeMap, catchError } from 'rxjs/operators'; +import Boom from '@hapi/boom'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Logger } from 'src/core/server'; import { from, of } from 'rxjs'; @@ -23,11 +24,14 @@ import { Dataset } from '../rule_data_plugin_service/index_options'; import { MAX_ALERT_SEARCH_SIZE } from '../../common/constants'; import { AlertAuditAction, alertAuditEvent } from '../'; import { getSpacesFilter, getAuthzFilter } from '../lib'; +import { getIsKibanaRequest } from '../lib/get_is_kibana_request'; export const EMPTY_RESPONSE: RuleRegistrySearchResponse = { rawResponse: {} as RuleRegistrySearchResponse['rawResponse'], }; +export const RULE_SEARCH_STRATEGY_NAME = 'privateRuleRegistryAlertsSearchStrategy'; + export const ruleRegistrySearchStrategyProvider = ( data: PluginStart, ruleDataService: IRuleDataService, @@ -40,6 +44,13 @@ export const ruleRegistrySearchStrategyProvider = ( const requestUserEs = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); return { search: (request, options, deps) => { + // We want to ensure this request came from our UI. We can't really do this + // but we have a best effort we can try + if (!getIsKibanaRequest(deps.request.headers)) { + throw Boom.notFound( + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is currently only available for internal use.` + ); + } // SIEM uses RBAC fields in their alerts but also utilizes ES DLS which // is different than every other solution so we need to special case // those requests. @@ -48,7 +59,7 @@ export const ruleRegistrySearchStrategyProvider = ( siemRequest = true; } else if (request.featureIds.includes(AlertConsumers.SIEM)) { throw new Error( - 'The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.' + `The ${RULE_SEARCH_STRATEGY_NAME} search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` ); } @@ -74,7 +85,7 @@ export const ruleRegistrySearchStrategyProvider = ( const indices: string[] = request.featureIds.reduce((accum: string[], featureId) => { if (!isValidFeatureId(featureId)) { logger.warn( - `Found invalid feature '${featureId}' while using rule registry search strategy. No alert data from this feature will be searched.` + `Found invalid feature '${featureId}' while using ${RULE_SEARCH_STRATEGY_NAME} search strategy. No alert data from this feature will be searched.` ); return accum; } diff --git a/x-pack/test/common/services/bsearch_secure.ts b/x-pack/test/common/services/bsearch_secure.ts index 622cca92aead5..c1aa173280f54 100644 --- a/x-pack/test/common/services/bsearch_secure.ts +++ b/x-pack/test/common/services/bsearch_secure.ts @@ -29,6 +29,8 @@ const getSpaceUrlPrefix = (spaceId?: string): string => { interface SendOptions { supertestWithoutAuth: SuperTest.SuperTest; auth: { username: string; password: string }; + referer?: string; + kibanaVersion?: string; options: object; strategy: string; space?: string; @@ -38,17 +40,45 @@ export const BSecureSearchFactory = (retry: RetryService) => ({ send: async ({ supertestWithoutAuth, auth, + referer, + kibanaVersion, options, strategy, space, }: SendOptions): Promise => { const spaceUrl = getSpaceUrlPrefix(space); const { body } = await retry.try(async () => { - const result = await supertestWithoutAuth - .post(`${spaceUrl}/internal/search/${strategy}`) - .auth(auth.username, auth.password) - .set('kbn-xsrf', 'true') - .send(options); + let result; + const url = `${spaceUrl}/internal/search/${strategy}`; + if (referer && kibanaVersion) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('referer', referer) + .set('kbn-version', kibanaVersion) + .set('kbn-xsrf', 'true') + .send(options); + } else if (referer) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('referer', referer) + .set('kbn-xsrf', 'true') + .send(options); + } else if (kibanaVersion) { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('kbn-version', kibanaVersion) + .set('kbn-xsrf', 'true') + .send(options); + } else { + result = await supertestWithoutAuth + .post(url) + .auth(auth.username, auth.password) + .set('kbn-xsrf', 'true') + .send(options); + } if (result.status === 500 || result.status === 200) { return result; } diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts index 745995588d8b3..2c203a4ffbcd3 100644 --- a/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/search_strategy.ts @@ -26,18 +26,28 @@ import { logsOnlySpacesAll, } from '../../../common/lib/authentication/users'; +type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & { + statusCode: number; + message: string; +}; + // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); - // const bsearch = getService('bsearch'); const secureBsearch = getService('secureBsearch'); const log = getService('log'); + const kbnClient = getService('kibanaServer'); const SPACE1 = 'space1'; describe('ruleRegistryAlertsSearchStrategy', () => { + let kibanaVersion: string; + before(async () => { + kibanaVersion = await kbnClient.version.get(); + }); + describe('logs', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); @@ -53,10 +63,12 @@ export default ({ getService }: FtrProviderContext) => { username: logsOnlySpacesAll.username, password: logsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.LOGS], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(5); const consumers = result.rawResponse.hits.hits.map((hit) => { @@ -72,6 +84,8 @@ export default ({ getService }: FtrProviderContext) => { username: logsOnlySpacesAll.username, password: logsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.LOGS], pagination: { @@ -86,7 +100,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(5); expect(result.rawResponse.hits.hits.length).to.eql(2); @@ -94,9 +108,26 @@ export default ({ getService }: FtrProviderContext) => { const second = result.rawResponse.hits.hits[1].fields?.['kibana.alert.evaluation.value']; expect(first > second).to.be(true); }); + + it('should reject public requests', async () => { + const result = await secureBsearch.send({ + supertestWithoutAuth, + auth: { + username: logsOnlySpacesAll.username, + password: logsOnlySpacesAll.password, + }, + options: { + featureIds: [AlertConsumers.LOGS], + }, + strategy: 'privateRuleRegistryAlertsSearchStrategy', + }); + expect(result.statusCode).to.be(500); + expect(result.message).to.be( + `The privateRuleRegistryAlertsSearchStrategy search strategy is currently only available for internal use.` + ); + }); }); - // TODO: need xavier's help here describe('siem', () => { before(async () => { await createSignalsIndex(supertest, log); @@ -126,10 +157,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAllEsRead.username, password: obsOnlySpacesAllEsRead.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.SIEM], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse.hits.total).to.eql(1); const consumers = result.rawResponse.hits.hits.map( @@ -139,24 +172,22 @@ export default ({ getService }: FtrProviderContext) => { }); it('should throw an error when trying to to search for more than just siem', async () => { - type RuleRegistrySearchResponseWithErrors = RuleRegistrySearchResponse & { - statusCode: number; - message: string; - }; const result = await secureBsearch.send({ supertestWithoutAuth, auth: { username: obsOnlySpacesAllEsRead.username, password: obsOnlySpacesAllEsRead.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.SIEM, AlertConsumers.LOGS], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.statusCode).to.be(500); expect(result.message).to.be( - `The ruleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` + `The privateRuleRegistryAlertsSearchStrategy search strategy is unable to accommodate requests containing multiple feature IDs and one of those IDs is SIEM.` ); }); }); @@ -176,10 +207,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAll.username, password: obsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [AlertConsumers.APM], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', space: SPACE1, }); expect(result.rawResponse.hits.total).to.eql(2); @@ -198,10 +231,12 @@ export default ({ getService }: FtrProviderContext) => { username: obsOnlySpacesAll.username, password: obsOnlySpacesAll.password, }, + referer: 'test', + kibanaVersion, options: { featureIds: [], }, - strategy: 'ruleRegistryAlertsSearchStrategy', + strategy: 'privateRuleRegistryAlertsSearchStrategy', }); expect(result.rawResponse).to.eql({}); }); From 5b4642b3658af666fcba6d5176b1dfe4202b84c0 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 23 Mar 2022 16:06:40 -0500 Subject: [PATCH 095/132] [build] Cross compile docker images (#128272) * [build] Cross compile docker images * typo * debug * Revert "[build] Cross compile docker images" This reverts commit 621780eb1d85076893e8a45b000b9886126c3153. * revert * support docker-cross-compile flag * fix types/tests * fix more tests * download cloud dependencies based on cross compile flag * fix array * fix more tests --- src/dev/build/args.test.ts | 7 +++++ src/dev/build/args.ts | 3 ++ src/dev/build/build_distributables.ts | 1 + src/dev/build/cli.ts | 1 + src/dev/build/lib/build.test.ts | 1 + src/dev/build/lib/config.test.ts | 1 + src/dev/build/lib/config.ts | 11 ++++++++ src/dev/build/lib/runner.test.ts | 1 + .../tasks/download_cloud_dependencies.ts | 28 +++++++++++-------- .../nodejs/download_node_builds_task.test.ts | 1 + .../nodejs/extract_node_builds_task.test.ts | 1 + .../verify_existing_node_builds_task.test.ts | 1 + .../tasks/os_packages/docker_generator/run.ts | 3 +- .../templates/build_docker_sh.template.ts | 3 +- 14 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index c06c13230c63f..b0f39840ba440 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -36,6 +36,7 @@ it('build default and oss dist for current platform, without packages, by defaul "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -67,6 +68,7 @@ it('builds packages if --all-platforms is passed', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -98,6 +100,7 @@ it('limits packages if --rpm passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -129,6 +132,7 @@ it('limits packages if --deb passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -161,6 +165,7 @@ it('limits packages if --docker passed with --all-platforms', () => { "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -200,6 +205,7 @@ it('limits packages if --docker passed with --skip-docker-ubi and --all-platform "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": false, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, @@ -232,6 +238,7 @@ it('limits packages if --all-platforms passed with --skip-docker-ubuntu', () => "createGenericFolders": true, "createPlatformFolders": true, "createRpmPackage": true, + "dockerCrossCompile": false, "dockerPush": false, "dockerTagQualifier": null, "downloadCloudDependencies": true, diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index 03fe49b72c954..2bad2c0721e2e 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -22,6 +22,7 @@ export function readCliArgs(argv: string[]) { 'skip-os-packages', 'rpm', 'deb', + 'docker-cross-compile', 'docker-images', 'docker-push', 'skip-docker-contexts', @@ -52,6 +53,7 @@ export function readCliArgs(argv: string[]) { rpm: null, deb: null, 'docker-images': null, + 'docker-cross-compile': false, 'docker-push': false, 'docker-tag-qualifier': null, 'version-qualifier': '', @@ -112,6 +114,7 @@ export function readCliArgs(argv: string[]) { const buildOptions: BuildOptions = { isRelease: Boolean(flags.release), versionQualifier: flags['version-qualifier'], + dockerCrossCompile: Boolean(flags['docker-cross-compile']), dockerPush: Boolean(flags['docker-push']), dockerTagQualifier: flags['docker-tag-qualifier'], initialize: !Boolean(flags['skip-initialize']), diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index 4fb849988cb60..d2b2d24667bce 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -13,6 +13,7 @@ import * as Tasks from './tasks'; export interface BuildOptions { isRelease: boolean; + dockerCrossCompile: boolean; dockerPush: boolean; dockerTagQualifier: string | null; downloadFreshNode: boolean; diff --git a/src/dev/build/cli.ts b/src/dev/build/cli.ts index ffcbb68215ab7..561e2aea5c15d 100644 --- a/src/dev/build/cli.ts +++ b/src/dev/build/cli.ts @@ -39,6 +39,7 @@ if (showHelp) { --rpm {dim Only build the rpm packages} --deb {dim Only build the deb packages} --docker-images {dim Only build the Docker images} + --docker-cross-compile {dim Produce arm64 and amd64 Docker images} --docker-contexts {dim Only build the Docker build contexts} --skip-docker-ubi {dim Don't build the docker ubi image} --skip-docker-ubuntu {dim Don't build the docker ubuntu image} diff --git a/src/dev/build/lib/build.test.ts b/src/dev/build/lib/build.test.ts index 8ea2a20d83960..3da87ff13b1ee 100644 --- a/src/dev/build/lib/build.test.ts +++ b/src/dev/build/lib/build.test.ts @@ -32,6 +32,7 @@ const config = new Config( buildSha: 'abcd1234', buildVersion: '8.0.0', }, + false, '', false, true diff --git a/src/dev/build/lib/config.test.ts b/src/dev/build/lib/config.test.ts index 3f90c8738d8e2..2195406270bdd 100644 --- a/src/dev/build/lib/config.test.ts +++ b/src/dev/build/lib/config.test.ts @@ -29,6 +29,7 @@ const setup = async ({ targetAllPlatforms = true }: { targetAllPlatforms?: boole return await Config.create({ isRelease: true, targetAllPlatforms, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/lib/config.ts b/src/dev/build/lib/config.ts index 650af04dfd54b..2bab1d28f9ef7 100644 --- a/src/dev/build/lib/config.ts +++ b/src/dev/build/lib/config.ts @@ -17,6 +17,7 @@ interface Options { isRelease: boolean; targetAllPlatforms: boolean; versionQualifier?: string; + dockerCrossCompile: boolean; dockerTagQualifier: string | null; dockerPush: boolean; } @@ -35,6 +36,7 @@ export class Config { isRelease, targetAllPlatforms, versionQualifier, + dockerCrossCompile, dockerTagQualifier, dockerPush, }: Options) { @@ -51,6 +53,7 @@ export class Config { versionQualifier, pkg, }), + dockerCrossCompile, dockerTagQualifier, dockerPush, isRelease @@ -63,6 +66,7 @@ export class Config { private readonly nodeVersion: string, private readonly repoRoot: string, private readonly versionInfo: VersionInfo, + private readonly dockerCrossCompile: boolean, private readonly dockerTagQualifier: string | null, private readonly dockerPush: boolean, public readonly isRelease: boolean @@ -96,6 +100,13 @@ export class Config { return this.dockerPush; } + /** + * Get docker cross compile + */ + getDockerCrossCompile() { + return this.dockerCrossCompile; + } + /** * Convert an absolute path to a relative path, based from the repo */ diff --git a/src/dev/build/lib/runner.test.ts b/src/dev/build/lib/runner.test.ts index 7c49c35446833..94ff3cb338176 100644 --- a/src/dev/build/lib/runner.test.ts +++ b/src/dev/build/lib/runner.test.ts @@ -50,6 +50,7 @@ const setup = async () => { isRelease: true, targetAllPlatforms: true, versionQualifier: '-SNAPSHOT', + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/download_cloud_dependencies.ts b/src/dev/build/tasks/download_cloud_dependencies.ts index 6ecc09c21ddce..31873550f6b4a 100644 --- a/src/dev/build/tasks/download_cloud_dependencies.ts +++ b/src/dev/build/tasks/download_cloud_dependencies.ts @@ -20,18 +20,24 @@ export const DownloadCloudDependencies: Task = { const version = config.getBuildVersion(); const buildId = id.match(/[0-9]\.[0-9]\.[0-9]-[0-9a-z]{8}/); const buildIdUrl = buildId ? `${buildId[0]}/` : ''; - const architecture = process.arch === 'arm64' ? 'arm64' : 'x86_64'; - const url = `https://${subdomain}-no-kpi.elastic.co/${buildIdUrl}downloads/beats/${beat}/${beat}-${version}-linux-${architecture}.tar.gz`; - const checksum = await downloadToString({ log, url: url + '.sha512', expectStatus: 200 }); - const destination = config.resolveFromRepo('.beats', Path.basename(url)); - return downloadToDisk({ - log, - url, - destination, - shaChecksum: checksum.split(' ')[0], - shaAlgorithm: 'sha512', - maxAttempts: 3, + + const localArchitecture = [process.arch === 'arm64' ? 'arm64' : 'x86_64']; + const allArchitectures = ['arm64', 'x86_64']; + const architectures = config.getDockerCrossCompile() ? allArchitectures : localArchitecture; + const downloads = architectures.map(async (arch) => { + const url = `https://${subdomain}-no-kpi.elastic.co/${buildIdUrl}downloads/beats/${beat}/${beat}-${version}-linux-${arch}.tar.gz`; + const checksum = await downloadToString({ log, url: url + '.sha512', expectStatus: 200 }); + const destination = config.resolveFromRepo('.beats', Path.basename(url)); + return downloadToDisk({ + log, + url, + destination, + shaChecksum: checksum.split(' ')[0], + shaAlgorithm: 'sha512', + maxAttempts: 3, + }); }); + return Promise.all(downloads); }; let buildId = ''; diff --git a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts index b1309bd05c603..c3b9cd5f8c6b1 100644 --- a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts @@ -39,6 +39,7 @@ async function setup({ failOnUrl }: { failOnUrl?: string } = {}) { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts index fb0891c24f3b0..0041829984aa7 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts @@ -43,6 +43,7 @@ async function setup() { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts index 3a71a2b06fe91..85458c29ddcff 100644 --- a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts @@ -48,6 +48,7 @@ async function setup(actualShaSums?: Record) { const config = await Config.create({ isRelease: true, targetAllPlatforms: true, + dockerCrossCompile: false, dockerPush: false, dockerTagQualifier: '', }); diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 332605e926537..3152f07628fc9 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -76,6 +76,7 @@ export async function runDockerGenerator( const dockerPush = config.getDockerPush(); const dockerTagQualifier = config.getDockerTagQualfiier(); + const dockerCrossCompile = config.getDockerCrossCompile(); const publicArtifactSubdomain = config.isRelease ? 'artifacts' : 'snapshots-no-kpi'; const scope: TemplateContext = { @@ -110,7 +111,7 @@ export async function runDockerGenerator( arm64: 'aarch64', }; const buildArchitectureSupported = hostTarget[process.arch] === flags.architecture; - if (flags.architecture && !buildArchitectureSupported) { + if (flags.architecture && !buildArchitectureSupported && !dockerCrossCompile) { return; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index de26705566585..a14de2a0581ff 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -23,6 +23,7 @@ function generator({ const dockerTargetName = `${imageTag}${imageFlavor}:${version}${ dockerTagQualifier ? '-' + dockerTagQualifier : '' }`; + const dockerArchitecture = architecture === 'aarch64' ? 'linux/arm64' : 'linux/amd64'; return dedent(` #!/usr/bin/env bash # @@ -59,7 +60,7 @@ function generator({ retry_docker_pull ${baseOSImage} echo "Building: kibana${imageFlavor}-docker"; \\ - docker build -t ${dockerTargetName} -f Dockerfile . || exit 1; + docker buildx build --platform ${dockerArchitecture} -t ${dockerTargetName} -f Dockerfile . || exit 1; docker save ${dockerTargetName} | gzip -c > ${dockerTargetFilename} From cab3041613b3015cd3399d27ca4673cdedb4d1c7 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 23 Mar 2022 17:12:46 -0400 Subject: [PATCH 096/132] [Fleet] Do not allow to edit output for managed policies (#128298) --- .../agent_policy_advanced_fields/index.tsx | 2 + .../server/routes/agent_policy/handlers.ts | 4 +- .../fleet/server/services/agent_policy.ts | 23 +++++- .../server/services/preconfiguration.test.ts | 5 +- .../fleet/server/services/preconfiguration.ts | 15 +++- .../server/types/rest_spec/agent_policy.ts | 4 +- .../apis/agent_policy/agent_policy.ts | 77 +++++++++++++------ .../apis/package_policy/delete.ts | 2 + 8 files changed, 102 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 1ba7f09d0333d..9fdcc0f73297f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -309,6 +309,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = isInvalid={Boolean(touchedFields.data_output_id && validation.data_output_id)} > = isInvalid={Boolean(touchedFields.monitoring_output_id && validation.monitoring_output_id)} > , - options?: { user?: AuthenticatedUser } + options?: { user?: AuthenticatedUser; force?: boolean } ): Promise { if (agentPolicy.name) { await this.requireUniqueName(soClient, { @@ -352,6 +354,23 @@ class AgentPolicyService { name: agentPolicy.name, }); } + + const existingAgentPolicy = await this.get(soClient, id, true); + + if (!existingAgentPolicy) { + throw new Error('Agent policy not found'); + } + + if (existingAgentPolicy.is_managed && !options?.force) { + Object.entries(agentPolicy) + .filter(([key]) => !KEY_EDITABLE_FOR_MANAGED_POLICIES.includes(key)) + .forEach(([key, val]) => { + if (!isEqual(existingAgentPolicy[key as keyof AgentPolicy], val)) { + throw new HostedAgentPolicyRestrictionRelatedError(`Cannot update ${key}`); + } + }); + } + return this._update(soClient, esClient, id, agentPolicy, options?.user); } diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 27919d7bf1011..862b589896793 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -689,7 +689,10 @@ describe('policy preconfiguration', () => { name: 'Renamed Test policy', description: 'Renamed Test policy description', unenroll_timeout: 999, - }) + }), + { + force: true, + } ); expect(policies.length).toEqual(1); expect(policies[0].id).toBe('test-id'); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index 6f8c8bbc6a20d..c11925fa8f2f3 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -159,7 +159,10 @@ export async function ensurePreconfiguredPackagesAndPolicies( soClient, esClient, String(preconfiguredAgentPolicy.id), - fields + fields, + { + force: true, + } ); return { created, policy: updatedPolicy }; } @@ -254,7 +257,15 @@ export async function ensurePreconfiguredPackagesAndPolicies( // Add the is_managed flag after configuring package policies to avoid errors if (shouldAddIsManagedFlag) { - await agentPolicyService.update(soClient, esClient, policy!.id, { is_managed: true }); + await agentPolicyService.update( + soClient, + esClient, + policy!.id, + { is_managed: true }, + { + force: true, + } + ); } } } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts index 64d142f150bfd..042129e1e0914 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent_policy.ts @@ -32,7 +32,9 @@ export const CreateAgentPolicyRequestSchema = { export const UpdateAgentPolicyRequestSchema = { ...GetOneAgentPolicyRequestSchema, - body: NewAgentPolicySchema, + body: NewAgentPolicySchema.extends({ + force: schema.maybe(schema.boolean()), + }), }; export const CopyAgentPolicyRequestSchema = { diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 0e3cd9796626d..6c2c2c7bc8b48 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -478,6 +478,38 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('should return a 409 if policy already exists with name given', async () => { + const sharedBody = { + name: 'Initial name', + description: 'Initial description', + namespace: 'default', + }; + + await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(200); + + const { body } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(409); + + expect(body.message).to.match(/already exists?/); + + // same name, different namespace + sharedBody.namespace = 'different'; + await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send(sharedBody) + .expect(409); + + expect(body.message).to.match(/already exists?/); + }); + it('sets given is_managed value', async () => { const { body: { item: createdPolicy }, @@ -504,6 +536,7 @@ export default function (providerContext: FtrProviderContext) { name: 'TEST2', namespace: 'default', is_managed: false, + force: true, }) .expect(200); @@ -513,36 +546,33 @@ export default function (providerContext: FtrProviderContext) { expect(policy2.is_managed).to.equal(false); }); - it('should return a 409 if policy already exists with name given', async () => { - const sharedBody = { - name: 'Initial name', - description: 'Initial description', - namespace: 'default', - }; - - await supertest + it('should return a 400 if trying to update a managed policy', async () => { + const { + body: { item: originalPolicy }, + } = await supertest .post(`/api/fleet/agent_policies`) .set('kbn-xsrf', 'xxxx') - .send(sharedBody) + .send({ + name: `Managed policy ${Date.now()}`, + description: 'Initial description', + namespace: 'default', + is_managed: true, + }) .expect(200); const { body } = await supertest - .post(`/api/fleet/agent_policies`) + .put(`/api/fleet/agent_policies/${originalPolicy.id}`) .set('kbn-xsrf', 'xxxx') - .send(sharedBody) - .expect(409); - - expect(body.message).to.match(/already exists?/); - - // same name, different namespace - sharedBody.namespace = 'different'; - await supertest - .post(`/api/fleet/agent_policies`) - .set('kbn-xsrf', 'xxxx') - .send(sharedBody) - .expect(409); + .send({ + name: 'Updated name', + description: 'Initial description', + namespace: 'default', + }) + .expect(400); - expect(body.message).to.match(/already exists?/); + expect(body.message).to.equal( + 'Cannot update name in Fleet because the agent policy is managed by an external orchestration solution, such as Elastic Cloud, Kubernetes, etc. Please make changes using your orchestration solution.' + ); }); }); @@ -586,6 +616,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Regular policy', namespace: 'default', is_managed: false, + force: true, }) .expect(200); diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts index 5a5fb68a1dbc7..1f7377ba189ba 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/delete.ts @@ -48,6 +48,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Test policy', namespace: 'default', is_managed: false, + force: true, }); } } @@ -138,6 +139,7 @@ export default function (providerContext: FtrProviderContext) { name: agentPolicy.name, namespace: agentPolicy.namespace, is_managed: false, + force: true, }) .expect(200); }); From 2f9e6eeacfac1d53a8ed91c8d7ddfe845a4e12cd Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Wed, 23 Mar 2022 22:16:50 +0100 Subject: [PATCH 097/132] [Lens] Manual Annotations (#126456) * Add event annotation service structure * adding annotation layer to lens. passing event annotation service * simplify initial Dimensions * add annotations to lens * no datasource layer * group the annotations into numerical icons * color icons in tooltip, add the annotation icon, fix date interval bug * display old time axis for annotations * error in annotation dimension when date histogram is removed * refactor: use the same methods for annotations and reference lines * wip * only check activeData for dataLayers * added new icons for annotations * refactor icons * uniqueLabels * unique Labels * diff config from args * change timestamp format * added expression event_annotation_group * names refactor * ea service adding help descriptions * rotate icon * added tests * fix button problem * dnd problem * dnd fix * tests for dimension trigger * tests for unique labels * [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' * type * add new button test * remove noDatasource from config (only needed when initializing a layer or dimension in getSupportedLayers) * addressing Joe's and Michael comments * remove hexagon and square, address Stratoula's feedback * stroke for icons & icon fill * fix tests * fix small things * align the set with tsvb * align IconSelect * fix i18nrc * Update src/plugins/event_annotation/public/event_annotation_service/index.tsx Co-authored-by: Alexey Antonov * refactor empty button * CR * date cr * remove DimensionEditorSection * change to emptyShade for traingle fill * Update x-pack/plugins/lens/public/app_plugin/app.scss Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Alexey Antonov --- .i18nrc.json | 17 +- docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/plugins/event_annotation/README.md | 3 + .../common/event_annotation_group/index.ts | 52 +++ src/plugins/event_annotation/common/index.ts | 13 + .../common/manual_event_annotation/index.ts | 82 +++++ .../common/manual_event_annotation/types.ts | 15 + src/plugins/event_annotation/common/types.ts | 29 ++ src/plugins/event_annotation/jest.config.js | 18 + src/plugins/event_annotation/kibana.json | 17 + .../public/event_annotation_service/README.md | 3 + .../event_annotation_service/helpers.ts | 9 + .../public/event_annotation_service/index.tsx | 20 ++ .../event_annotation_service/service.tsx | 49 +++ .../public/event_annotation_service/types.ts | 14 + src/plugins/event_annotation/public/index.ts | 17 + src/plugins/event_annotation/public/mocks.ts | 12 + src/plugins/event_annotation/public/plugin.ts | 39 +++ src/plugins/event_annotation/server/index.ts | 10 + src/plugins/event_annotation/server/plugin.ts | 30 ++ src/plugins/event_annotation/tsconfig.json | 22 ++ x-pack/plugins/lens/common/constants.ts | 1 + .../layer_config/annotation_layer_config.ts | 67 ++++ .../xy_chart/layer_config/index.ts | 7 +- .../common/expressions/xy_chart/xy_args.ts | 5 +- .../common/expressions/xy_chart/xy_chart.ts | 8 +- x-pack/plugins/lens/kibana.json | 3 +- .../plugins/lens/public/app_plugin/app.scss | 7 + .../public/assets/annotation_icons/circle.tsx | 31 ++ .../public/assets/annotation_icons/index.tsx | 9 + .../assets/annotation_icons/triangle.tsx | 30 ++ .../public/assets/chart_bar_annotations.tsx | 37 ++ .../buttons/draggable_dimension_button.tsx | 5 +- .../buttons/drop_targets_utils.tsx | 12 +- .../buttons/empty_dimension_button.tsx | 79 +++-- .../config_panel/config_panel.test.tsx | 75 +++- .../config_panel/config_panel.tsx | 94 +++--- .../config_panel/layer_panel.test.tsx | 82 +++-- .../editor_frame/config_panel/layer_panel.tsx | 233 +++++++------ x-pack/plugins/lens/public/expressions.ts | 2 + .../dimension_editor.tsx | 136 ++++---- .../droppable/get_drop_props.ts | 13 +- .../indexpattern.test.ts | 4 - .../indexpattern_datasource/indexpattern.tsx | 2 +- .../lens/public/pie_visualization/toolbar.tsx | 24 +- x-pack/plugins/lens/public/plugin.ts | 14 +- .../shared_components/dimension_section.scss | 24 ++ .../shared_components/dimension_section.tsx | 29 ++ .../lens/public/shared_components/index.ts | 1 + .../public/state_management/lens_slice.ts | 89 +++-- x-pack/plugins/lens/public/types.ts | 39 ++- .../visualizations/gauge/visualization.tsx | 6 - .../__snapshots__/expression.test.tsx.snap | 213 ++++++++++++ .../annotations/config_panel/icon_set.ts | 97 ++++++ .../annotations/config_panel/index.scss | 3 + .../annotations/config_panel/index.tsx | 186 ++++++++++ .../annotations/expression.scss | 37 ++ .../annotations/expression.tsx | 233 +++++++++++++ .../annotations/helpers.test.ts | 210 ++++++++++++ .../xy_visualization/annotations/helpers.tsx | 240 +++++++++++++ .../xy_visualization/annotations_helpers.tsx | 253 ++++++++++++++ .../xy_visualization/color_assignment.ts | 35 +- .../xy_visualization/expression.test.tsx | 238 +++++++++++-- .../public/xy_visualization/expression.tsx | 110 ++++-- .../expression_reference_lines.tsx | 228 ++----------- .../lens/public/xy_visualization/index.ts | 7 +- .../reference_line_helpers.tsx | 24 +- .../public/xy_visualization/state_helpers.ts | 5 +- .../xy_visualization/to_expression.test.ts | 2 + .../public/xy_visualization/to_expression.ts | 179 +++++++--- .../xy_visualization/visualization.test.ts | 319 +++++++++++++++++- .../public/xy_visualization/visualization.tsx | 111 ++++-- .../visualization_helpers.tsx | 41 ++- .../xy_config_panel/color_picker.tsx | 9 +- .../xy_config_panel/layer_header.tsx | 16 +- .../xy_config_panel/reference_line_panel.tsx | 1 + .../xy_config_panel/shared/icon_select.tsx | 32 +- .../shared/line_style_settings.tsx | 7 +- .../shared/marker_decoration_settings.tsx | 33 +- .../xy_visualization/xy_suggestions.test.ts | 58 +++- .../public/xy_visualization/xy_suggestions.ts | 5 +- .../lens/server/expressions/expressions.ts | 2 + x-pack/plugins/lens/tsconfig.json | 110 ++++-- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 87 files changed, 3885 insertions(+), 806 deletions(-) create mode 100644 src/plugins/event_annotation/README.md create mode 100644 src/plugins/event_annotation/common/event_annotation_group/index.ts create mode 100644 src/plugins/event_annotation/common/index.ts create mode 100644 src/plugins/event_annotation/common/manual_event_annotation/index.ts create mode 100644 src/plugins/event_annotation/common/manual_event_annotation/types.ts create mode 100644 src/plugins/event_annotation/common/types.ts create mode 100644 src/plugins/event_annotation/jest.config.js create mode 100644 src/plugins/event_annotation/kibana.json create mode 100644 src/plugins/event_annotation/public/event_annotation_service/README.md create mode 100644 src/plugins/event_annotation/public/event_annotation_service/helpers.ts create mode 100644 src/plugins/event_annotation/public/event_annotation_service/index.tsx create mode 100644 src/plugins/event_annotation/public/event_annotation_service/service.tsx create mode 100644 src/plugins/event_annotation/public/event_annotation_service/types.ts create mode 100644 src/plugins/event_annotation/public/index.ts create mode 100644 src/plugins/event_annotation/public/mocks.ts create mode 100644 src/plugins/event_annotation/public/plugin.ts create mode 100644 src/plugins/event_annotation/server/index.ts create mode 100644 src/plugins/event_annotation/server/plugin.ts create mode 100644 src/plugins/event_annotation/tsconfig.json create mode 100644 x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/index.tsx create mode 100644 x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx create mode 100644 x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_section.scss create mode 100644 x-pack/plugins/lens/public/shared_components/dimension_section.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/icon_set.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/index.scss create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/config_panel/index.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx create mode 100644 x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx diff --git a/.i18nrc.json b/.i18nrc.json index 402932902f249..71b68d2c51d85 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -31,6 +31,7 @@ "expressions": "src/plugins/expressions", "expressionShape": "src/plugins/expression_shape", "expressionTagcloud": "src/plugins/chart_expressions/expression_tagcloud", + "eventAnnotation": "src/plugins/event_annotation", "fieldFormats": "src/plugins/field_formats", "flot": "packages/kbn-flot-charts/lib", "home": "src/plugins/home", @@ -50,7 +51,10 @@ "kibana-react": "src/plugins/kibana_react", "kibanaOverview": "src/plugins/kibana_overview", "lists": "packages/kbn-securitysolution-list-utils/src", - "management": ["src/legacy/core_plugins/management", "src/plugins/management"], + "management": [ + "src/legacy/core_plugins/management", + "src/plugins/management" + ], "monaco": "packages/kbn-monaco/src", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", @@ -62,8 +66,13 @@ "sharedUX": "src/plugins/shared_ux", "sharedUXComponents": "packages/kbn-shared-ux-components/src", "statusPage": "src/legacy/core_plugins/status_page", - "telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"], - "timelion": ["src/plugins/vis_types/timelion"], + "telemetry": [ + "src/plugins/telemetry", + "src/plugins/telemetry_management_section" + ], + "timelion": [ + "src/plugins/vis_types/timelion" + ], "uiActions": "src/plugins/ui_actions", "uiActionsExamples": "examples/ui_action_examples", "usageCollection": "src/plugins/usage_collection", @@ -83,4 +92,4 @@ "visualizations": "src/plugins/visualizations" }, "translations": [] -} +} \ No newline at end of file diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index bf81ab1e0bec4..aefaf4eab40fa 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -94,6 +94,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a |This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module. +|{kib-repo}blob/{branch}/src/plugins/event_annotation/README.md[eventAnnotation] +|The Event Annotation service contains expressions for event annotations + + |{kib-repo}blob/{branch}/src/plugins/expression_error/README.md[expressionError] |Expression Error plugin adds an error renderer to the expression plugin. The renderer will display the error image. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 396ffd4599284..526c1ff5dad82 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -124,3 +124,4 @@ pageLoadAssetSize: sessionView: 77750 cloudSecurityPosture: 19109 visTypeGauge: 24113 + eventAnnotation: 19334 diff --git a/src/plugins/event_annotation/README.md b/src/plugins/event_annotation/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/common/event_annotation_group/index.ts b/src/plugins/event_annotation/common/event_annotation_group/index.ts new file mode 100644 index 0000000000000..85f1d9dff900c --- /dev/null +++ b/src/plugins/event_annotation/common/event_annotation_group/index.ts @@ -0,0 +1,52 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationOutput } from '../manual_event_annotation/types'; + +export interface EventAnnotationGroupOutput { + type: 'event_annotation_group'; + annotations: EventAnnotationOutput[]; +} + +export interface EventAnnotationGroupArgs { + annotations: EventAnnotationOutput[]; +} + +export function eventAnnotationGroup(): ExpressionFunctionDefinition< + 'event_annotation_group', + null, + EventAnnotationGroupArgs, + EventAnnotationGroupOutput +> { + return { + name: 'event_annotation_group', + aliases: [], + type: 'event_annotation_group', + inputTypes: ['null'], + help: i18n.translate('eventAnnotation.group.description', { + defaultMessage: 'Event annotation group', + }), + args: { + annotations: { + types: ['manual_event_annotation'], + help: i18n.translate('eventAnnotation.group.args.annotationConfigs', { + defaultMessage: 'Annotation configs', + }), + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'event_annotation_group', + annotations: args.annotations, + }; + }, + }; +} diff --git a/src/plugins/event_annotation/common/index.ts b/src/plugins/event_annotation/common/index.ts new file mode 100644 index 0000000000000..332fa19150aad --- /dev/null +++ b/src/plugins/event_annotation/common/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type { EventAnnotationArgs, EventAnnotationOutput } from './manual_event_annotation/types'; +export { manualEventAnnotation } from './manual_event_annotation'; +export { eventAnnotationGroup } from './event_annotation_group'; +export type { EventAnnotationGroupArgs } from './event_annotation_group'; +export type { EventAnnotationConfig } from './types'; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/index.ts b/src/plugins/event_annotation/common/manual_event_annotation/index.ts new file mode 100644 index 0000000000000..108df93b34180 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/index.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import type { EventAnnotationArgs, EventAnnotationOutput } from './types'; +export const manualEventAnnotation: ExpressionFunctionDefinition< + 'manual_event_annotation', + null, + EventAnnotationArgs, + EventAnnotationOutput +> = { + name: 'manual_event_annotation', + aliases: [], + type: 'manual_event_annotation', + help: i18n.translate('eventAnnotation.manualAnnotation.description', { + defaultMessage: `Configure manual annotation`, + }), + inputTypes: ['null'], + args: { + time: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.time', { + defaultMessage: `Timestamp for annotation`, + }), + }, + label: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.label', { + defaultMessage: `The name of the annotation`, + }), + }, + color: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.color', { + defaultMessage: 'The color of the line', + }), + }, + lineStyle: { + types: ['string'], + options: ['solid', 'dotted', 'dashed'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineStyle', { + defaultMessage: 'The style of the annotation line', + }), + }, + lineWidth: { + types: ['number'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.lineWidth', { + defaultMessage: 'The width of the annotation line', + }), + }, + icon: { + types: ['string'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.icon', { + defaultMessage: 'An optional icon used for annotation lines', + }), + }, + textVisibility: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.textVisibility', { + defaultMessage: 'Visibility of the label on the annotation line', + }), + }, + isHidden: { + types: ['boolean'], + help: i18n.translate('eventAnnotation.manualAnnotation.args.isHidden', { + defaultMessage: `Switch to hide annotation`, + }), + }, + }, + fn: function fn(input: unknown, args: EventAnnotationArgs) { + return { + type: 'manual_event_annotation', + ...args, + }; + }, +}; diff --git a/src/plugins/event_annotation/common/manual_event_annotation/types.ts b/src/plugins/event_annotation/common/manual_event_annotation/types.ts new file mode 100644 index 0000000000000..e1bed4a592d23 --- /dev/null +++ b/src/plugins/event_annotation/common/manual_event_annotation/types.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { StyleProps } from '../types'; + +export type EventAnnotationArgs = { + time: string; +} & StyleProps; + +export type EventAnnotationOutput = EventAnnotationArgs & { type: 'manual_event_annotation' }; diff --git a/src/plugins/event_annotation/common/types.ts b/src/plugins/event_annotation/common/types.ts new file mode 100644 index 0000000000000..95275804d1d1f --- /dev/null +++ b/src/plugins/event_annotation/common/types.ts @@ -0,0 +1,29 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export type LineStyle = 'solid' | 'dashed' | 'dotted'; +export type AnnotationType = 'manual'; +export type KeyType = 'point_in_time'; + +export interface StyleProps { + label: string; + color?: string; + icon?: string; + lineWidth?: number; + lineStyle?: LineStyle; + textVisibility?: boolean; + isHidden?: boolean; +} + +export type EventAnnotationConfig = { + id: string; + key: { + type: KeyType; + timestamp: string; + }; +} & StyleProps; diff --git a/src/plugins/event_annotation/jest.config.js b/src/plugins/event_annotation/jest.config.js new file mode 100644 index 0000000000000..a6ea4a6b430df --- /dev/null +++ b/src/plugins/event_annotation/jest.config.js @@ -0,0 +1,18 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/event_annotation'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/event_annotation', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/event_annotation/{common,public,server}/**/*.{ts,tsx}', + ], +}; diff --git a/src/plugins/event_annotation/kibana.json b/src/plugins/event_annotation/kibana.json new file mode 100644 index 0000000000000..5a0c49be09ba3 --- /dev/null +++ b/src/plugins/event_annotation/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "eventAnnotation", + "version": "kibana", + "server": true, + "ui": true, + "description": "The Event Annotation service contains expressions for event annotations", + "extraPublicDirs": [ + "common" + ], + "requiredPlugins": [ + "expressions" + ], + "owner": { + "name": "Vis Editors", + "githubTeam": "kibana-vis-editors" + } +} \ No newline at end of file diff --git a/src/plugins/event_annotation/public/event_annotation_service/README.md b/src/plugins/event_annotation/public/event_annotation_service/README.md new file mode 100644 index 0000000000000..a7a85d3ab3641 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/README.md @@ -0,0 +1,3 @@ +# Event Annotation service + +The Event Annotation service contains expressions for event annotations diff --git a/src/plugins/event_annotation/public/event_annotation_service/helpers.ts b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts new file mode 100644 index 0000000000000..aed33da840574 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/helpers.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { euiLightVars } from '@kbn/ui-theme'; +export const defaultAnnotationColor = euiLightVars.euiColorAccent; diff --git a/src/plugins/event_annotation/public/event_annotation_service/index.tsx b/src/plugins/event_annotation/public/event_annotation_service/index.tsx new file mode 100644 index 0000000000000..e967a7cb0f0a2 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServiceType } from './types'; + +export class EventAnnotationService { + private eventAnnotationService?: EventAnnotationServiceType; + public async getService() { + if (!this.eventAnnotationService) { + const { getEventAnnotationService } = await import('./service'); + this.eventAnnotationService = getEventAnnotationService(); + } + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/service.tsx b/src/plugins/event_annotation/public/event_annotation_service/service.tsx new file mode 100644 index 0000000000000..3d81ea6a3e3a6 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/service.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServiceType } from './types'; +import { defaultAnnotationColor } from './helpers'; + +export function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'empty'; +} + +export function getEventAnnotationService(): EventAnnotationServiceType { + return { + toExpression: ({ + label, + isHidden, + color, + lineStyle, + lineWidth, + icon, + textVisibility, + time, + }) => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'manual_event_annotation', + arguments: { + time: [time], + label: [label], + color: [color || defaultAnnotationColor], + lineWidth: [lineWidth || 1], + lineStyle: [lineStyle || 'solid'], + icon: hasIcon(icon) ? [icon] : ['triangle'], + textVisibility: [textVisibility || false], + isHidden: [Boolean(isHidden)], + }, + }, + ], + }; + }, + }; +} diff --git a/src/plugins/event_annotation/public/event_annotation_service/types.ts b/src/plugins/event_annotation/public/event_annotation_service/types.ts new file mode 100644 index 0000000000000..bb0b6eb4cc200 --- /dev/null +++ b/src/plugins/event_annotation/public/event_annotation_service/types.ts @@ -0,0 +1,14 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionAstExpression } from '../../../expressions/common/ast'; +import { EventAnnotationArgs } from '../../common'; + +export interface EventAnnotationServiceType { + toExpression: (props: EventAnnotationArgs) => ExpressionAstExpression; +} diff --git a/src/plugins/event_annotation/public/index.ts b/src/plugins/event_annotation/public/index.ts new file mode 100644 index 0000000000000..c15429c94cbe4 --- /dev/null +++ b/src/plugins/event_annotation/public/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// TODO: https://github.com/elastic/kibana/issues/110891 +/* eslint-disable @kbn/eslint/no_export_all */ + +import { EventAnnotationPlugin } from './plugin'; +export const plugin = () => new EventAnnotationPlugin(); +export type { EventAnnotationPluginSetup, EventAnnotationPluginStart } from './plugin'; +export * from './event_annotation_service/types'; +export { EventAnnotationService } from './event_annotation_service'; +export { defaultAnnotationColor } from './event_annotation_service/helpers'; diff --git a/src/plugins/event_annotation/public/mocks.ts b/src/plugins/event_annotation/public/mocks.ts new file mode 100644 index 0000000000000..e78d4e8f75de7 --- /dev/null +++ b/src/plugins/event_annotation/public/mocks.ts @@ -0,0 +1,12 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getEventAnnotationService } from './event_annotation_service/service'; + +// not really mocking but avoiding async loading +export const eventAnnotationServiceMock = getEventAnnotationService(); diff --git a/src/plugins/event_annotation/public/plugin.ts b/src/plugins/event_annotation/public/plugin.ts new file mode 100644 index 0000000000000..83cdc0546a7f5 --- /dev/null +++ b/src/plugins/event_annotation/public/plugin.ts @@ -0,0 +1,39 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Plugin, CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../expressions/public'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { EventAnnotationService } from './event_annotation_service'; + +interface SetupDependencies { + expressions: ExpressionsSetup; +} + +/** @public */ +export type EventAnnotationPluginSetup = EventAnnotationService; + +/** @public */ +export type EventAnnotationPluginStart = EventAnnotationService; + +/** @public */ +export class EventAnnotationPlugin + implements Plugin +{ + private readonly eventAnnotationService = new EventAnnotationService(); + + public setup(core: CoreSetup, dependencies: SetupDependencies): EventAnnotationPluginSetup { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + return this.eventAnnotationService; + } + + public start(): EventAnnotationPluginStart { + return this.eventAnnotationService; + } +} diff --git a/src/plugins/event_annotation/server/index.ts b/src/plugins/event_annotation/server/index.ts new file mode 100644 index 0000000000000..d9d13045ed10a --- /dev/null +++ b/src/plugins/event_annotation/server/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EventAnnotationServerPlugin } from './plugin'; +export const plugin = () => new EventAnnotationServerPlugin(); diff --git a/src/plugins/event_annotation/server/plugin.ts b/src/plugins/event_annotation/server/plugin.ts new file mode 100644 index 0000000000000..ef4e0216fb5ac --- /dev/null +++ b/src/plugins/event_annotation/server/plugin.ts @@ -0,0 +1,30 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, Plugin } from 'kibana/server'; +import { manualEventAnnotation, eventAnnotationGroup } from '../common'; +import { ExpressionsServerSetup } from '../../expressions/server'; + +interface SetupDependencies { + expressions: ExpressionsServerSetup; +} + +export class EventAnnotationServerPlugin implements Plugin { + public setup(core: CoreSetup, dependencies: SetupDependencies) { + dependencies.expressions.registerFunction(manualEventAnnotation); + dependencies.expressions.registerFunction(eventAnnotationGroup); + + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/event_annotation/tsconfig.json b/src/plugins/event_annotation/tsconfig.json new file mode 100644 index 0000000000000..ca3d65a13b214 --- /dev/null +++ b/src/plugins/event_annotation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { + "path": "../../core/tsconfig.json" + }, + { + "path": "../expressions/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index 1504e33ecacab..d0bfecbd386be 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -45,6 +45,7 @@ export const LegendDisplay = { export const layerTypes = { DATA: 'data', REFERENCELINE: 'referenceLine', + ANNOTATIONS: 'annotations', } as const; // might collide with user-supplied field names, try to make as unique as possible diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts new file mode 100644 index 0000000000000..45b4bf31c0cdc --- /dev/null +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/annotation_layer_config.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EventAnnotationConfig, + EventAnnotationOutput, +} from '../../../../../../../src/plugins/event_annotation/common'; +import type { ExpressionFunctionDefinition } from '../../../../../../../src/plugins/expressions/common'; +import { layerTypes } from '../../../constants'; + +export interface XYAnnotationLayerConfig { + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + annotations: EventAnnotationConfig[]; + hide?: boolean; +} + +export interface AnnotationLayerArgs { + annotations: EventAnnotationOutput[]; + layerId: string; + layerType: typeof layerTypes.ANNOTATIONS; + hide?: boolean; +} +export type XYAnnotationLayerArgsResult = AnnotationLayerArgs & { + type: 'lens_xy_annotation_layer'; +}; +export function annotationLayerConfig(): ExpressionFunctionDefinition< + 'lens_xy_annotation_layer', + null, + AnnotationLayerArgs, + XYAnnotationLayerArgsResult +> { + return { + name: 'lens_xy_annotation_layer', + aliases: [], + type: 'lens_xy_annotation_layer', + inputTypes: ['null'], + help: 'Annotation layer in lens', + args: { + layerId: { + types: ['string'], + help: '', + }, + layerType: { types: ['string'], options: [layerTypes.ANNOTATIONS], help: '' }, + hide: { + types: ['boolean'], + default: false, + help: 'Show details', + }, + annotations: { + types: ['manual_event_annotation'], + help: '', + multi: true, + }, + }, + fn: (input, args) => { + return { + type: 'lens_xy_annotation_layer', + ...args, + }; + }, + }; +} diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts index 0b27ce7d6ed85..df27229bdb81f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/layer_config/index.ts @@ -6,7 +6,12 @@ */ import { XYDataLayerConfig } from './data_layer_config'; import { XYReferenceLineLayerConfig } from './reference_line_layer_config'; +import { XYAnnotationLayerConfig } from './annotation_layer_config'; export * from './data_layer_config'; export * from './reference_line_layer_config'; +export * from './annotation_layer_config'; -export type XYLayerConfig = XYDataLayerConfig | XYReferenceLineLayerConfig; +export type XYLayerConfig = + | XYDataLayerConfig + | XYReferenceLineLayerConfig + | XYAnnotationLayerConfig; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts index 940896a2079e6..4520f0c99c3e9 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_args.ts @@ -9,13 +9,14 @@ import type { AxisExtentConfigResult, AxisTitlesVisibilityConfigResult } from '. import type { FittingFunction } from './fitting_function'; import type { EndValue } from './end_value'; import type { GridlinesConfigResult } from './grid_lines_config'; -import type { DataLayerArgs } from './layer_config'; +import type { AnnotationLayerArgs, DataLayerArgs } from './layer_config'; import type { LegendConfigResult } from './legend_config'; import type { TickLabelsConfigResult } from './tick_labels_config'; import type { LabelsOrientationConfigResult } from './labels_orientation_config'; import type { ValueLabelConfig } from '../../types'; export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; +export type XYLayerArgs = DataLayerArgs | AnnotationLayerArgs; // Arguments to XY chart expression, with computed properties export interface XYArgs { @@ -28,7 +29,7 @@ export interface XYArgs { yRightExtent: AxisExtentConfigResult; legend: LegendConfigResult; valueLabels: ValueLabelConfig; - layers: DataLayerArgs[]; + layers: XYLayerArgs[]; fittingFunction?: FittingFunction; endValue?: EndValue; emphasizeFitting?: boolean; diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts index d0f278d382be9..6d73e8eb9ba5f 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/xy_chart.ts @@ -128,8 +128,12 @@ export const xyChart: ExpressionFunctionDefinition< }), }, layers: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - types: ['lens_xy_data_layer', 'lens_xy_referenceLine_layer'] as any, + types: [ + 'lens_xy_data_layer', + 'lens_xy_referenceLine_layer', + 'lens_xy_annotation_layer', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, help: 'Layers of visual series', multi: true, }, diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 17a58a0f96770..18f33adf40840 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -21,7 +21,8 @@ "presentationUtil", "dataViewFieldEditor", "expressionGauge", - "expressionHeatmap" + "expressionHeatmap", + "eventAnnotation" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 83b0a39be9229..5e859c1a93818 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -38,6 +38,13 @@ fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); } } +.lensAnnotationIconNoFill { + fill: none; +} + +.lensAnnotationIconFill { + fill: $euiColorGhost; +} // Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. .lnsNavItem__goBack { diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx new file mode 100644 index 0000000000000..fe19dc7e4c8fc --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/circle.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconCircle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx new file mode 100644 index 0000000000000..9e641d495582f --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { IconCircle } from './circle'; +export { IconTriangle } from './triangle'; diff --git a/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx new file mode 100644 index 0000000000000..9924c049004cf --- /dev/null +++ b/x-pack/plugins/lens/public/assets/annotation_icons/triangle.tsx @@ -0,0 +1,30 @@ +/* + * 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 * as React from 'react'; +import { EuiIconProps } from '@elastic/eui'; +import classnames from 'classnames'; + +export const IconTriangle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx new file mode 100644 index 0000000000000..63fc9023533f6 --- /dev/null +++ b/x-pack/plugins/lens/public/assets/chart_bar_annotations.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarAnnotations = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + + + +); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index e88b04588d2e0..f0e0911b708fd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -18,6 +18,7 @@ import { getCustomDropTarget, getAdditionalClassesOnDroppable, getAdditionalClassesOnEnter, + getDropProps, } from './drop_targets_utils'; export function DraggableDimensionButton({ @@ -59,8 +60,8 @@ export function DraggableDimensionButton({ }) { const { dragging } = useContext(DragContext); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId, filterOperations: group.filterOperations, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx index 7d92eb9d22cbb..a293af4d11bfe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -9,7 +9,7 @@ import React from 'react'; import classNames from 'classnames'; import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DropType } from '../../../../types'; +import { Datasource, DropType, GetDropProps } from '../../../../types'; function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') { switch (type) { @@ -129,3 +129,13 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => { return 'lnsDragDrop-notCompatible'; } }; + +export const getDropProps = ( + layerDatasource: Datasource, + layerDatasourceDropProps: GetDropProps +) => { + if (layerDatasource) { + return layerDatasource.getDropProps(layerDatasourceDropProps); + } + return; +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index 1ba3ff8f6ac34..f2118bda216b8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -14,7 +14,11 @@ import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types'; import { LayerDatasourceDropProps } from '../types'; -import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; +import { + getCustomDropTarget, + getAdditionalClassesOnDroppable, + getDropProps, +} from './drop_targets_utils'; const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', @@ -24,32 +28,47 @@ interface EmptyButtonProps { columnId: string; onClick: (id: string) => void; group: VisualizationDimensionGroupConfig; + labels?: { + ariaLabel: (label: string) => string; + label: JSX.Element | string; + }; } -const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( - + i18n.translate('xpack.lens.indexPattern.addColumnAriaLabel', { defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, - })} - data-test-subj="lns-empty-dimension" - onClick={() => { - onClick(columnId); - }} - > + values: { groupLabel: l }, + }), + label: ( - -); + ), +}; + +const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => { + const { buttonAriaLabel, buttonLabel } = group.labels || {}; + return ( + { + onClick(columnId); + }} + > + {buttonLabel || defaultButtonLabels.label} + + ); +}; const SuggestedValueButton = ({ columnId, group, onClick }: EmptyButtonProps) => ( contentProps={{ className: 'lnsLayerPanel__triggerTextContent', }} - aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', { - defaultMessage: 'Add or drag-and-drop a field to {groupLabel}', - values: { groupLabel: group.groupLabel }, + aria-label={i18n.translate('xpack.lens.indexPattern.suggestedValueAriaLabel', { + defaultMessage: 'Suggested value: {value} for {groupLabel}', + values: { value: group.suggestedValue?.(), groupLabel: group.groupLabel }, })} data-test-subj="lns-empty-dimension-suggested-value" onClick={() => { @@ -112,8 +131,8 @@ export function EmptyDimensionButton({ setNewColumnId(generateId()); }, [itemIndex]); - const dropProps = layerDatasource.getDropProps({ - ...layerDatasourceDropProps, + const dropProps = getDropProps(layerDatasource, { + ...(layerDatasourceDropProps || {}), dragging, columnId: newColumnId, filterOperations: group.filterOperations, @@ -151,6 +170,12 @@ export function EmptyDimensionButton({ [value, onDrop] ); + const buttonProps: EmptyButtonProps = { + columnId: value.columnId, + onClick, + group, + }; + return (
    {typeof group.suggestedValue?.() === 'number' ? ( - + ) : ( - + )}
    diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index cd26cd3197587..b234b18f5262f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -20,7 +20,7 @@ import { LayerPanel } from './layer_panel'; import { coreMock } from 'src/core/public/mocks'; import { generateId } from '../../../id_generator'; import { mountWithProvider } from '../../../mocks'; -import { layerTypes } from '../../../../common'; +import { LayerType, layerTypes } from '../../../../common'; import { ReactWrapper } from 'enzyme'; import { addLayer } from '../../../state_management'; @@ -231,14 +231,17 @@ describe('ConfigPanel', () => { }); describe('initial default value', () => { - function clickToAddLayer(instance: ReactWrapper) { + function clickToAddLayer( + instance: ReactWrapper, + layerType: LayerType = layerTypes.REFERENCELINE + ) { act(() => { instance.find('[data-test-subj="lnsLayerAddButton"]').first().simulate('click'); }); instance.update(); act(() => { instance - .find(`[data-test-subj="lnsLayerAddButton-${layerTypes.REFERENCELINE}"]`) + .find(`[data-test-subj="lnsLayerAddButton-${layerType}"]`) .first() .simulate('click'); }); @@ -288,8 +291,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -319,8 +320,6 @@ describe('ConfigPanel', () => { { groupId: 'testGroup', columnId: 'myColumn', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -335,9 +334,7 @@ describe('ConfigPanel', () => { expect(lensStore.dispatch).toHaveBeenCalledTimes(1); expect(datasourceMap.testDatasource.initializeDimension).toHaveBeenCalledWith({}, 'newId', { columnId: 'myColumn', - dataType: 'number', groupId: 'testGroup', - label: 'Initial value', staticValue: 100, }); }); @@ -354,8 +351,6 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, }, ], @@ -374,11 +369,65 @@ describe('ConfigPanel', () => { { groupId: 'a', columnId: 'newId', - dataType: 'number', - label: 'Initial value', staticValue: 100, } ); }); + + it('When visualization is `noDatasource` should not run datasource methods', async () => { + const datasourceMap = mockDatasourceMap(); + + const visualizationMap = mockVisualizationMap(); + visualizationMap.testVis.setDimension = jest.fn(); + visualizationMap.testVis.getSupportedLayers = jest.fn(() => [ + { + type: layerTypes.DATA, + label: 'Data Layer', + initialDimensions: [ + { + groupId: 'testGroup', + columnId: 'myColumn', + staticValue: 100, + }, + ], + }, + { + type: layerTypes.REFERENCELINE, + label: 'Reference layer', + }, + { + type: layerTypes.ANNOTATIONS, + label: 'Annotations Layer', + noDatasource: true, + initialDimensions: [ + { + groupId: 'a', + columnId: 'newId', + staticValue: 100, + }, + ], + }, + ]); + + datasourceMap.testDatasource.initializeDimension = jest.fn(); + const props = getDefaultProps({ visualizationMap, datasourceMap }); + const { instance, lensStore } = await prepareAndMountComponent(props); + await clickToAddLayer(instance, layerTypes.ANNOTATIONS); + expect(lensStore.dispatch).toHaveBeenCalledTimes(1); + + expect(visualizationMap.testVis.setDimension).toHaveBeenCalledWith({ + columnId: 'newId', + frame: { + activeData: undefined, + datasourceLayers: { + a: expect.anything(), + }, + }, + groupId: 'a', + layerId: 'newId', + prevState: undefined, + }); + expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index d3574abe4f57a..163d1b8ce8e61 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -135,61 +135,57 @@ export function LayerPanels( [dispatchLens] ); - const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; - return ( - {layerIds.map((layerId, layerIndex) => - datasourcePublicAPIs[layerId] ? ( - { - // avoid state update if the datasource does not support initializeDimension - if ( - activeDatasourceId != null && - datasourceMap[activeDatasourceId]?.initializeDimension - ) { - dispatchLens( - setLayerDefaultDimension({ - layerId, - columnId, - groupId, - }) - ); - } - }} - onRemoveLayer={() => { + {layerIds.map((layerId, layerIndex) => ( + { + // avoid state update if the datasource does not support initializeDimension + if ( + activeDatasourceId != null && + datasourceMap[activeDatasourceId]?.initializeDimension + ) { dispatchLens( - removeOrClearLayer({ - visualizationId: activeVisualization.id, + setLayerDefaultDimension({ layerId, - layerIds, + columnId, + groupId, }) ); - removeLayerRef(layerId); - }} - toggleFullscreen={toggleFullscreen} - /> - ) : null - )} + } + }} + onRemoveLayer={() => { + dispatchLens( + removeOrClearLayer({ + visualizationId: activeVisualization.id, + layerId, + layerIds, + }) + ); + removeLayerRef(layerId); + }} + toggleFullscreen={toggleFullscreen} + /> + ))} Hello!, + style: {}, + }, +}; + describe('LayerPanel', () => { let mockVisualization: jest.Mocked; let mockVisualization2: jest.Mocked; let mockDatasource: DatasourceMock; + mockDatasource = createMockDatasource('testDatasource'); let frame: FramePublicAPI; function getDefaultProps() { @@ -611,17 +623,6 @@ describe('LayerPanel', () => { nextLabel: '', }); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -666,17 +667,6 @@ describe('LayerPanel', () => { columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined ); - const draggingField = { - field: { name: 'dragged' }, - indexPatternId: 'a', - id: '1', - humanData: { label: 'Label' }, - ghost: { - children: , - style: {}, - }, - }; - const { instance } = await mountWithProvider( @@ -985,4 +975,52 @@ describe('LayerPanel', () => { ); }); }); + describe('dimension trigger', () => { + it('should render datasource dimension trigger if there is layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).toHaveBeenCalled(); + }); + + it('should render visualization dimension trigger if there is no layer datasource', async () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'x' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const props = getDefaultProps(); + const propsWithVisOnlyLayer = { + ...props, + framePublicAPI: { ...props.framePublicAPI, datasourceLayers: {} }, + }; + + mockVisualization.renderDimensionTrigger = jest.fn(); + mockVisualization.getUniqueLabels = jest.fn(() => ({ + x: 'A', + })); + + await mountWithProvider(); + expect(mockDatasource.renderDimensionTrigger).not.toHaveBeenCalled(); + expect(mockVisualization.renderDimensionTrigger).toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 404a40832fc2f..366d3f93bf842 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -81,10 +81,10 @@ export function LayerPanel( updateDatasourceAsync, visualizationState, } = props; - const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; - const dateRange = useLensSelector(selectResolvedDateRange); + const datasourceStates = useLensSelector(selectDatasourceStates); const isFullscreen = useLensSelector(selectIsFullscreenDatasource); + const dateRange = useLensSelector(selectResolvedDateRange); useEffect(() => { setActiveDimension(initialActiveDimensionState); @@ -104,8 +104,10 @@ export function LayerPanel( activeData: props.framePublicAPI.activeData, }; - const datasourceId = datasourcePublicAPI.datasourceId; - const layerDatasourceState = datasourceStates[datasourceId].state; + const datasourcePublicAPI = framePublicAPI.datasourceLayers?.[layerId]; + const datasourceId = datasourcePublicAPI?.datasourceId; + const layerDatasourceState = datasourceStates?.[datasourceId]?.state; + const layerDatasource = props.datasourceMap[datasourceId]; const layerDatasourceDropProps = useMemo( () => ({ @@ -118,12 +120,9 @@ export function LayerPanel( [layerId, layerDatasourceState, datasourceId, updateDatasource] ); - const layerDatasource = props.datasourceMap[datasourceId]; - const layerDatasourceConfigProps = { ...layerDatasourceDropProps, frame: props.framePublicAPI, - activeData: props.framePublicAPI.activeData, dateRange, }; @@ -137,11 +136,15 @@ export function LayerPanel( activeVisualization, ] ); + + const columnLabelMap = + !layerDatasource && activeVisualization.getUniqueLabels + ? activeVisualization.getUniqueLabels(props.visualizationState) + : layerDatasource?.uniqueLabels?.(layerDatasourceConfigProps?.state); + const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); const { activeId, activeGroup } = activeDimension; - const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); - const { setDimension, removeDimension } = activeVisualization; const allAccessors = groups.flatMap((group) => @@ -154,7 +157,7 @@ export function LayerPanel( registerNewRef: registerNewButtonRef, } = useFocusUpdate(allAccessors); - const layerDatasourceOnDrop = layerDatasource.onDrop; + const layerDatasourceOnDrop = layerDatasource?.onDrop; const onDrop = useMemo(() => { return ( @@ -180,16 +183,18 @@ export function LayerPanel( const filterOperations = group?.filterOperations || (() => false); - const dropResult = layerDatasourceOnDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId, - layerId: targetLayerId, - filterOperations, - dimensionGroups: groups, - groupId, - dropType, - }); + const dropResult = layerDatasource + ? layerDatasourceOnDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId, + layerId: targetLayerId, + filterOperations, + dimensionGroups: groups, + groupId, + dropType, + }) + : false; if (dropResult) { let previousColumn = typeof droppedItem.column === 'string' ? droppedItem.column : undefined; @@ -241,6 +246,7 @@ export function LayerPanel( removeDimension, layerDatasourceDropProps, setNextFocusedButtonId, + layerDatasource, ]); const isDimensionPanelOpen = Boolean(activeId); @@ -340,43 +346,45 @@ export function LayerPanel( /> - {layerDatasource && ( - { - const newState = - typeof updater === 'function' ? updater(layerDatasourceState) : updater; - // Look for removed columns - const nextPublicAPI = layerDatasource.getPublicAPI({ - state: newState, - layerId, - }); - const nextTable = new Set( - nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) - ); - const removed = datasourcePublicAPI - .getTableSpec() - .map(({ columnId }) => columnId) - .filter((columnId) => !nextTable.has(columnId)); - let nextVisState = props.visualizationState; - removed.forEach((columnId) => { - nextVisState = activeVisualization.removeDimension({ + <> + + { + const newState = + typeof updater === 'function' ? updater(layerDatasourceState) : updater; + // Look for removed columns + const nextPublicAPI = layerDatasource.getPublicAPI({ + state: newState, layerId, - columnId, - prevState: nextVisState, - frame: framePublicAPI, }); - }); + const nextTable = new Set( + nextPublicAPI.getTableSpec().map(({ columnId }) => columnId) + ); + const removed = datasourcePublicAPI + .getTableSpec() + .map(({ columnId }) => columnId) + .filter((columnId) => !nextTable.has(columnId)); + let nextVisState = props.visualizationState; + removed.forEach((columnId) => { + nextVisState = activeVisualization.removeDimension({ + layerId, + columnId, + prevState: nextVisState, + frame: framePublicAPI, + }); + }); - props.updateAll(datasourceId, newState, nextVisState); - }, - }} - /> + props.updateAll(datasourceId, newState, nextVisState); + }, + }} + /> + )}
@@ -401,7 +409,6 @@ export function LayerPanel( : i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', { defaultMessage: 'Requires field', }); - const isOptional = !group.required && !group.suggestedValue; return ( {group.accessors.map((accessorConfig, accessorIndex) => { const { columnId } = accessorConfig; - return ( { setActiveDimension({ @@ -478,42 +484,66 @@ export function LayerPanel( }} onRemoveClick={(id: string) => { trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: id, - prevState: layerDatasourceState, - }), - activeVisualization.removeDimension({ - layerId, - columnId: id, - prevState: props.visualizationState, - frame: framePublicAPI, - }) - ); + if (datasourceId && layerDatasource) { + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: id, + prevState: layerDatasourceState, + }), + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } else { + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: id, + prevState: props.visualizationState, + frame: framePublicAPI, + }) + ); + } removeButtonRef(id); }} invalid={ - !layerDatasource.isValidColumn( + layerDatasource && + !layerDatasource?.isValidColumn( layerDatasourceState, layerId, columnId ) } > - + {layerDatasource ? ( + + ) : ( + <> + {activeVisualization?.renderDimensionTrigger?.({ + columnId, + label: columnLabelMap[columnId], + hideTooltip, + invalid: group.invalid, + invalidMessage: group.invalidMessage, + })} + + )}
@@ -536,7 +566,7 @@ export function LayerPanel( setActiveDimension({ activeGroup: group, activeId: id, - isNew: !group.supportStaticValue, + isNew: !group.supportStaticValue && Boolean(layerDatasource), }); }} onDrop={onDrop} @@ -555,22 +585,25 @@ export function LayerPanel( isFullscreen={isFullscreen} groupLabel={activeGroup?.groupLabel || ''} handleClose={() => { - if ( - layerDatasource.canCloseDimensionEditor && - !layerDatasource.canCloseDimensionEditor(layerDatasourceState) - ) { - return false; - } - if (layerDatasource.updateStateOnCloseDimension) { - const newState = layerDatasource.updateStateOnCloseDimension({ - state: layerDatasourceState, - layerId, - columnId: activeId!, - }); - if (newState) { - props.updateDatasource(datasourceId, newState); + if (layerDatasource) { + if ( + layerDatasource.canCloseDimensionEditor && + !layerDatasource.canCloseDimensionEditor(layerDatasourceState) + ) { + return false; + } + if (layerDatasource.updateStateOnCloseDimension) { + const newState = layerDatasource.updateStateOnCloseDimension({ + state: layerDatasourceState, + layerId, + columnId: activeId!, + }); + if (newState) { + props.updateDatasource(datasourceId, newState); + } } } + setActiveDimension(initialActiveDimensionState); if (isFullscreen) { toggleFullscreen(); @@ -579,7 +612,7 @@ export function LayerPanel( }} panel={
- {activeGroup && activeId && ( + {activeGroup && activeId && layerDatasource && ( - + - - color)} - type={FIXED_PROGRESSION} - onClick={() => { - setIsPaletteOpen(!isPaletteOpen); - }} - /> - - - { - setIsPaletteOpen(!isPaletteOpen); - }} - size="xs" - flush="both" - > - {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { - defaultMessage: 'Edit', - })} - - setIsPaletteOpen(!isPaletteOpen)} - > - {activePalette && ( - { - // make sure to always have a list of stops - if (newPalette.params && !newPalette.params.stops) { - newPalette.params.stops = displayStops; - } - (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; - setState({ - ...state, - palette: newPalette as HeatmapVisualizationState['palette'], - }); - }} - /> - )} - - - - + + + color)} + type={FIXED_PROGRESSION} + onClick={() => { + setIsPaletteOpen(!isPaletteOpen); + }} + /> + + + { + setIsPaletteOpen(!isPaletteOpen); + }} + size="xs" + flush="both" + > + {i18n.translate('xpack.lens.paletteHeatmapGradient.customize', { + defaultMessage: 'Edit', + })} + + setIsPaletteOpen(!isPaletteOpen)} + > + {activePalette && ( + { + // make sure to always have a list of stops + if (newPalette.params && !newPalette.params.stops) { + newPalette.params.stops = displayStops; + } + (newPalette as HeatmapVisualizationState['palette'])!.accessor = accessor; + setState({ + ...state, + palette: newPalette as HeatmapVisualizationState['palette'], + }); + }} + /> + )} + + + + + ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index f3c48bace4a5f..3318b8c30909e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -89,12 +89,13 @@ export function getDropProps(props: GetDropProps) { ) { const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; const targetColumn = state.layers[layerId].columns[columnId]; - const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; - const isSameGroup = groupId === dragging.groupId; if (isSameGroup) { - return getDropPropsForSameGroup(targetColumn); - } else if (filterOperations(sourceColumn)) { + return getDropPropsForSameGroup(!targetColumn); + } + const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + + if (filterOperations(sourceColumn)) { return getDropPropsForCompatibleGroup( props.dimensionGroups, dragging.columnId, @@ -164,8 +165,8 @@ function getDropPropsForField({ return; } -function getDropPropsForSameGroup(targetColumn?: GenericIndexPatternColumn): DropProps { - return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; +function getDropPropsForSameGroup(isNew?: boolean): DropProps { + return !isNew ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; } function getDropPropsForCompatibleGroup( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index f19658d468d5f..6bdd41d8db631 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -2626,9 +2626,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', }) ).toBe(state); }); @@ -2655,9 +2653,7 @@ describe('IndexPattern Data Source', () => { expect( indexPatternDatasource.initializeDimension!(state, 'first', { columnId: 'newStatic', - label: 'MyNewColumn', groupId: 'a', - dataType: 'number', staticValue: 0, // use a falsy value to check also this corner case }) ).toEqual({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index cf77d1c9c1cc2..d0b644e2bf9b4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -230,7 +230,7 @@ export function getIndexPatternDatasource({ }); }, - initializeDimension(state, layerId, { columnId, groupId, label, dataType, staticValue }) { + initializeDimension(state, layerId, { columnId, groupId, staticValue }) { const indexPattern = state.indexPatterns[state.layers[layerId]?.indexPatternId]; if (staticValue == null) { return state; diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 2c038b0937999..d1f16ac5f9c41 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -22,8 +22,12 @@ import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; -import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; -import { PalettePicker } from '../shared_components'; +import { + ToolbarPopover, + LegendSettingsPopover, + useDebouncedValue, + PalettePicker, +} from '../shared_components'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; import { shouldShowValuesInLegend } from './render_helpers'; @@ -298,14 +302,12 @@ export function DimensionEditor( } ) { return ( - <> - { - props.setState({ ...props.state, palette: newPalette }); - }} - /> - + { + props.setState({ ...props.state, palette: newPalette }); + }} + /> ); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 4d883c3a27c5e..d2bb7cdbb4344 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -33,6 +33,7 @@ import type { NavigationPublicPluginStart } from '../../../../src/plugins/naviga import type { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public'; import type { GlobalSearchPluginSetup } from '../../global_search/public'; import type { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import type { EventAnnotationPluginSetup } from '../../../../src/plugins/event_annotation/public'; import type { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; @@ -104,6 +105,7 @@ export interface LensPluginSetupDependencies { embeddable?: EmbeddableSetup; visualizations: VisualizationsSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; discover?: DiscoverSetup; @@ -120,6 +122,7 @@ export interface LensPluginStartDependencies { visualizations: VisualizationsStart; embeddable: EmbeddableStart; charts: ChartsPluginStart; + eventAnnotation: EventAnnotationPluginSetup; savedObjectsTagging?: SavedObjectTaggingPluginStart; presentationUtil: PresentationUtilPluginStart; dataViewFieldEditor: IndexPatternFieldEditorStart; @@ -235,6 +238,7 @@ export class LensPlugin { embeddable, visualizations, charts, + eventAnnotation, globalSearch, usageCollection, }: LensPluginSetupDependencies @@ -251,7 +255,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - plugins.fieldFormats.deserialize + plugins.fieldFormats.deserialize, + eventAnnotation ); const visualizationMap = await this.editorFrameService!.loadVisualizations(); @@ -311,7 +316,8 @@ export class LensPlugin { charts, expressions, fieldFormats, - deps.fieldFormats.deserialize + deps.fieldFormats.deserialize, + eventAnnotation ), ensureDefaultDataView(), ]); @@ -368,7 +374,8 @@ export class LensPlugin { charts: ChartsPluginSetup, expressions: ExpressionsServiceSetup, fieldFormats: FieldFormatsSetup, - formatFactory: FormatFactory + formatFactory: FormatFactory, + eventAnnotation: EventAnnotationPluginSetup ) { const { DatatableVisualization, @@ -402,6 +409,7 @@ export class LensPlugin { charts, editorFrame: editorFrameSetupInterface, formatFactory, + eventAnnotation, }; this.indexpatternDatasource.setup(core, dependencies); this.xyVisualization.setup(core, dependencies); diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.scss b/x-pack/plugins/lens/public/shared_components/dimension_section.scss new file mode 100644 index 0000000000000..7781c91785d67 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.scss @@ -0,0 +1,24 @@ +.lnsDimensionEditorSection { + padding-top: $euiSize; + padding-bottom: $euiSize; +} + +.lnsDimensionEditorSection:first-child { + padding-top: 0; +} + +.lnsDimensionEditorSection:first-child .lnsDimensionEditorSection__border { + display: none; +} + +.lnsDimensionEditorSection__border { + position: relative; + &:before { + content: ''; + position: absolute; + top: -$euiSize; + right: -$euiSize; + left: -$euiSize; + border-top: 1px solid $euiColorLightShade; + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/shared_components/dimension_section.tsx b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx new file mode 100644 index 0000000000000..d56e08db4b037 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/dimension_section.tsx @@ -0,0 +1,29 @@ +/* + * 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 { EuiTitle } from '@elastic/eui'; +import React from 'react'; +import './dimension_section.scss'; + +export const DimensionEditorSection = ({ + children, + title, +}: { + title?: string; + children?: React.ReactNode | React.ReactNode[]; +}) => { + return ( +
+
+ {title && ( + +

{title}

+
+ )} + {children} +
+ ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index 6140e54b43dc7..b2428532a72c9 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -17,5 +17,6 @@ export { LegendActionPopover } from './legend_action_popover'; export { NameInput } from './name_input'; export { ValueLabelsSettings } from './value_labels_settings'; export { AxisTitleSettings } from './axis_title_settings'; +export { DimensionEditorSection } from './dimension_section'; export * from './static_header'; export * from './vis_label'; diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 56ff89f506c85..959db8ca006fe 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -619,30 +619,39 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { return state; } - const activeDatasource = datasourceMap[state.activeDatasourceId]; const activeVisualization = visualizationMap[state.visualization.activeId]; - - const datasourceState = activeDatasource.insertLayer( - state.datasourceStates[state.activeDatasourceId].state, - layerId - ); - const visualizationState = activeVisualization.appendLayer!( state.visualization.state, layerId, layerType ); + const framePublicAPI = { + // any better idea to avoid `as`? + activeData: state.activeData + ? (current(state.activeData) as TableInspectorAdapter) + : undefined, + datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), + }; + + const activeDatasource = datasourceMap[state.activeDatasourceId]; + const { noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; + + const datasourceState = + !noDatasource && activeDatasource + ? activeDatasource.insertLayer( + state.datasourceStates[state.activeDatasourceId].state, + layerId + ) + : state.datasourceStates[state.activeDatasourceId].state; + const { activeDatasourceState, activeVisualizationState } = addInitialValueIfAvailable({ datasourceState, visualizationState, - framePublicAPI: { - // any better idea to avoid `as`? - activeData: state.activeData - ? (current(state.activeData) as TableInspectorAdapter) - : undefined, - datasourceLayers: getDatasourceLayers(state.datasourceStates, datasourceMap), - }, + framePublicAPI, activeVisualization, activeDatasource, layerId, @@ -710,39 +719,49 @@ function addInitialValueIfAvailable({ framePublicAPI: FramePublicAPI; visualizationState: unknown; datasourceState: unknown; - activeDatasource: Datasource; + activeDatasource?: Datasource; activeVisualization: Visualization; layerId: string; layerType: string; columnId?: string; groupId?: string; }) { - const layerInfo = activeVisualization - .getSupportedLayers(visualizationState, framePublicAPI) - .find(({ type }) => type === layerType); + const { initialDimensions, noDatasource } = + activeVisualization + .getSupportedLayers(visualizationState, framePublicAPI) + .find(({ type }) => type === layerType) || {}; - if (layerInfo?.initialDimensions && activeDatasource?.initializeDimension) { + if (initialDimensions) { const info = groupId - ? layerInfo.initialDimensions.find(({ groupId: id }) => id === groupId) - : // pick the first available one if not passed - layerInfo.initialDimensions[0]; + ? initialDimensions.find(({ groupId: id }) => id === groupId) + : initialDimensions[0]; // pick the first available one if not passed if (info) { - return { - activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { - ...info, - columnId: columnId || info.columnId, - }), - activeVisualizationState: activeVisualization.setDimension({ - groupId: info.groupId, - layerId, - columnId: columnId || info.columnId, - prevState: visualizationState, - frame: framePublicAPI, - }), - }; + const activeVisualizationState = activeVisualization.setDimension({ + groupId: info.groupId, + layerId, + columnId: columnId || info.columnId, + prevState: visualizationState, + frame: framePublicAPI, + }); + + if (!noDatasource && activeDatasource?.initializeDimension) { + return { + activeDatasourceState: activeDatasource.initializeDimension(datasourceState, layerId, { + ...info, + columnId: columnId || info.columnId, + }), + activeVisualizationState, + }; + } else { + return { + activeDatasourceState: datasourceState, + activeVisualizationState, + }; + } } } + return { activeDatasourceState: datasourceState, activeVisualizationState: visualizationState, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9bea94bd723d3..cfa23320dc561 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -198,6 +198,12 @@ interface ChartSettings { }; } +export type GetDropProps = DatasourceDimensionDropProps & { + groupId: string; + dragging: DragContextState['dragging']; + prioritizedOperation?: string; +}; + /** * Interface for the datasource registry */ @@ -227,10 +233,8 @@ export interface Datasource { layerId: string, value: { columnId: string; - label: string; - dataType: string; - staticValue?: unknown; groupId: string; + staticValue?: unknown; } ) => T; @@ -251,11 +255,7 @@ export interface Datasource { props: DatasourceLayerPanelProps ) => ((cleanupElement: Element) => void) | void; getDropProps: ( - props: DatasourceDimensionDropProps & { - groupId: string; - dragging: DragContextState['dragging']; - prioritizedOperation?: string; - } + props: GetDropProps ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; /** @@ -585,6 +585,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & { supportStaticValue?: boolean; paramEditorCustomProps?: ParamEditorCustomProps; supportFieldFormat?: boolean; + labels?: { buttonAriaLabel: string; buttonLabel: string }; }; interface VisualizationDimensionChangeProps { @@ -786,14 +787,13 @@ export interface Visualization { type: LayerType; label: string; icon?: IconType; + noDatasource?: boolean; disabled?: boolean; toolTipContent?: string; initialDimensions?: Array<{ - groupId: string; columnId: string; - dataType: string; - label: string; - staticValue: unknown; + groupId: string; + staticValue?: unknown; }>; }>; getLayerType: (layerId: string, state?: T) => LayerType | undefined; @@ -858,7 +858,20 @@ export interface Visualization { domElement: Element, props: VisualizationDimensionEditorProps ) => ((cleanupElement: Element) => void) | void; - + /** + * Renders dimension trigger. Used only for noDatasource layers + */ + renderDimensionTrigger?: (props: { + columnId: string; + label: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) => JSX.Element | null; + /** + * Creates map of columns ids and unique lables. Used only for noDatasource layers + */ + getUniqueLabels?: (state: T) => Record; /** * The frame will call this function on all visualizations at different times. The * main use cases where visualization suggestions are requested are: diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index e1885fafab5e0..1770bac893b67 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -399,22 +399,16 @@ export const getGaugeVisualization = ({ { groupId: 'min', columnId: generateId(), - dataType: 'number', - label: 'minAccessor', staticValue: minValue, }, { groupId: 'max', columnId: generateId(), - dataType: 'number', - label: 'maxAccessor', staticValue: maxValue, }, { groupId: 'goal', columnId: generateId(), - dataType: 'number', - label: 'goalAccessor', staticValue: goalValue, }, ] diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap index 504a553c5a631..fdde8eb6ad3f2 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/expression.test.tsx.snap @@ -1,5 +1,218 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xy_expression XYChart component annotations should render basic annotation 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations preserving the shared styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": Array [ + 9, + 3, + ], + "opacity": 1, + "stroke": "red", + "strokeWidth": 3, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render grouped annotations with default styles 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + +exports[`xy_expression XYChart component annotations should render simplified annotation when hide is true 1`] = ` + + } + markerBody={ + + } + markerPosition="top" + style={ + Object { + "line": Object { + "dash": undefined, + "opacity": 1, + "stroke": "#f04e98", + "strokeWidth": 1, + }, + } + } +/> +`; + exports[`xy_expression XYChart component it renders area 1`] = ` & { + formatFactory: FormatFactory; + paletteService: PaletteRegistry; + } +) => { + const { state, setState, layerId, accessor } = props; + const isHorizontal = isHorizontalChart(state.layers); + + const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ + value: state, + onChange: setState, + }); + + const index = localState.layers.findIndex((l) => l.layerId === layerId); + const localLayer = localState.layers.find( + (l) => l.layerId === layerId + ) as XYAnnotationLayerConfig; + + const currentAnnotations = localLayer.annotations?.find((c) => c.id === accessor); + + const setAnnotations = useCallback( + (annotations: Partial | undefined) => { + if (annotations == null) { + return; + } + const newConfigs = [...(localLayer.annotations || [])]; + const existingIndex = newConfigs.findIndex((c) => c.id === accessor); + if (existingIndex !== -1) { + newConfigs[existingIndex] = { ...newConfigs[existingIndex], ...annotations }; + } else { + return; // that should never happen because annotations are created before annotations panel is opened + } + setLocalState(updateLayer(localState, { ...localLayer, annotations: newConfigs }, index)); + }, + [accessor, index, localState, localLayer, setLocalState] + ); + + return ( + <> + + { + if (date) { + setAnnotations({ + key: { + ...(currentAnnotations?.key || { type: 'point_in_time' }), + timestamp: date.toISOString(), + }, + }); + } + }} + label={i18n.translate('xpack.lens.xyChart.annotationDate', { + defaultMessage: 'Annotation date', + })} + /> + + + { + setAnnotations({ label: value }); + }} + /> + + + + setAnnotations({ isHidden: ev.target.checked })} + /> + + + ); +}; + +const ConfigPanelDatePicker = ({ + value, + label, + onChange, +}: { + value: moment.Moment; + label: string; + onChange: (val: moment.Moment | null) => void; +}) => { + return ( + + + + ); +}; + +const ConfigPanelHideSwitch = ({ + value, + onChange, +}: { + value: boolean; + onChange: (event: EuiSwitchEvent) => void; +}) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss new file mode 100644 index 0000000000000..fc2b1204bb1d0 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.scss @@ -0,0 +1,37 @@ +.lnsXyDecorationRotatedWrapper { + display: inline-block; + overflow: hidden; + line-height: 1.5; + + .lnsXyDecorationRotatedWrapper__label { + display: inline-block; + white-space: nowrap; + transform: translate(0, 100%) rotate(-90deg); + transform-origin: 0 0; + + &::after { + content: ''; + float: left; + margin-top: 100%; + } + } +} + +.lnsXyAnnotationNumberIcon { + border-radius: $euiSize; + min-width: $euiSize; + height: $euiSize; + background-color: currentColor; +} + +.lnsXyAnnotationNumberIcon__text { + font-weight: 500; + font-size: 9px; + letter-spacing: -.5px; + line-height: 11px; +} + +.lnsXyAnnotationIcon_rotate90 { + transform: rotate(45deg); + transform-origin: center; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx new file mode 100644 index 0000000000000..c36488f29d238 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/expression.tsx @@ -0,0 +1,233 @@ +/* + * 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 './expression.scss'; +import React from 'react'; +import { snakeCase } from 'lodash'; +import { + AnnotationDomainType, + AnnotationTooltipFormatter, + LineAnnotation, + Position, +} from '@elastic/charts'; +import type { FieldFormat } from 'src/plugins/field_formats/common'; +import type { EventAnnotationArgs } from 'src/plugins/event_annotation/common'; +import moment from 'moment'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import type { AnnotationLayerArgs } from '../../../common/expressions'; +import { hasIcon } from '../xy_config_panel/shared/icon_select'; +import { + mapVerticalToHorizontalPlacement, + LINES_MARKER_SIZE, + MarkerBody, + Marker, + AnnotationIcon, +} from '../annotations_helpers'; + +const getRoundedTimestamp = (timestamp: number, firstTimestamp?: number, minInterval?: number) => { + if (!firstTimestamp || !minInterval) { + return timestamp; + } + return timestamp - ((timestamp - firstTimestamp) % minInterval); +}; + +export interface AnnotationsProps { + groupedAnnotations: CollectiveConfig[]; + formatter?: FieldFormat; + isHorizontal: boolean; + paddingMap: Partial>; + hide?: boolean; + minInterval?: number; + isBarChart?: boolean; +} + +interface CollectiveConfig extends EventAnnotationArgs { + roundedTimestamp: number; + axisMode: 'bottom'; + customTooltipDetails?: AnnotationTooltipFormatter | undefined; +} + +const groupVisibleConfigsByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number +) => { + return layers + .flatMap(({ annotations }) => annotations.filter((a) => !a.isHidden)) + .reduce>((acc, current) => { + const roundedTimestamp = getRoundedTimestamp( + moment(current.time).valueOf(), + firstTimestamp, + minInterval + ); + return { + ...acc, + [roundedTimestamp]: acc[roundedTimestamp] ? [...acc[roundedTimestamp], current] : [current], + }; + }, {}); +}; + +const createCustomTooltipDetails = + ( + config: EventAnnotationArgs[], + formatter?: FieldFormat + ): AnnotationTooltipFormatter | undefined => + () => { + return ( +
+ {config.map(({ icon, label, time, color }) => ( +
+ + {hasIcon(icon) && ( + + + + )} + {label} + + {formatter?.convert(time) || String(time)} +
+ ))} +
+ ); + }; + +function getCommonProperty( + configArr: EventAnnotationArgs[], + propertyName: K, + fallbackValue: T +) { + const firstStyle = configArr[0][propertyName]; + if (configArr.every((config) => firstStyle === config[propertyName])) { + return firstStyle; + } + return fallbackValue; +} + +const getCommonStyles = (configArr: EventAnnotationArgs[]) => { + return { + color: getCommonProperty( + configArr, + 'color', + defaultAnnotationColor + ), + lineWidth: getCommonProperty(configArr, 'lineWidth', 1), + lineStyle: getCommonProperty(configArr, 'lineStyle', 'solid'), + textVisibility: getCommonProperty(configArr, 'textVisibility', false), + }; +}; + +export const getAnnotationsGroupedByInterval = ( + layers: AnnotationLayerArgs[], + minInterval?: number, + firstTimestamp?: number, + formatter?: FieldFormat +) => { + const visibleGroupedConfigs = groupVisibleConfigsByInterval(layers, minInterval, firstTimestamp); + let collectiveConfig: CollectiveConfig; + return Object.entries(visibleGroupedConfigs).map(([roundedTimestamp, configArr]) => { + collectiveConfig = { + ...configArr[0], + roundedTimestamp: Number(roundedTimestamp), + axisMode: 'bottom', + }; + if (configArr.length > 1) { + const commonStyles = getCommonStyles(configArr); + collectiveConfig = { + ...collectiveConfig, + ...commonStyles, + icon: String(configArr.length), + customTooltipDetails: createCustomTooltipDetails(configArr, formatter), + }; + } + return collectiveConfig; + }); +}; + +export const Annotations = ({ + groupedAnnotations, + formatter, + isHorizontal, + paddingMap, + hide, + minInterval, + isBarChart, +}: AnnotationsProps) => { + return ( + <> + {groupedAnnotations.map((annotation) => { + const markerPositionVertical = Position.Top; + const markerPosition = isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; + const id = snakeCase(annotation.label); + const { roundedTimestamp, time: exactTimestamp } = annotation; + const isGrouped = Boolean(annotation.customTooltipDetails); + const header = + formatter?.convert(isGrouped ? roundedTimestamp : exactTimestamp) || + moment(isGrouped ? roundedTimestamp : exactTimestamp).toISOString(); + const strokeWidth = annotation.lineWidth || 1; + return ( + + ) : undefined + } + markerBody={ + !hide ? ( + + ) : undefined + } + markerPosition={markerPosition} + dataValues={[ + { + dataValue: moment( + isBarChart && minInterval ? roundedTimestamp + minInterval / 2 : roundedTimestamp + ).valueOf(), + header, + details: annotation.label, + }, + ]} + customTooltipDetails={annotation.customTooltipDetails} + style={{ + line: { + strokeWidth, + stroke: annotation.color || defaultAnnotationColor, + dash: + annotation.lineStyle === 'dashed' + ? [strokeWidth * 3, strokeWidth] + : annotation.lineStyle === 'dotted' + ? [strokeWidth, strokeWidth] + : undefined, + opacity: 1, + }, + }} + /> + ); + })} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts new file mode 100644 index 0000000000000..fbf13db7fa7a5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.test.ts @@ -0,0 +1,210 @@ +/* + * 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 { FramePublicAPI } from '../../types'; +import { getStaticDate } from './helpers'; + +describe('annotations helpers', () => { + describe('getStaticDate', () => { + it('should return `now` value on when nothing is configured', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-08T11:01:58.135Z').valueOf()); + expect(getStaticDate([], undefined)).toBe('2022-04-08T11:01:58.135Z'); + }); + it('should return `now` value on when there is no active data', () => { + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + undefined + ) + ).toBe('2022-04-08T11:01:58.135Z'); + }); + + it('should return timestamp value for single active data point', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1646002800000, + b: 1050, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-02-27T23:00:00.000Z'); + }); + + it('should correctly calculate middle value for active data', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2022-03-26T05:00:00.000Z'); + }); + + it('should calculate middle date point correctly for multiple layers', () => { + const activeData = { + layerId: { + type: 'datatable', + rows: [ + { + a: 1648206000000, + b: 19, + }, + { + a: 1648249200000, + b: 73, + }, + { + a: 1648292400000, + b: 69, + }, + { + a: 1648335600000, + b: 7, + }, + ], + columns: [ + { + id: 'a', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'b', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + layerId2: { + type: 'datatable', + rows: [ + { + d: 1548206000000, + c: 19, + }, + { + d: 1548249200000, + c: 73, + }, + ], + columns: [ + { + id: 'd', + name: 'order_date per week', + meta: { type: 'date' }, + }, + { + id: 'c', + name: 'Count of records', + meta: { type: 'number', params: { id: 'number' } }, + }, + ], + }, + }; + expect( + getStaticDate( + [ + { + layerId: 'layerId', + accessors: ['b'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'a', + }, + { + layerId: 'layerId2', + accessors: ['c'], + seriesType: 'bar_stacked', + layerType: 'data', + xAccessor: 'd', + }, + ], + activeData as FramePublicAPI['activeData'] + ) + ).toBe('2020-08-24T12:06:40.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx new file mode 100644 index 0000000000000..321090c94241a --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations/helpers.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { layerTypes } from '../../../common'; +import type { + XYDataLayerConfig, + XYAnnotationLayerConfig, + XYLayerConfig, +} from '../../../common/expressions'; +import type { FramePublicAPI, Visualization } from '../../types'; +import { isHorizontalChart } from '../state_helpers'; +import type { XYState } from '../types'; +import { + checkScaleOperation, + getAnnotationsLayers, + getAxisName, + getDataLayers, + isAnnotationsLayer, +} from '../visualization_helpers'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; +import { generateId } from '../../id_generator'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; +import { defaultAnnotationLabel } from './config_panel'; + +const MAX_DATE = 8640000000000000; +const MIN_DATE = -8640000000000000; + +export function getStaticDate( + dataLayers: XYDataLayerConfig[], + activeData: FramePublicAPI['activeData'] +) { + const fallbackValue = moment().toISOString(); + + const dataLayersId = dataLayers.map(({ layerId }) => layerId); + if ( + !activeData || + Object.entries(activeData) + .filter(([key]) => dataLayersId.includes(key)) + .every(([, { rows }]) => !rows || !rows.length) + ) { + return fallbackValue; + } + + const minDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const firstTimestamp = activeData[lId]?.rows?.[0]?.[xAccessor]; + return firstTimestamp && firstTimestamp < acc ? firstTimestamp : acc; + }, MAX_DATE); + + const maxDate = dataLayersId.reduce((acc, lId) => { + const xAccessor = dataLayers.find((dataLayer) => dataLayer.layerId === lId)?.xAccessor!; + const lastTimestamp = activeData[lId]?.rows?.[activeData?.[lId]?.rows?.length - 1]?.[xAccessor]; + return lastTimestamp && lastTimestamp > acc ? lastTimestamp : acc; + }, MIN_DATE); + const middleDate = (minDate + maxDate) / 2; + return moment(middleDate).toISOString(); +} + +export const getAnnotationsSupportedLayer = ( + state?: XYState, + frame?: Pick +) => { + const dataLayers = getDataLayers(state?.layers || []); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + const initialDimensions = + state && hasDateHistogram + ? [ + { + groupId: 'xAnnotations', + columnId: generateId(), + }, + ] + : undefined; + + return { + type: layerTypes.ANNOTATIONS, + label: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabel', { + defaultMessage: 'Annotations', + }), + icon: LensIconChartBarAnnotations, + disabled: !hasDateHistogram, + toolTipContent: !hasDateHistogram + ? i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }) + : undefined, + initialDimensions, + noDatasource: true, + }; +}; + +export const setAnnotationsDimension: Visualization['setDimension'] = ({ + prevState, + layerId, + columnId, + previousColumn, + frame, +}) => { + const foundLayer = prevState.layers.find((l) => l.layerId === layerId); + if (!foundLayer || !isAnnotationsLayer(foundLayer)) { + return prevState; + } + const dataLayers = getDataLayers(prevState.layers); + const newLayer = { ...foundLayer } as XYAnnotationLayerConfig; + + const hasConfig = newLayer.annotations?.some(({ id }) => id === columnId); + const previousConfig = previousColumn + ? newLayer.annotations?.find(({ id }) => id === previousColumn) + : false; + if (!hasConfig) { + const newTimestamp = getStaticDate(dataLayers, frame?.activeData); + newLayer.annotations = [ + ...(newLayer.annotations || []), + { + label: defaultAnnotationLabel, + key: { + type: 'point_in_time', + timestamp: newTimestamp, + }, + icon: 'triangle', + ...previousConfig, + id: columnId, + }, + ]; + } + return { + ...prevState, + layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), + }; +}; + +export const getAnnotationsAccessorColorConfig = (layer: XYAnnotationLayerConfig) => { + return layer.annotations.map((annotation) => { + return { + columnId: annotation.id, + triggerIcon: annotation.isHidden ? ('invisible' as const) : ('color' as const), + color: annotation?.color || defaultAnnotationColor, + }; + }); +}; + +export const getAnnotationsConfiguration = ({ + state, + frame, + layer, +}: { + state: XYState; + frame: FramePublicAPI; + layer: XYAnnotationLayerConfig; +}) => { + const dataLayers = getDataLayers(state.layers); + + const hasDateHistogram = Boolean( + dataLayers.length && + dataLayers.every( + (dataLayer) => + dataLayer.xAccessor && + checkScaleOperation('interval', 'date', frame?.datasourceLayers || {})(dataLayer) + ) + ); + + const groupLabel = getAxisName('x', { isHorizontal: isHorizontalChart(state.layers) }); + + const emptyButtonLabels = { + buttonAriaLabel: i18n.translate('xpack.lens.indexPattern.addColumnAriaLabelClick', { + defaultMessage: 'Add an annotation to {groupLabel}', + values: { groupLabel }, + }), + buttonLabel: i18n.translate('xpack.lens.configure.emptyConfigClick', { + defaultMessage: 'Add an annotation', + }), + }; + + return { + groups: [ + { + groupId: 'xAnnotations', + groupLabel, + accessors: getAnnotationsAccessorColorConfig(layer), + dataTestSubj: 'lnsXY_xAnnotationsPanel', + invalid: !hasDateHistogram, + invalidMessage: i18n.translate('xpack.lens.xyChart.addAnnotationsLayerLabelDisabledHelp', { + defaultMessage: 'Annotations require a time based chart to work. Add a date histogram.', + }), + required: false, + requiresPreviousColumnOnDuplicate: true, + supportsMoreColumns: true, + supportFieldFormat: false, + enableDimensionEditor: true, + filterOperations: () => false, + labels: emptyButtonLabels, + }, + ], + }; +}; + +export const getUniqueLabels = (layers: XYLayerConfig[]) => { + const annotationLayers = getAnnotationsLayers(layers); + const columnLabelMap = {} as Record; + const counts = {} as Record; + + const makeUnique = (label: string) => { + let uniqueLabel = label; + + while (counts[uniqueLabel] >= 0) { + const num = ++counts[uniqueLabel]; + uniqueLabel = i18n.translate('xpack.lens.uniqueLabel', { + defaultMessage: '{label} [{num}]', + values: { label, num }, + }); + } + + counts[uniqueLabel] = 0; + return uniqueLabel; + }; + + annotationLayers.forEach((layer) => { + if (!layer.annotations) { + return; + } + layer.annotations.forEach((l) => { + columnLabelMap[l.id] = makeUnique(l.label); + }); + }); + return columnLabelMap; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx new file mode 100644 index 0000000000000..ddbdfc91f4a3e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/annotations_helpers.tsx @@ -0,0 +1,253 @@ +/* + * 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 './expression_reference_lines.scss'; +import React from 'react'; +import { EuiFlexGroup, EuiIcon, EuiIconProps, EuiText } from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import classnames from 'classnames'; +import type { IconPosition, YAxisMode, YConfig } from '../../common/expressions'; +import { hasIcon } from './xy_config_panel/shared/icon_select'; +import { annotationsIconSet } from './annotations/config_panel/icon_set'; + +export const LINES_MARKER_SIZE = 20; + +export const computeChartMargins = ( + referenceLinePaddings: Partial>, + labelVisibility: Partial>, + titleVisibility: Partial>, + axesMap: Record<'left' | 'right', unknown>, + isHorizontal: boolean +) => { + const result: Partial> = {}; + if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; + result[placement] = referenceLinePaddings.bottom; + } + if ( + referenceLinePaddings.left && + (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; + result[placement] = referenceLinePaddings.left; + } + if ( + referenceLinePaddings.right && + (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) + ) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; + result[placement] = referenceLinePaddings.right; + } + // there's no top axis, so just check if a margin has been computed + if (referenceLinePaddings.top) { + const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; + result[placement] = referenceLinePaddings.top; + } + return result; +}; + +// Note: it does not take into consideration whether the reference line is in view or not + +export const getLinesCausedPaddings = ( + visualConfigs: Array< + Pick | undefined + >, + axesMap: Record<'left' | 'right', unknown> +) => { + // collect all paddings for the 4 axis: if any text is detected double it. + const paddings: Partial> = {}; + const icons: Partial> = {}; + visualConfigs?.forEach((config) => { + if (!config) { + return; + } + const { axisMode, icon, iconPosition, textVisibility } = config; + if (axisMode && (hasIcon(icon) || textVisibility)) { + const placement = getBaseIconPlacement(iconPosition, axesMap, axisMode); + paddings[placement] = Math.max( + paddings[placement] || 0, + LINES_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text + ); + icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); + } + }); + // post-process the padding based on the icon presence: + // if no icon is present for the placement, just reduce the padding + (Object.keys(paddings) as Position[]).forEach((placement) => { + if (!icons[placement]) { + paddings[placement] = LINES_MARKER_SIZE; + } + }); + return paddings; +}; + +export function mapVerticalToHorizontalPlacement(placement: Position) { + switch (placement) { + case Position.Top: + return Position.Right; + case Position.Bottom: + return Position.Left; + case Position.Left: + return Position.Bottom; + case Position.Right: + return Position.Top; + } +} + +// if there's just one axis, put it on the other one +// otherwise use the same axis +// this function assume the chart is vertical +export function getBaseIconPlacement( + iconPosition: IconPosition | undefined, + axesMap?: Record, + axisMode?: YAxisMode +) { + if (iconPosition === 'auto') { + if (axisMode === 'bottom') { + return Position.Top; + } + if (axesMap) { + if (axisMode === 'left') { + return axesMap.right ? Position.Left : Position.Right; + } + return axesMap.left ? Position.Right : Position.Left; + } + } + + if (iconPosition === 'left') { + return Position.Left; + } + if (iconPosition === 'right') { + return Position.Right; + } + if (iconPosition === 'below') { + return Position.Bottom; + } + return Position.Top; +} + +export function MarkerBody({ + label, + isHorizontal, +}: { + label: string | undefined; + isHorizontal: boolean; +}) { + if (!label) { + return null; + } + if (isHorizontal) { + return ( +
+ {label} +
+ ); + } + return ( +
+
+ {label} +
+
+ ); +} + +const isNumericalString = (value: string) => !isNaN(Number(value)); + +function NumberIcon({ number }: { number: number }) { + return ( + + + {number < 10 ? number : `9+`} + + + ); +} + +interface MarkerConfig { + axisMode?: YAxisMode; + icon?: string; + textVisibility?: boolean; + iconPosition?: IconPosition; +} + +export const AnnotationIcon = ({ + type, + rotateClassName = '', + isHorizontal, + renderedInChart, + ...rest +}: { + type: string; + rotateClassName?: string; + isHorizontal?: boolean; + renderedInChart?: boolean; +} & EuiIconProps) => { + if (isNumericalString(type)) { + return ; + } + const iconConfig = annotationsIconSet.find((i) => i.value === type); + if (!iconConfig) { + return null; + } + return ( + + ); +}; + +export function Marker({ + config, + isHorizontal, + hasReducedPadding, + label, + rotateClassName, +}: { + config: MarkerConfig; + isHorizontal: boolean; + hasReducedPadding: boolean; + label?: string; + rotateClassName?: string; +}) { + if (hasIcon(config.icon)) { + return ( + + ); + } + + // if there's some text, check whether to show it as marker, or just show some padding for the icon + if (config.textVisibility) { + if (hasReducedPadding) { + return ; + } + return ; + } + return null; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 82c1106e72a08..f8d5805279a2e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -13,7 +13,9 @@ import type { AccessorConfig, FramePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import { FormatFactory, LayerType } from '../../common'; import type { XYLayerConfig } from '../../common/expressions'; -import { isDataLayer, isReferenceLayer } from './visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer } from './visualization_helpers'; +import { getAnnotationsAccessorColorConfig } from './annotations/helpers'; +import { getReferenceLineAccessorColorConfig } from './reference_line_helpers'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; @@ -42,15 +44,13 @@ export function getColorAssignments( ): ColorAssignments { const layersPerPalette: Record = {}; - layers - .filter((layer) => isDataLayer(layer)) - .forEach((layer) => { - const palette = layer.palette?.name || 'default'; - if (!layersPerPalette[palette]) { - layersPerPalette[palette] = []; - } - layersPerPalette[palette].push(layer); - }); + layers.forEach((layer) => { + const palette = layer.palette?.name || 'default'; + if (!layersPerPalette[palette]) { + layersPerPalette[palette] = []; + } + layersPerPalette[palette].push(layer); + }); return mapValues(layersPerPalette, (paletteLayers) => { const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { @@ -102,17 +102,6 @@ export function getColorAssignments( }); } -const getReferenceLineAccessorColorConfig = (layer: XYLayerConfig) => { - return layer.accessors.map((accessor) => { - const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); - return { - columnId: accessor, - triggerIcon: 'color' as const, - color: currentYConfig?.color || defaultReferenceLineColor, - }; - }); -}; - export function getAccessorColorConfig( colorAssignments: ColorAssignments, frame: Pick, @@ -122,7 +111,9 @@ export function getAccessorColorConfig( if (isReferenceLayer(layer)) { return getReferenceLineAccessorColorConfig(layer); } - + if (isAnnotationsLayer(layer)) { + return getAnnotationsAccessorColorConfig(layer); + } const layerContainsSplits = Boolean(layer.splitAccessor); const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' }; const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount; diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 654a0f1b94a14..03a180cc20a08 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -20,12 +20,13 @@ import { HorizontalAlignment, VerticalAlignment, LayoutDirection, + LineAnnotation, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; import { calculateMinInterval, XYChart, XYChartRenderProps } from './expression'; import type { LensMultiTable } from '../../common'; import { layerTypes } from '../../common'; -import { xyChart } from '../../common/expressions'; +import { AnnotationLayerArgs, xyChart } from '../../common/expressions'; import { dataLayerConfig, legendConfig, @@ -41,12 +42,14 @@ import { } from '../../common/expressions'; import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public'; import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { XyEndzones } from './x_domain'; +import { eventAnnotationServiceMock } from '../../../../../src/plugins/event_annotation/public/mocks'; +import { EventAnnotationOutput } from 'src/plugins/event_annotation/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -536,6 +539,7 @@ describe('xy_expression', () => { onSelectRange, syncColors: false, useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }; }); @@ -546,7 +550,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -613,7 +617,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'time' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time' }, + ], }} minInterval={undefined} /> @@ -802,7 +808,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'time', isHistogram: true, @@ -878,7 +884,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', xScaleType: 'time', isHistogram: true, @@ -975,7 +981,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'area', }, ], @@ -1006,7 +1012,7 @@ describe('xy_expression', () => { }, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', }, ], @@ -1083,7 +1089,9 @@ describe('xy_expression', () => { }} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'line', xScaleType: 'linear' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'line', xScaleType: 'linear' }, + ], }} /> ); @@ -1102,7 +1110,12 @@ describe('xy_expression', () => { args={{ ...args, layers: [ - { ...args.layers[0], seriesType: 'line', xScaleType: 'linear', isHistogram: true }, + { + ...(args.layers[0] as DataLayerArgs), + seriesType: 'line', + xScaleType: 'linear', + isHistogram: true, + }, ], }} /> @@ -1150,7 +1163,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1165,7 +1178,7 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1180,7 +1193,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1678,7 +1694,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1693,7 +1712,10 @@ describe('xy_expression', () => { ); expect(component).toMatchSnapshot(); @@ -1710,7 +1732,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar_horizontal_stacked' }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_horizontal_stacked' }, + ], }} /> ); @@ -1732,7 +1756,7 @@ describe('xy_expression', () => { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), xAccessor: undefined, splitAccessor: 'e', seriesType: 'bar_stacked', @@ -1762,7 +1786,7 @@ describe('xy_expression', () => { accessors: ['b'], seriesType: 'bar', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1772,7 +1796,11 @@ describe('xy_expression', () => { test('it does not apply histogram mode to more than one bar series for unstacked bar chart', () => { const { data, args } = sampleArgs(); - const firstLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + const firstLayer: DataLayerArgs = { + ...args.layers[0], + seriesType: 'bar', + isHistogram: true, + } as DataLayerArgs; delete firstLayer.splitAccessor; const component = shallow( @@ -1787,13 +1815,13 @@ describe('xy_expression', () => { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete firstLayer.splitAccessor; const secondLayer: DataLayerArgs = { ...args.layers[0], seriesType: 'line', isHistogram: true, - }; + } as DataLayerArgs; delete secondLayer.splitAccessor; const component = shallow( { ...args, layers: [ { - ...args.layers[0], + ...(args.layers[0] as DataLayerArgs), seriesType: 'bar_stacked', isHistogram: true, }, @@ -1836,7 +1864,9 @@ describe('xy_expression', () => { data={data} args={{ ...args, - layers: [{ ...args.layers[0], seriesType: 'bar', isHistogram: true }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), seriesType: 'bar', isHistogram: true }, + ], }} /> ); @@ -2232,7 +2262,10 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -2246,7 +2279,7 @@ describe('xy_expression', () => { ); expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -2268,7 +2301,7 @@ describe('xy_expression', () => { ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -2678,7 +2711,9 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { ...(args.layers[0] as DataLayerArgs), accessors: ['a'], splitAccessor: undefined }, + ], legend: { ...args.legend, isVisible: true, showSingleSeries: true }, }} /> @@ -2696,7 +2731,13 @@ describe('xy_expression', () => { data={{ ...data }} args={{ ...args, - layers: [{ ...args.layers[0], accessors: ['a'], splitAccessor: undefined }], + layers: [ + { + ...(args.layers[0] as DataLayerArgs), + accessors: ['a'], + splitAccessor: undefined, + }, + ], legend: { ...args.legend, isVisible: true, isInside: true }, }} /> @@ -2782,7 +2823,7 @@ describe('xy_expression', () => { test('it should apply None fitting function if not specified', () => { const { data, args } = sampleArgs(); - args.layers[0].accessors = ['a']; + (args.layers[0] as DataLayerArgs).accessors = ['a']; const component = shallow( @@ -2920,6 +2961,139 @@ describe('xy_expression', () => { }, ]); }); + + describe('annotations', () => { + const sampleStyledAnnotation: EventAnnotationOutput = { + time: '2022-03-18T08:25:00.000Z', + label: 'Event 1', + icon: 'triangle', + type: 'manual_event_annotation', + color: 'red', + lineStyle: 'dashed', + lineWidth: 3, + }; + const sampleAnnotationLayers: AnnotationLayerArgs[] = [ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + time: '2022-03-18T08:25:17.140Z', + label: 'Annotation', + type: 'manual_event_annotation', + }, + ], + }, + ]; + function sampleArgsWithAnnotation(annotationLayers = sampleAnnotationLayers) { + const { args } = sampleArgs(); + return { + data: dateHistogramData, + args: { + ...args, + layers: [dateHistogramLayer, ...annotationLayers], + } as XYArgs, + }; + } + test('should render basic annotation', () => { + const { data, args } = sampleArgsWithAnnotation(); + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + test('should render simplified annotation when hide is true', () => { + const { data, args } = sampleArgsWithAnnotation(); + args.layers[0].hide = true; + const component = mount(); + expect(component.find('LineAnnotation')).toMatchSnapshot(); + }); + + test('should render grouped annotations preserving the shared styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:25:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 3', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are passed because they are shared, dataValues & header is rounded to the interval + expect(groupedAnnotation).toMatchSnapshot(); + // renders numeric icon for grouped annotations + const marker = mount(
{groupedAnnotation.prop('marker')}
); + const numberIcon = marker.find('NumberIcon'); + expect(numberIcon.length).toEqual(1); + expect(numberIcon.text()).toEqual('3'); + + // checking tooltip + const renderLinks = mount(
{groupedAnnotation.prop('customTooltipDetails')!()}
); + expect(renderLinks.text()).toEqual( + ' Event 1 2022-03-18T08:25:00.000Z Event 2 2022-03-18T08:25:00.020Z Event 3 2022-03-18T08:25:00.001Z' + ); + }); + test('should render grouped annotations with default styles', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [sampleStyledAnnotation], + }, + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + { + ...sampleStyledAnnotation, + icon: 'square', + color: 'blue', + lineStyle: 'dotted', + lineWidth: 10, + time: '2022-03-18T08:25:00.001Z', + label: 'Event 2', + }, + ], + }, + ]); + const component = mount(); + const groupedAnnotation = component.find(LineAnnotation); + + expect(groupedAnnotation.length).toEqual(1); + // styles are default because they are different for both annotations + expect(groupedAnnotation).toMatchSnapshot(); + }); + test('should not render hidden annotations', () => { + const { data, args } = sampleArgsWithAnnotation([ + { + layerType: layerTypes.ANNOTATIONS, + layerId: 'annotation', + annotations: [ + sampleStyledAnnotation, + { ...sampleStyledAnnotation, time: '2022-03-18T08:30:00.020Z', label: 'Event 2' }, + { + ...sampleStyledAnnotation, + time: '2022-03-18T08:35:00.001Z', + label: 'Event 3', + isHidden: true, + }, + ], + }, + ]); + const component = mount(); + const annotations = component.find(LineAnnotation); + + expect(annotations.length).toEqual(2); + }); + }); }); describe('calculateMinInterval', () => { @@ -2927,7 +3101,7 @@ describe('xy_expression', () => { beforeEach(() => { xyProps = sampleArgs(); - xyProps.args.layers[0].xScaleType = 'time'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'time'; }); it('should use first valid layer and determine interval', async () => { xyProps.data.tables.first.columns[2].meta.source = 'esaggs'; @@ -2942,7 +3116,7 @@ describe('xy_expression', () => { }); it('should return interval of number histogram if available on first x axis columns', async () => { - xyProps.args.layers[0].xScaleType = 'linear'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'linear'; xyProps.data.tables.first.columns[2].meta = { source: 'esaggs', type: 'number', @@ -2984,7 +3158,7 @@ describe('xy_expression', () => { }); it('should return undefined if x axis is not a date', async () => { - xyProps.args.layers[0].xScaleType = 'ordinal'; + (xyProps.args.layers[0] as DataLayerArgs).xScaleType = 'ordinal'; xyProps.data.tables.first.columns.splice(2, 1); const result = await calculateMinInterval(xyProps); expect(result).toEqual(undefined); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 72a3f5f4f6976..8b62b8d0c120c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -50,11 +50,17 @@ import { i18n } from '@kbn/i18n'; import { RenderMode } from 'src/plugins/expressions'; import { ThemeServiceStart } from 'kibana/public'; import { FieldFormat } from 'src/plugins/field_formats/common'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import type { ILensInterpreterRenderHandlers, LensFilterEvent, LensBrushEvent } from '../types'; import type { LensMultiTable, FormatFactory } from '../../common'; -import type { DataLayerArgs, SeriesType, XYChartProps } from '../../common/expressions'; +import type { + DataLayerArgs, + SeriesType, + XYChartProps, + XYLayerArgs, +} from '../../common/expressions'; import { visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; @@ -72,13 +78,17 @@ import { getAxesConfiguration, GroupsConfiguration, validateExtent } from './axe import { getColorAssignments } from './color_assignment'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './get_legend_action'; -import { - computeChartMargins, - getReferenceLineRequiredPaddings, - ReferenceLineAnnotations, -} from './expression_reference_lines'; +import { ReferenceLineAnnotations } from './expression_reference_lines'; + +import { computeChartMargins, getLinesCausedPaddings } from './annotations_helpers'; + +import { Annotations, getAnnotationsGroupedByInterval } from './annotations/expression'; import { computeOverallDataDomain } from './reference_line_helpers'; -import { getReferenceLayers, isDataLayer } from './visualization_helpers'; +import { + getReferenceLayers, + getDataLayersArgs, + getAnnotationsLayersArgs, +} from './visualization_helpers'; declare global { interface Window { @@ -104,6 +114,7 @@ export type XYChartRenderProps = XYChartProps & { onSelectRange: (data: LensBrushEvent['data']) => void; renderMode: RenderMode; syncColors: boolean; + eventAnnotationService: EventAnnotationServiceType; }; export function calculateMinInterval({ args: { layers }, data }: XYChartProps) { @@ -140,6 +151,7 @@ export const getXyChartRenderer = (dependencies: { timeZone: string; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; + eventAnnotationService: EventAnnotationServiceType; }): ExpressionRenderDefinition => ({ name: 'lens_xy_chart_renderer', displayName: 'XY chart', @@ -170,6 +182,7 @@ export const getXyChartRenderer = (dependencies: { chartsActiveCursorService={dependencies.chartsActiveCursorService} chartsThemeService={dependencies.chartsThemeService} paletteService={dependencies.paletteService} + eventAnnotationService={dependencies.eventAnnotationService} timeZone={dependencies.timeZone} useLegacyTimeAxis={dependencies.useLegacyTimeAxis} minInterval={calculateMinInterval(config)} @@ -265,7 +278,9 @@ export function XYChart({ }); if (filteredLayers.length === 0) { - const icon: IconType = getIconForSeriesType(layers?.[0]?.seriesType || 'bar'); + const icon: IconType = getIconForSeriesType( + getDataLayersArgs(layers)?.[0]?.seriesType || 'bar' + ); return ; } @@ -353,7 +368,23 @@ export function XYChart({ }; const referenceLineLayers = getReferenceLayers(layers); - const referenceLinePaddings = getReferenceLineRequiredPaddings(referenceLineLayers, yAxesMap); + const annotationsLayers = getAnnotationsLayersArgs(layers); + const firstTable = data.tables[filteredLayers[0].layerId]; + + const xColumnId = firstTable.columns.find((col) => col.id === filteredLayers[0].xAccessor)?.id; + + const groupedAnnotations = getAnnotationsGroupedByInterval( + annotationsLayers, + minInterval, + xColumnId ? firstTable.rows[0]?.[xColumnId] : undefined, + xAxisFormatter + ); + const visualConfigs = [ + ...referenceLineLayers.flatMap(({ yConfig }) => yConfig), + ...groupedAnnotations, + ].filter(Boolean); + + const linesPaddings = getLinesCausedPaddings(visualConfigs, yAxesMap); const getYAxesStyle = (groupId: 'left' | 'right') => { const tickVisible = @@ -369,9 +400,9 @@ export function XYChart({ ? args.labelsOrientation?.yRight || 0 : args.labelsOrientation?.yLeft || 0, padding: - referenceLinePaddings[groupId] != null + linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -382,9 +413,9 @@ export function XYChart({ : axisTitlesVisibilitySettings?.yLeft, // if labels are not visible add the padding to the title padding: - !tickVisible && referenceLinePaddings[groupId] != null + !tickVisible && linesPaddings[groupId] != null ? { - inner: referenceLinePaddings[groupId], + inner: linesPaddings[groupId], } : undefined, }, @@ -458,7 +489,7 @@ export function XYChart({ const valueLabelsStyling = shouldShowValueLabels && valueLabels !== 'hide' && getValueLabelsStyling(shouldRotate); - const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + const colorAssignments = getColorAssignments(getDataLayersArgs(args.layers), data, formatFactory); const clickHandler: ElementClickListener = ([[geometry, series]]) => { // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue @@ -591,16 +622,13 @@ export function XYChart({ tickLabel: { visible: tickLabelsVisibilitySettings?.x, rotation: labelsOrientation?.x, - padding: - referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } - : undefined, + padding: linesPaddings.bottom != null ? { inner: linesPaddings.bottom } : undefined, }, axisTitle: { visible: axisTitlesVisibilitySettings.x, padding: - !tickLabelsVisibilitySettings?.x && referenceLinePaddings.bottom != null - ? { inner: referenceLinePaddings.bottom } + !tickLabelsVisibilitySettings?.x && linesPaddings.bottom != null + ? { inner: linesPaddings.bottom } : undefined, }, }; @@ -633,7 +661,7 @@ export function XYChart({ chartMargins: { ...chartTheme.chartPaddings, ...computeChartMargins( - referenceLinePaddings, + linesPaddings, tickLabelsVisibilitySettings, axisTitlesVisibilitySettings, yAxesMap, @@ -1005,29 +1033,37 @@ export function XYChart({ right: Boolean(yAxesMap.right), }} isHorizontal={shouldRotate} - paddingMap={referenceLinePaddings} + paddingMap={linesPaddings} + /> + ) : null} + {groupedAnnotations.length ? ( + 0} + minInterval={minInterval} /> ) : null}
); } -function getFilteredLayers(layers: DataLayerArgs[], data: LensMultiTable) { - return layers.filter((layer) => { +function getFilteredLayers(layers: XYLayerArgs[], data: LensMultiTable) { + return getDataLayersArgs(layers).filter((layer) => { const { layerId, xAccessor, accessors, splitAccessor } = layer; - return ( - isDataLayer(layer) && - !( - !accessors.length || - !data.tables[layerId] || - data.tables[layerId].rows.length === 0 || - (xAccessor && - data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || - // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty - (!xAccessor && - splitAccessor && - data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) - ) + return !( + !accessors.length || + !data.tables[layerId] || + data.tables[layerId].rows.length === 0 || + (xAccessor && + data.tables[layerId].rows.every((row) => typeof row[xAccessor] === 'undefined')) || + // stacked percentage bars have no xAccessors but splitAccessor with undefined values in them when empty + (!xAccessor && + splitAccessor && + data.tables[layerId].rows.every((row) => typeof row[splitAccessor] === 'undefined')) ); }); } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx index 2d22f6a6ed76e..7817db573e419 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_reference_lines.tsx @@ -8,183 +8,19 @@ import './expression_reference_lines.scss'; import React from 'react'; import { groupBy } from 'lodash'; -import { EuiIcon } from '@elastic/eui'; import { RectAnnotation, AnnotationDomainType, LineAnnotation, Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { FieldFormat } from 'src/plugins/field_formats/common'; -import { euiLightVars } from '@kbn/ui-theme'; -import type { IconPosition, ReferenceLineLayerArgs, YAxisMode } from '../../common/expressions'; +import type { ReferenceLineLayerArgs } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; -import { hasIcon } from './xy_config_panel/shared/icon_select'; - -export const REFERENCE_LINE_MARKER_SIZE = 20; - -export const computeChartMargins = ( - referenceLinePaddings: Partial>, - labelVisibility: Partial>, - titleVisibility: Partial>, - axesMap: Record<'left' | 'right', unknown>, - isHorizontal: boolean -) => { - const result: Partial> = {}; - if (!labelVisibility?.x && !titleVisibility?.x && referenceLinePaddings.bottom) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('bottom') : 'bottom'; - result[placement] = referenceLinePaddings.bottom; - } - if ( - referenceLinePaddings.left && - (isHorizontal || (!labelVisibility?.yLeft && !titleVisibility?.yLeft)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('left') : 'left'; - result[placement] = referenceLinePaddings.left; - } - if ( - referenceLinePaddings.right && - (isHorizontal || !axesMap.right || (!labelVisibility?.yRight && !titleVisibility?.yRight)) - ) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('right') : 'right'; - result[placement] = referenceLinePaddings.right; - } - // there's no top axis, so just check if a margin has been computed - if (referenceLinePaddings.top) { - const placement = isHorizontal ? mapVerticalToHorizontalPlacement('top') : 'top'; - result[placement] = referenceLinePaddings.top; - } - return result; -}; - -// Note: it does not take into consideration whether the reference line is in view or not -export const getReferenceLineRequiredPaddings = ( - referenceLineLayers: ReferenceLineLayerArgs[], - axesMap: Record<'left' | 'right', unknown> -) => { - // collect all paddings for the 4 axis: if any text is detected double it. - const paddings: Partial> = {}; - const icons: Partial> = {}; - referenceLineLayers.forEach((layer) => { - layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => { - if (axisMode && (hasIcon(icon) || textVisibility)) { - const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); - paddings[placement] = Math.max( - paddings[placement] || 0, - REFERENCE_LINE_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text - ); - icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); - } - }); - }); - // post-process the padding based on the icon presence: - // if no icon is present for the placement, just reduce the padding - (Object.keys(paddings) as Position[]).forEach((placement) => { - if (!icons[placement]) { - paddings[placement] = REFERENCE_LINE_MARKER_SIZE; - } - }); - - return paddings; -}; - -function mapVerticalToHorizontalPlacement(placement: Position) { - switch (placement) { - case Position.Top: - return Position.Right; - case Position.Bottom: - return Position.Left; - case Position.Left: - return Position.Bottom; - case Position.Right: - return Position.Top; - } -} - -// if there's just one axis, put it on the other one -// otherwise use the same axis -// this function assume the chart is vertical -function getBaseIconPlacement( - iconPosition: IconPosition | undefined, - axisMode: YAxisMode | undefined, - axesMap: Record -) { - if (iconPosition === 'auto') { - if (axisMode === 'bottom') { - return Position.Top; - } - if (axisMode === 'left') { - return axesMap.right ? Position.Left : Position.Right; - } - return axesMap.left ? Position.Right : Position.Left; - } - - if (iconPosition === 'left') { - return Position.Left; - } - if (iconPosition === 'right') { - return Position.Right; - } - if (iconPosition === 'below') { - return Position.Bottom; - } - return Position.Top; -} - -function getMarkerBody(label: string | undefined, isHorizontal: boolean) { - if (!label) { - return; - } - if (isHorizontal) { - return ( -
- {label} -
- ); - } - return ( -
-
- {label} -
-
- ); -} - -interface MarkerConfig { - axisMode?: YAxisMode; - icon?: string; - textVisibility?: boolean; -} - -function getMarkerToShow( - markerConfig: MarkerConfig, - label: string | undefined, - isHorizontal: boolean, - hasReducedPadding: boolean -) { - // show an icon if present - if (hasIcon(markerConfig.icon)) { - return ; - } - // if there's some text, check whether to show it as marker, or just show some padding for the icon - if (markerConfig.textVisibility) { - if (hasReducedPadding) { - return getMarkerBody( - label, - (!isHorizontal && markerConfig.axisMode === 'bottom') || - (isHorizontal && markerConfig.axisMode !== 'bottom') - ); - } - return ; - } -} +import { defaultReferenceLineColor } from './color_assignment'; +import { + MarkerBody, + Marker, + LINES_MARKER_SIZE, + mapVerticalToHorizontalPlacement, + getBaseIconPlacement, +} from './annotations_helpers'; export interface ReferenceLineAnnotationsProps { layers: ReferenceLineLayerArgs[]; @@ -241,32 +77,40 @@ export const ReferenceLineAnnotations = ({ const formatter = formatters[groupId || 'bottom']; - const defaultColor = euiLightVars.euiColorDarkShade; - // get the position for vertical chart const markerPositionVertical = getBaseIconPlacement( yConfig.iconPosition, - yConfig.axisMode, - axesMap + axesMap, + yConfig.axisMode ); // the padding map is built for vertical chart - const hasReducedPadding = - paddingMap[markerPositionVertical] === REFERENCE_LINE_MARKER_SIZE; + const hasReducedPadding = paddingMap[markerPositionVertical] === LINES_MARKER_SIZE; const props = { groupId, - marker: getMarkerToShow( - yConfig, - columnToLabelMap[yConfig.forAccessor], - isHorizontal, - hasReducedPadding + marker: ( + ), - markerBody: getMarkerBody( - yConfig.textVisibility && !hasReducedPadding - ? columnToLabelMap[yConfig.forAccessor] - : undefined, - (!isHorizontal && yConfig.axisMode === 'bottom') || - (isHorizontal && yConfig.axisMode !== 'bottom') + markerBody: ( + ), // rotate the position if required markerPosition: isHorizontal @@ -284,7 +128,7 @@ export const ReferenceLineAnnotations = ({ const sharedStyle = { strokeWidth: yConfig.lineWidth || 1, - stroke: yConfig.color || defaultColor, + stroke: yConfig.color || defaultReferenceLineColor, dash: dashStyle, }; @@ -355,7 +199,7 @@ export const ReferenceLineAnnotations = ({ })} style={{ ...sharedStyle, - fill: yConfig.color || defaultColor, + fill: yConfig.color || defaultReferenceLineColor, opacity: 0.1, }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 9697ba149e16e..cfeb1387f689c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -6,6 +6,7 @@ */ import type { CoreSetup } from 'kibana/public'; +import { EventAnnotationPluginSetup } from '../../../../../src/plugins/event_annotation/public'; import type { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import type { EditorFrameSetup } from '../types'; import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; @@ -19,6 +20,7 @@ export interface XyVisualizationPluginSetupPlugins { formatFactory: FormatFactory; editorFrame: EditorFrameSetup; charts: ChartsPluginSetup; + eventAnnotation: EventAnnotationPluginSetup; } export class XyVisualization { @@ -28,8 +30,9 @@ export class XyVisualization { ) { editorFrame.registerVisualization(async () => { const { getXyChartRenderer, getXyVisualization } = await import('../async_services'); - const [, { charts, fieldFormats }] = await core.getStartServices(); + const [, { charts, fieldFormats, eventAnnotation }] = await core.getStartServices(); const palettes = await charts.palettes.getPalettes(); + const eventAnnotationService = await eventAnnotation.getService(); const useLegacyTimeAxis = core.uiSettings.get(LEGACY_TIME_AXIS); expressions.registerRenderer( getXyChartRenderer({ @@ -37,6 +40,7 @@ export class XyVisualization { chartsThemeService: charts.theme, chartsActiveCursorService: charts.activeCursor, paletteService: palettes, + eventAnnotationService, timeZone: getTimeZone(core.uiSettings), useLegacyTimeAxis, kibanaTheme: core.theme, @@ -44,6 +48,7 @@ export class XyVisualization { ); return getXyVisualization({ paletteService: palettes, + eventAnnotationService, fieldFormats, useLegacyTimeAxis, kibanaTheme: core.theme, diff --git a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx index ac50a81da5423..8b6a96ce24d44 100644 --- a/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/reference_line_helpers.tsx @@ -14,7 +14,7 @@ import type { YConfig, } from '../../common/expressions'; import { Datatable } from '../../../../../src/plugins/expressions/public'; -import type { AccessorConfig, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; +import type { DatasourcePublicAPI, FramePublicAPI, Visualization } from '../types'; import { groupAxesByType } from './axes_configuration'; import { isHorizontalChart, isPercentageSeries, isStackedChart } from './state_helpers'; import type { XYState } from './types'; @@ -27,6 +27,7 @@ import { } from './visualization_helpers'; import { generateId } from '../id_generator'; import { LensIconChartBarReferenceLine } from '../assets/chart_bar_reference_line'; +import { defaultReferenceLineColor } from './color_assignment'; export interface ReferenceLineBase { label: 'x' | 'yRight' | 'yLeft'; @@ -360,18 +361,29 @@ export const setReferenceDimension: Visualization['setDimension'] = ({ }; }; +const getSingleColorConfig = (id: string, color = defaultReferenceLineColor) => ({ + columnId: id, + triggerIcon: 'color' as const, + color, +}); + +export const getReferenceLineAccessorColorConfig = (layer: XYReferenceLineLayerConfig) => { + return layer.accessors.map((accessor) => { + const currentYConfig = layer.yConfig?.find((yConfig) => yConfig.forAccessor === accessor); + return getSingleColorConfig(accessor, currentYConfig?.color); + }); +}; + export const getReferenceConfiguration = ({ state, frame, layer, sortedAccessors, - mappedAccessors, }: { state: XYState; frame: FramePublicAPI; layer: XYReferenceLineLayerConfig; sortedAccessors: string[]; - mappedAccessors: AccessorConfig[]; }) => { const idToIndex = sortedAccessors.reduce>((memo, id, index) => { memo[id] = index; @@ -420,11 +432,7 @@ export const getReferenceConfiguration = ({ groups: groupsToShow.map(({ config = [], id, label, dataTestSubj, valid }) => ({ groupId: id, groupLabel: getAxisName(label, { isHorizontal }), - accessors: config.map(({ forAccessor, color }) => ({ - columnId: forAccessor, - color: color || mappedAccessors.find(({ columnId }) => columnId === forAccessor)?.color, - triggerIcon: 'color' as const, - })), + accessors: config.map(({ forAccessor, color }) => getSingleColorConfig(forAccessor, color)), filterOperations: isNumericMetric, supportsMoreColumns: true, required: false, diff --git a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts index dee7899740173..e0984e62cb9cc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts +++ b/x-pack/plugins/lens/public/xy_visualization/state_helpers.ts @@ -16,7 +16,7 @@ import type { XYReferenceLineLayerConfig, } from '../../common/expressions'; import { visualizationTypes } from './types'; -import { getDataLayers, isDataLayer } from './visualization_helpers'; +import { getDataLayers, isAnnotationsLayer, isDataLayer } from './visualization_helpers'; export function isHorizontalSeries(seriesType: SeriesType) { return ( @@ -53,6 +53,9 @@ export function getIconForSeries(type: SeriesType): EuiIconType { } export const getSeriesColor = (layer: XYLayerConfig, accessor: string) => { + if (isAnnotationsLayer(layer)) { + return layer?.annotations?.find((ann) => ann.id === accessor)?.color || null; + } if (isDataLayer(layer) && layer.splitAccessor) { return null; } diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index fa992d8829b20..2e3db8f2f6f93 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -15,6 +15,7 @@ import { layerTypes } from '../../common'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { defaultReferenceLineColor } from './color_assignment'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; describe('#toExpression', () => { const xyVisualization = getXyVisualization({ @@ -22,6 +23,7 @@ describe('#toExpression', () => { fieldFormats: fieldFormatsServiceMock.createStartContract(), kibanaTheme: themeServiceMock.createStartContract(), useLegacyTimeAxis: false, + eventAnnotationService: eventAnnotationServiceMock, }); let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index a9c166a9c13eb..ade90ff98e553 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -8,29 +8,40 @@ import { Ast } from '@kbn/interpreter'; import { ScaleType } from '@elastic/charts'; import { PaletteRegistry } from 'src/plugins/charts/public'; +import { EventAnnotationServiceType } from 'src/plugins/event_annotation/public'; import { State } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, - XYDataLayerConfig, + XYAnnotationLayerConfig, XYReferenceLineLayerConfig, YConfig, + XYDataLayerConfig, } from '../../common/expressions'; import { layerTypes } from '../../common'; import { hasIcon } from './xy_config_panel/shared/icon_select'; import { defaultReferenceLineColor } from './color_assignment'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; -import { isDataLayer } from './visualization_helpers'; +import { + getLayerTypeOptions, + getDataLayers, + getReferenceLayers, + getAnnotationsLayers, +} from './visualization_helpers'; +import { defaultAnnotationLabel } from './annotations/config_panel'; +import { getUniqueLabels } from './annotations/helpers'; export const getSortedAccessors = ( datasource: DatasourcePublicAPI, layer: XYDataLayerConfig | XYReferenceLineLayerConfig ) => { const originalOrder = datasource - .getTableSpec() - .map(({ columnId }: { columnId: string }) => columnId) - .filter((columnId: string) => layer.accessors.includes(columnId)); + ? datasource + .getTableSpec() + .map(({ columnId }: { columnId: string }) => columnId) + .filter((columnId: string) => layer.accessors.includes(columnId)) + : layer.accessors; // When we add a column it could be empty, and therefore have no order return Array.from(new Set(originalOrder.concat(layer.accessors))); }; @@ -39,7 +50,8 @@ export const toExpression = ( state: State, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { if (!state || !state.layers.length) { return null; @@ -49,38 +61,58 @@ export const toExpression = ( state.layers.forEach((layer) => { metadata[layer.layerId] = {}; const datasource = datasourceLayers[layer.layerId]; - datasource.getTableSpec().forEach((column) => { - const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); - metadata[layer.layerId][column.columnId] = operation; - }); + if (datasource) { + datasource.getTableSpec().forEach((column) => { + const operation = datasourceLayers[layer.layerId].getOperationForColumnId(column.columnId); + metadata[layer.layerId][column.columnId] = operation; + }); + } }); - return buildExpression(state, metadata, datasourceLayers, paletteService, attributes); + return buildExpression( + state, + metadata, + datasourceLayers, + paletteService, + attributes, + eventAnnotationService + ); +}; + +const simplifiedLayerExpression = { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => ({ ...layer, hide: true }), + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => ({ + ...layer, + hide: true, + yConfig: layer.yConfig?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => ({ + ...layer, + hide: true, + annotations: layer.annotations?.map(({ lineWidth, ...rest }) => ({ + ...rest, + lineWidth: 1, + icon: undefined, + textVisibility: false, + })), + }), }; export function toPreviewExpression( state: State, datasourceLayers: Record, - paletteService: PaletteRegistry + paletteService: PaletteRegistry, + eventAnnotationService: EventAnnotationServiceType ) { return toExpression( { ...state, - layers: state.layers.map((layer) => - isDataLayer(layer) - ? { ...layer, hide: true } - : // cap the reference line to 1px - { - ...layer, - hide: true, - yConfig: layer.yConfig?.map(({ lineWidth, ...config }) => ({ - ...config, - lineWidth: 1, - icon: undefined, - textVisibility: false, - })), - } - ), + layers: state.layers.map((layer) => getLayerTypeOptions(layer, simplifiedLayerExpression)), // hide legend for preview legend: { ...state.legend, @@ -90,7 +122,8 @@ export function toPreviewExpression( }, datasourceLayers, paletteService, - {} + {}, + eventAnnotationService ); } @@ -125,23 +158,35 @@ export const buildExpression = ( metadata: Record>, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + eventAnnotationService: EventAnnotationServiceType ): Ast | null => { - const validLayers = state.layers + const validDataLayers = getDataLayers(state.layers) .filter((layer): layer is ValidLayer => Boolean(layer.accessors.length)) - .map((layer) => { - if (!datasourceLayers) { - return layer; - } - const sortedAccessors = getSortedAccessors(datasourceLayers[layer.layerId], layer); + .map((layer) => ({ + ...layer, + accessors: getSortedAccessors(datasourceLayers[layer.layerId], layer), + })); + + // sorting doesn't change anything so we don't sort reference layers (TODO: should we make it work?) + const validReferenceLayers = getReferenceLayers(state.layers).filter((layer) => + Boolean(layer.accessors.length) + ); + const uniqueLabels = getUniqueLabels(state.layers); + const validAnnotationsLayers = getAnnotationsLayers(state.layers) + .filter((layer) => Boolean(layer.annotations.length)) + .map((layer) => { return { ...layer, - accessors: sortedAccessors, + annotations: layer.annotations.map((c) => ({ + ...c, + label: uniqueLabels[c.id], + })), }; }); - if (!validLayers.length) { + if (!validDataLayers.length) { return null; } @@ -309,20 +354,25 @@ export const buildExpression = ( valueLabels: [state?.valueLabels || 'hide'], hideEndzones: [state?.hideEndzones || false], valuesInLegend: [state?.valuesInLegend || false], - layers: validLayers.map((layer) => { - if (isDataLayer(layer)) { - return dataLayerToExpression( + layers: [ + ...validDataLayers.map((layer) => + dataLayerToExpression( layer, datasourceLayers[layer.layerId], metadata, paletteService - ); - } - return referenceLineLayerToExpression( - layer, - datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] - ); - }), + ) + ), + ...validReferenceLayers.map((layer) => + referenceLineLayerToExpression( + layer, + datasourceLayers[(layer as XYReferenceLineLayerConfig).layerId] + ) + ), + ...validAnnotationsLayers.map((layer) => + annotationLayerToExpression(layer, eventAnnotationService) + ), + ], }, }, ], @@ -355,6 +405,41 @@ const referenceLineLayerToExpression = ( }; }; +const annotationLayerToExpression = ( + layer: XYAnnotationLayerConfig, + eventAnnotationService: EventAnnotationServiceType +): Ast => { + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_annotation_layer', + arguments: { + hide: [Boolean(layer.hide)], + layerId: [layer.layerId], + layerType: [layerTypes.ANNOTATIONS], + annotations: layer.annotations + ? layer.annotations.map( + (ann): Ast => + eventAnnotationService.toExpression({ + time: ann.key.timestamp, + label: ann.label || defaultAnnotationLabel, + textVisibility: ann.textVisibility, + icon: ann.icon, + lineStyle: ann.lineStyle, + lineWidth: ann.lineWidth, + color: ann.color, + isHidden: Boolean(ann.isHidden), + }) + ) + : [], + }, + }, + ], + }; +}; + const dataLayerToExpression = ( layer: ValidLayer, datasourceLayer: DatasourcePublicAPI, diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 07e411b1993c9..b93cf317e1b2f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -8,7 +8,7 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation, VisualizeEditorContext, Suggestion, OperationDescriptor } from '../types'; -import type { State, XYSuggestion } from './types'; +import type { State, XYState, XYSuggestion } from './types'; import type { SeriesType, XYDataLayerConfig, @@ -23,6 +23,18 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { Datatable } from 'src/plugins/expressions'; import { themeServiceMock } from '../../../../../src/core/public/mocks'; +import { eventAnnotationServiceMock } from 'src/plugins/event_annotation/public/mocks'; +import { EventAnnotationConfig } from 'src/plugins/event_annotation/common'; + +const exampleAnnotation: EventAnnotationConfig = { + id: 'an1', + label: 'Event 1', + key: { + type: 'point_in_time', + timestamp: '2022-03-18T08:25:17.140Z', + }, + icon: 'circle', +}; function exampleState(): State { return { @@ -49,6 +61,7 @@ const xyVisualization = getXyVisualization({ fieldFormats: fieldFormatsMock, useLegacyTimeAxis: false, kibanaTheme: themeServiceMock.createStartContract(), + eventAnnotationService: eventAnnotationServiceMock, }); describe('xy_visualization', () => { @@ -149,7 +162,7 @@ describe('xy_visualization', () => { expect(initialState.layers).toHaveLength(1); expect((initialState.layers[0] as XYDataLayerConfig).xAccessor).not.toBeDefined(); - expect(initialState.layers[0].accessors).toHaveLength(0); + expect((initialState.layers[0] as XYDataLayerConfig).accessors).toHaveLength(0); expect(initialState).toMatchInlineSnapshot(` Object { @@ -227,12 +240,63 @@ describe('xy_visualization', () => { describe('#getSupportedLayers', () => { it('should return a double layer types', () => { - expect(xyVisualization.getSupportedLayers()).toHaveLength(2); + expect(xyVisualization.getSupportedLayers()).toHaveLength(3); }); it('should return the icon for the visualization type', () => { expect(xyVisualization.getSupportedLayers()[0].icon).not.toBeUndefined(); }); + describe('annotations', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + it('when there is no date histogram annotation layer is disabled', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState()) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeTruthy(); + }); + it('for data with date histogram annotation layer is enabled and calculates initial dimensions', () => { + const supportedAnnotationLayer = xyVisualization + .getSupportedLayers(exampleState(), frame) + .find((a) => a.type === 'annotations'); + expect(supportedAnnotationLayer?.disabled).toBeFalsy(); + expect(supportedAnnotationLayer?.noDatasource).toBeTruthy(); + expect(supportedAnnotationLayer?.initialDimensions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: 'xAnnotations', columnId: expect.any(String) }), + ]) + ); + }); + }); }); describe('#getLayerType', () => { @@ -358,6 +422,45 @@ describe('xy_visualization', () => { ], }); }); + + describe('annotations', () => { + it('should add a dimension to a annotation layer', () => { + jest.spyOn(Date, 'now').mockReturnValue(new Date('2022-04-18T11:01:58.135Z').valueOf()); + expect( + xyVisualization.setDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }, + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'newCol', + }).layers[0] + ).toEqual({ + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + exampleAnnotation, + { + icon: 'triangle', + id: 'newCol', + key: { + timestamp: '2022-04-18T11:01:58.135Z', + type: 'point_in_time', + }, + label: 'Event', + }, + ], + }); + }); + }); }); describe('#updateLayersConfigurationFromContext', () => { @@ -472,9 +575,10 @@ describe('xy_visualization', () => { layerId: 'first', context: newContext, }); - expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); - expect(state?.layers[0]).toHaveProperty('layerType', 'referenceLine'); - expect(state?.layers[0].yConfig).toStrictEqual([ + const firstLayer = state?.layers[0] as XYDataLayerConfig; + expect(firstLayer).toHaveProperty('seriesType', 'area'); + expect(firstLayer).toHaveProperty('layerType', 'referenceLine'); + expect(firstLayer.yConfig).toStrictEqual([ { axisMode: 'right', color: '#68BC00', @@ -695,6 +799,45 @@ describe('xy_visualization', () => { accessors: [], }); }); + it('removes annotation dimension', () => { + expect( + xyVisualization.removeDimension({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation, { ...exampleAnnotation, id: 'an2' }], + }, + ], + }, + layerId: 'ann', + columnId: 'an2', + }).layers + ).toEqual([ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'ann', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ]); + }); }); describe('#getConfiguration', () => { @@ -1069,7 +1212,7 @@ describe('xy_visualization', () => { it('should support static value', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[1] as XYReferenceLineLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; expect( xyVisualization.getConfiguration({ @@ -1082,7 +1225,7 @@ describe('xy_visualization', () => { it('should return no referenceLine groups for a empty data layer', () => { const state = getStateWithBaseReferenceLine(); - state.layers[0].accessors = []; + (state.layers[0] as XYDataLayerConfig).accessors = []; (state.layers[1] as XYReferenceLineLayerConfig).yConfig = undefined; const options = xyVisualization.getConfiguration({ @@ -1358,6 +1501,83 @@ describe('xy_visualization', () => { }); }); + describe('annotations', () => { + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + frame.datasourceLayers.first.getOperationForColumnId = jest.fn((accessor) => { + if (accessor === 'a') { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + label: 'date_histogram', + isStaticValue: false, + hasTimeShift: false, + }; + } + return null; + }); + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + }); + + function getStateWithAnnotationLayer(): State { + return { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'area', + splitAccessor: undefined, + xAccessor: 'a', + accessors: ['b'], + }, + { + layerId: 'annotations', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation], + }, + ], + }; + } + + it('returns configuration correctly', () => { + const state = getStateWithAnnotationLayer(); + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].accessors).toEqual([ + { color: '#f04e98', columnId: 'an1', triggerIcon: 'color' }, + ]); + expect(config.groups[0].invalid).toEqual(false); + }); + + it('When data layer is empty, should return invalid state', () => { + const state = getStateWithAnnotationLayer(); + (state.layers[0] as XYDataLayerConfig).xAccessor = undefined; + const config = xyVisualization.getConfiguration({ + state, + frame, + layerId: 'annotations', + }); + expect(config.groups[0].invalid).toEqual(true); + }); + }); + describe('color assignment', () => { function callConfig(layerConfigOverride: Partial) { const baseState = exampleState(); @@ -1954,4 +2174,87 @@ describe('xy_visualization', () => { `); }); }); + describe('#getUniqueLabels', () => { + it('creates unique labels for single annotations layer with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layerId', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + }); + }); + it('creates unique labels for multiple annotations layers with repeating labels', async () => { + const xyState = { + layers: [ + { + layerId: 'layer1', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '1', + }, + { + label: 'Event', + id: '2', + }, + { + label: 'Custom', + id: '3', + }, + ], + }, + { + layerId: 'layer2', + layerType: 'annotations', + annotations: [ + { + label: 'Event', + id: '4', + }, + { + label: 'Event [1]', + id: '5', + }, + { + label: 'Custom', + id: '6', + }, + ], + }, + ], + } as XYState; + + expect(xyVisualization.getUniqueLabels!(xyState)).toEqual({ + '1': 'Event', + '2': 'Event [1]', + '3': 'Custom', + '4': 'Event [2]', + '5': 'Event [1] [1]', + '6': 'Custom [1]', + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index c9951c24f8a47..78fd50f7cfece 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -13,16 +13,17 @@ import { i18n } from '@kbn/i18n'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { FieldFormatsStart } from 'src/plugins/field_formats/public'; import { ThemeServiceStart } from 'kibana/public'; +import { EventAnnotationServiceType } from '../../../../../src/plugins/event_annotation/public'; import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; -import type { FillStyle } from '../../common/expressions/xy_chart'; +import type { FillStyle, XYLayerConfig } from '../../common/expressions/xy_chart'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar } from './xy_config_panel'; import { DimensionEditor } from './xy_config_panel/dimension_editor'; import { LayerHeader } from './xy_config_panel/layer_header'; import type { Visualization, AccessorConfig, FramePublicAPI } from '../types'; import { State, visualizationTypes, XYSuggestion } from './types'; -import { SeriesType, XYDataLayerConfig, XYLayerConfig, YAxisMode } from '../../common/expressions'; +import { SeriesType, XYDataLayerConfig, YAxisMode } from '../../common/expressions'; import { layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -34,6 +35,12 @@ import { getReferenceSupportedLayer, setReferenceDimension, } from './reference_line_helpers'; +import { + getAnnotationsConfiguration, + getAnnotationsSupportedLayer, + setAnnotationsDimension, + getUniqueLabels, +} from './annotations/helpers'; import { checkXAccessorCompatibility, defaultSeriesType, @@ -42,7 +49,9 @@ import { getDescription, getFirstDataLayer, getLayersByType, + getReferenceLayers, getVisualizationType, + isAnnotationsLayer, isBucketed, isDataLayer, isNumericDynamicMetric, @@ -54,14 +63,18 @@ import { import { groupAxesByType } from './axes_configuration'; import { XYState } from '..'; import { ReferenceLinePanel } from './xy_config_panel/reference_line_panel'; +import { DimensionTrigger } from '../shared_components/dimension_trigger'; +import { AnnotationsPanel, defaultAnnotationLabel } from './annotations/config_panel'; export const getXyVisualization = ({ paletteService, fieldFormats, useLegacyTimeAxis, kibanaTheme, + eventAnnotationService, }: { paletteService: PaletteRegistry; + eventAnnotationService: EventAnnotationServiceType; fieldFormats: FieldFormatsStart; useLegacyTimeAxis: boolean; kibanaTheme: ThemeServiceStart; @@ -155,7 +168,11 @@ export const getXyVisualization = ({ }, getSupportedLayers(state, frame) { - return [supportedDataLayer, getReferenceSupportedLayer(state, frame)]; + return [ + supportedDataLayer, + getAnnotationsSupportedLayer(state, frame), + getReferenceSupportedLayer(state, frame), + ]; }, getConfiguration({ state, frame, layerId }) { @@ -164,10 +181,18 @@ export const getXyVisualization = ({ return { groups: [] }; } + if (isAnnotationsLayer(layer)) { + return getAnnotationsConfiguration({ state, frame, layer }); + } + const sortedAccessors: string[] = getSortedAccessors( frame.datasourceLayers[layer.layerId], layer ); + if (isReferenceLayer(layer)) { + return getReferenceConfiguration({ state, frame, layer, sortedAccessors }); + } + const mappedAccessors = getMappedAccessors({ state, frame, @@ -177,11 +202,7 @@ export const getXyVisualization = ({ accessors: sortedAccessors, }); - if (isReferenceLayer(layer)) { - return getReferenceConfiguration({ state, frame, layer, sortedAccessors, mappedAccessors }); - } const dataLayers = getDataLayers(state.layers); - const isHorizontal = isHorizontalChart(state.layers); const { left, right } = groupAxesByType([layer], frame.activeData); // Check locally if it has one accessor OR one accessor per axis @@ -275,6 +296,9 @@ export const getXyVisualization = ({ if (isReferenceLayer(foundLayer)) { return setReferenceDimension(props); } + if (isAnnotationsLayer(foundLayer)) { + return setAnnotationsDimension(props); + } const newLayer = { ...foundLayer }; if (groupId === 'x') { @@ -295,7 +319,7 @@ export const getXyVisualization = ({ updateLayersConfigurationFromContext({ prevState, layerId, context }) { const { chartType, axisPosition, palette, metrics } = context; const foundLayer = prevState?.layers.find((l) => l.layerId === layerId); - if (!foundLayer) { + if (!foundLayer || !isDataLayer(foundLayer)) { return prevState; } const isReferenceLine = metrics.some((metric) => metric.agg === 'static_value'); @@ -377,7 +401,16 @@ export const getXyVisualization = ({ if (!foundLayer) { return prevState; } - const dataLayers = getDataLayers(prevState.layers); + if (isAnnotationsLayer(foundLayer)) { + const newLayer = { ...foundLayer }; + newLayer.annotations = newLayer.annotations.filter(({ id }) => id !== columnId); + + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + return { + ...prevState, + layers: newLayers, + }; + } const newLayer = { ...foundLayer }; if (isDataLayer(newLayer)) { if (newLayer.xAccessor === columnId) { @@ -392,15 +425,15 @@ export const getXyVisualization = ({ newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } - if (newLayer.yConfig) { - newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + if ('yConfig' in newLayer) { + newLayer.yConfig = newLayer.yConfig?.filter(({ forAccessor }) => forAccessor !== columnId); } let newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); // check if there's any reference layer and pull it off if all data layers have no dimensions set // check for data layers if they all still have xAccessors const groupsAvailable = getGroupsAvailableInData( - dataLayers, + getDataLayers(prevState.layers), frame.datasourceLayers, frame?.activeData ); @@ -410,7 +443,9 @@ export const getXyVisualization = ({ (id) => !groupsAvailable[id] ) ) { - newLayers = newLayers.filter((layer) => isDataLayer(layer) || layer.accessors.length); + newLayers = newLayers.filter( + (layer) => isDataLayer(layer) || ('accessors' in layer && layer.accessors.length) + ); } return { @@ -450,9 +485,12 @@ export const getXyVisualization = ({ const layer = props.state.layers.find((l) => l.layerId === props.layerId)!; const dimensionEditor = isReferenceLayer(layer) ? ( + ) : isAnnotationsLayer(layer) ? ( + ) : ( ); + render( {dimensionEditor} @@ -462,8 +500,9 @@ export const getXyVisualization = ({ }, toExpression: (state, layers, attributes) => - toExpression(state, layers, paletteService, attributes), - toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), + toExpression(state, layers, paletteService, attributes, eventAnnotationService), + toPreviewExpression: (state, layers) => + toPreviewExpression(state, layers, paletteService, eventAnnotationService), getErrorMessages(state, datasourceLayers) { // Data error handling below here @@ -504,7 +543,7 @@ export const getXyVisualization = ({ // temporary fix for #87068 errors.push(...checkXAccessorCompatibility(state, datasourceLayers)); - for (const layer of state.layers) { + for (const layer of getDataLayers(state.layers)) { const datasourceAPI = datasourceLayers[layer.layerId]; if (datasourceAPI) { for (const accessor of layer.accessors) { @@ -540,9 +579,10 @@ export const getXyVisualization = ({ return; } - const layers = state.layers; - - const filteredLayers = layers.filter(({ accessors }: XYLayerConfig) => accessors.length > 0); + const filteredLayers = [ + ...getDataLayers(state.layers), + ...getReferenceLayers(state.layers), + ].filter(({ accessors }) => accessors.length > 0); const accessorsWithArrayValues = []; for (const layer of filteredLayers) { const { layerId, accessors } = layer; @@ -569,6 +609,35 @@ export const getXyVisualization = ({ /> )); }, + getUniqueLabels(state) { + return getUniqueLabels(state.layers); + }, + renderDimensionTrigger({ + columnId, + label, + hideTooltip, + invalid, + invalidMessage, + }: { + columnId: string; + label?: string; + hideTooltip?: boolean; + invalid?: boolean; + invalidMessage?: string; + }) { + if (label) { + return ( + + ); + } + return null; + }, }); const getMappedAccessors = ({ @@ -584,7 +653,7 @@ const getMappedAccessors = ({ paletteService: PaletteRegistry; fieldFormats: FieldFormatsStart; state: XYState; - layer: XYLayerConfig; + layer: XYDataLayerConfig; }) => { let mappedAccessors: AccessorConfig[] = accessors.map((accessor) => ({ columnId: accessor, @@ -592,7 +661,7 @@ const getMappedAccessors = ({ if (frame.activeData) { const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, fieldFormats.deserialize ); diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx index 7446c2a06119c..23c2446ca2363 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization_helpers.tsx @@ -11,8 +11,12 @@ import { DatasourcePublicAPI, OperationMetadata, VisualizationType } from '../ty import { State, visualizationTypes, XYState } from './types'; import { isHorizontalChart } from './state_helpers'; import { + AnnotationLayerArgs, + DataLayerArgs, SeriesType, + XYAnnotationLayerConfig, XYDataLayerConfig, + XYLayerArgs, XYLayerConfig, XYReferenceLineLayerConfig, } from '../../common/expressions'; @@ -130,9 +134,12 @@ export function checkScaleOperation( export const isDataLayer = (layer: Pick): layer is XYDataLayerConfig => layer.layerType === layerTypes.DATA || !layer.layerType; -export const getDataLayers = (layers: XYLayerConfig[]) => +export const getDataLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer)); +export const getDataLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is DataLayerArgs => isDataLayer(layer)); + export const getFirstDataLayer = (layers: XYLayerConfig[]) => (layers || []).find((layer): layer is XYDataLayerConfig => isDataLayer(layer)); @@ -140,9 +147,34 @@ export const isReferenceLayer = ( layer: Pick ): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE; -export const getReferenceLayers = (layers: XYLayerConfig[]) => +export const getReferenceLayers = (layers: Array>) => (layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer)); +export const isAnnotationsLayer = ( + layer: Pick +): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS; + +export const getAnnotationsLayers = (layers: Array>) => + (layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer)); + +export const getAnnotationsLayersArgs = (layers: XYLayerArgs[]) => + (layers || []).filter((layer): layer is AnnotationLayerArgs => isAnnotationsLayer(layer)); + +export interface LayerTypeToLayer { + [layerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig; + [layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig; + [layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig; +} + +export const getLayerTypeOptions = (layer: XYLayerConfig, options: LayerTypeToLayer) => { + if (isDataLayer(layer)) { + return options[layerTypes.DATA](layer); + } else if (isReferenceLayer(layer)) { + return options[layerTypes.REFERENCELINE](layer); + } + return options[layerTypes.ANNOTATIONS](layer); +}; + export function getVisualizationType(state: State): VisualizationType | 'mixed' { if (!state.layers.length) { return ( @@ -255,6 +287,11 @@ const newLayerFn = { layerType: layerTypes.REFERENCELINE, accessors: [], }), + [layerTypes.ANNOTATIONS]: ({ layerId }: { layerId: string }): XYAnnotationLayerConfig => ({ + layerId, + layerType: layerTypes.ANNOTATIONS, + annotations: [], + }), }; export function newLayerState({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx index 8aa2aaf16ae5f..b448ebfbd455e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/color_picker.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { defaultAnnotationColor } from '../../../../../../src/plugins/event_annotation/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State } from '../types'; import { FormatFactory } from '../../../common'; @@ -20,7 +21,7 @@ import { } from '../color_assignment'; import { getSortedAccessors } from '../to_expression'; import { TooltipWrapper } from '../../shared_components'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isReferenceLayer, isAnnotationsLayer, getDataLayers } from '../visualization_helpers'; const tooltipContent = { auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { @@ -62,15 +63,17 @@ export const ColorPicker = ({ if (overwriteColor || !frame.activeData) return overwriteColor; if (isReferenceLayer(layer)) { return defaultReferenceLineColor; + } else if (isAnnotationsLayer(layer)) { + return defaultAnnotationColor; } const sortedAccessors: string[] = getSortedAccessors( - frame.datasourceLayers[layer.layerId], + frame.datasourceLayers[layer.layerId] ?? layer.accessors, layer ); const colorAssignments = getColorAssignments( - state.layers, + getDataLayers(state.layers), { tables: frame.activeData }, formatFactory ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx index 465a627fa33b2..c4e5268cfb8af 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/layer_header.tsx @@ -16,8 +16,9 @@ import { trackUiEvent } from '../../lens_ui_telemetry'; import { StaticHeader } from '../../shared_components'; import { ToolbarButton } from '../../../../../../src/plugins/kibana_react/public'; import { LensIconChartBarReferenceLine } from '../../assets/chart_bar_reference_line'; +import { LensIconChartBarAnnotations } from '../../assets/chart_bar_annotations'; import { updateLayer } from '.'; -import { isReferenceLayer } from '../visualization_helpers'; +import { isAnnotationsLayer, isReferenceLayer } from '../visualization_helpers'; export function LayerHeader(props: VisualizationLayerWidgetProps) { const layer = props.state.layers.find((l) => l.layerId === props.layerId); @@ -26,6 +27,8 @@ export function LayerHeader(props: VisualizationLayerWidgetProps) { } if (isReferenceLayer(layer)) { return ; + } else if (isAnnotationsLayer(layer)) { + return ; } return ; } @@ -41,6 +44,17 @@ function ReferenceLayerHeader() { ); } +function AnnotationsLayerHeader() { + return ( + + ); +} + function DataLayerHeader(props: VisualizationLayerWidgetProps) { const [isPopoverOpen, setPopoverIsOpen] = useState(false); const { state, layerId } = props; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx index f00d60b0dc814..78020034c3d43 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx @@ -70,6 +70,7 @@ export const ReferenceLinePanel = ( return ( <> + {' '} ; + +export const euiIconsSet = [ { value: 'empty', label: i18n.translate('xpack.lens.xyChart.iconSelect.noIconLabel', { @@ -70,29 +72,35 @@ const icons = [ }, ]; -const IconView = (props: { value?: string; label: string }) => { +const IconView = (props: { value?: string; label: string; icon?: IconType }) => { if (!props.value) return null; return ( - - - {` ${props.label}`} - + + + + + {props.label} + ); }; export const IconSelect = ({ value, onChange, + customIconSet = euiIconsSet, }: { value?: string; onChange: (newIcon: string) => void; + customIconSet?: IconSet; }) => { - const selectedIcon = icons.find((option) => value === option.value) || icons[0]; + const selectedIcon = + customIconSet.find((option) => value === option.value) || + customIconSet.find((option) => option.value === 'empty')!; return ( { onChange(selection[0].value!); @@ -100,7 +108,11 @@ export const IconSelect = ({ singleSelection={{ asPlainText: true }} renderOption={IconView} compressed - prepend={hasIcon(selectedIcon.value) ? : undefined} + prepend={ + hasIcon(selectedIcon.value) ? ( + + ) : undefined + } /> ); }; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx index db01a027d8fec..766d5462db787 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/shared/line_style_settings.tsx @@ -40,8 +40,8 @@ export const LineStyleSettings = ({ defaultMessage: 'Line', })} > - - + + { @@ -49,9 +49,8 @@ export const LineStyleSettings = ({ }} /> - + void; isHorizontal: boolean; + customIconSet?: IconSet; }) => { return ( <> @@ -133,13 +136,15 @@ export const MarkerDecorationSettings = ({ })} > { setConfig({ icon: newIcon }); }} /> - {hasIcon(currentConfig?.icon) || currentConfig?.textVisibility ? ( + {currentConfig?.iconPosition && + (hasIcon(currentConfig?.icon) || currentConfig?.textVisibility) ? ( { @@ -533,6 +535,60 @@ describe('xy_suggestions', () => { ); }); + test('passes annotation layer without modifying it', () => { + const annotationLayer: XYAnnotationLayerConfig = { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + annotations: [ + { + id: '1', + key: { + type: 'point_in_time', + timestamp: '2020-20-22', + }, + label: 'annotation', + }, + ], + }; + const currentState: XYState = { + legend: { isVisible: true, position: 'bottom' }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + fittingFunction: 'None', + layers: [ + { + accessors: ['price'], + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'bar', + splitAccessor: 'date', + xAccessor: 'product', + }, + annotationLayer, + ], + }; + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + keptLayerIds: [], + }); + + suggestions.every((suggestion) => + expect(suggestion.state.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerType: layerTypes.ANNOTATIONS, + }), + ]) + ) + ); + }); + test('includes passed in palette for split charts if specified', () => { const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; const [suggestion] = getSuggestions({ diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 1578442b52815..bd5a37c206c6c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -521,7 +521,10 @@ function buildSuggestion({ const keptLayers = currentState ? currentState.layers // Remove layers that aren't being suggested - .filter((layer) => keptLayerIds.includes(layer.layerId)) + .filter( + (layer) => + keptLayerIds.includes(layer.layerId) || layer.layerType === layerTypes.ANNOTATIONS + ) // Update in place .map((layer) => (layer.layerId === layerId ? newLayer : layer)) // Replace the seriesType on all previous layers diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index 84e238b3eb15e..c68fed23a7fdb 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -12,6 +12,7 @@ import { yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, @@ -40,6 +41,7 @@ export const setupExpressions = ( yAxisConfig, dataLayerConfig, referenceLineLayerConfig, + annotationLayerConfig, formatColumn, legendConfig, renameColumns, diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index 583e2963a1ca7..76e25f8b08639 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -1,4 +1,3 @@ - { "extends": "../../../tsconfig.base.json", "compilerOptions": { @@ -15,31 +14,86 @@ "../../../typings/**/*" ], "references": [ - { "path": "../spaces/tsconfig.json" }, - { "path": "../../../src/core/tsconfig.json" }, - { "path": "../task_manager/tsconfig.json" }, - { "path": "../global_search/tsconfig.json"}, - { "path": "../saved_objects_tagging/tsconfig.json"}, - { "path": "../../../src/plugins/data/tsconfig.json"}, - { "path": "../../../src/plugins/data_views/tsconfig.json"}, - { "path": "../../../src/plugins/data_view_field_editor/tsconfig.json"}, - { "path": "../../../src/plugins/charts/tsconfig.json"}, - { "path": "../../../src/plugins/expressions/tsconfig.json"}, - { "path": "../../../src/plugins/navigation/tsconfig.json" }, - { "path": "../../../src/plugins/url_forwarding/tsconfig.json" }, - { "path": "../../../src/plugins/visualizations/tsconfig.json" }, - { "path": "../../../src/plugins/dashboard/tsconfig.json" }, - { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json" }, - { "path": "../../../src/plugins/share/tsconfig.json" }, - { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, - { "path": "../../../src/plugins/saved_objects/tsconfig.json"}, - { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, - { "path": "../../../src/plugins/embeddable/tsconfig.json"}, - { "path": "../../../src/plugins/presentation_util/tsconfig.json"}, - { "path": "../../../src/plugins/field_formats/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json"}, - { "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json"} + { + "path": "../spaces/tsconfig.json" + }, + { + "path": "../../../src/core/tsconfig.json" + }, + { + "path": "../task_manager/tsconfig.json" + }, + { + "path": "../global_search/tsconfig.json" + }, + { + "path": "../saved_objects_tagging/tsconfig.json" + }, + { + "path": "../../../src/plugins/data/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_views/tsconfig.json" + }, + { + "path": "../../../src/plugins/data_view_field_editor/tsconfig.json" + }, + { + "path": "../../../src/plugins/charts/tsconfig.json" + }, + { + "path": "../../../src/plugins/expressions/tsconfig.json" + }, + { + "path": "../../../src/plugins/navigation/tsconfig.json" + }, + { + "path": "../../../src/plugins/url_forwarding/tsconfig.json" + }, + { + "path": "../../../src/plugins/visualizations/tsconfig.json" + }, + { + "path": "../../../src/plugins/dashboard/tsconfig.json" + }, + { + "path": "../../../src/plugins/ui_actions/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/share/tsconfig.json" + }, + { + "path": "../../../src/plugins/usage_collection/tsconfig.json" + }, + { + "path": "../../../src/plugins/saved_objects/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_utils/tsconfig.json" + }, + { + "path": "../../../src/plugins/kibana_react/tsconfig.json" + }, + { + "path": "../../../src/plugins/embeddable/tsconfig.json" + }, + { + "path": "../../../src/plugins/presentation_util/tsconfig.json" + }, + { + "path": "../../../src/plugins/field_formats/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_heatmap/tsconfig.json" + }, + { + "path": "../../../src/plugins/chart_expressions/expression_gauge/tsconfig.json" + }, + { + "path": "../../../src/plugins/event_annotation/tsconfig.json" + } ] -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index db10095ce0591..5fbde8959b364 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -530,7 +530,6 @@ "xpack.lens.indexPattern.ranges.lessThanTooltip": "Inférieur à", "xpack.lens.indexPattern.records": "Enregistrements", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "Sous-fonction", - "xpack.lens.indexPattern.removeColumnAriaLabel": "Ajouter ou glisser-déposer un champ dans {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "Retirer la configuration de \"{groupLabel}\"", "xpack.lens.indexPattern.removeFieldLabel": "Retirer le champ du modèle d'indexation", "xpack.lens.indexPattern.sortField.invalid": "Champ non valide. Vérifiez votre modèle d'indexation ou choisissez un autre champ.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2395df6d2d901..9d1ec062fe1b3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -611,7 +611,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name}の希少な値", "xpack.lens.indexPattern.records": "記録", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "サブ関数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "フィールドを追加するか、{groupLabel}までドラッグアンドドロップします", "xpack.lens.indexPattern.removeColumnLabel": "「{groupLabel}」から構成を削除", "xpack.lens.indexPattern.removeFieldLabel": "データビューフィールドを削除", "xpack.lens.indexPattern.sortField.invalid": "無効なフィールドです。データビューを確認するか、別のフィールドを選択してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6d4465ae16487..b055d663f9e69 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -617,7 +617,6 @@ "xpack.lens.indexPattern.rareTermsOf": "{name} 的稀有值", "xpack.lens.indexPattern.records": "记录", "xpack.lens.indexPattern.referenceFunctionPlaceholder": "子函数", - "xpack.lens.indexPattern.removeColumnAriaLabel": "将字段添加或拖放到 {groupLabel}", "xpack.lens.indexPattern.removeColumnLabel": "从“{groupLabel}”中删除配置", "xpack.lens.indexPattern.removeFieldLabel": "移除数据视图字段", "xpack.lens.indexPattern.sortField.invalid": "字段无效。检查数据视图或选取其他字段。", From 0b4282e1f5be29f44eab61340d947acaec2326b3 Mon Sep 17 00:00:00 2001 From: Karl Godard Date: Wed, 23 Mar 2022 14:20:26 -0700 Subject: [PATCH 098/132] Fix for process event pagination in session view (#128421) Co-authored-by: mitodrummer --- .../session_view/public/components/session_view/hooks.ts | 4 ++-- .../session_view/server/routes/process_events_route.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index bf8796336602d..e48b3a335dbd3 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -58,7 +58,7 @@ export const useFetchSessionViewProcessEvents = ( getNextPageParam: (lastPage) => { if (lastPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { - cursor: lastPage.events[lastPage.events.length - 1]['@timestamp'], + cursor: lastPage.events[lastPage.events.length - 1].process.start, forward: true, }; } @@ -66,7 +66,7 @@ export const useFetchSessionViewProcessEvents = ( getPreviousPageParam: (firstPage) => { if (jumpToEvent && firstPage.events.length === PROCESS_EVENTS_PER_PAGE) { return { - cursor: firstPage.events[0]['@timestamp'], + cursor: firstPage.events[0].process.start, forward: false, }; } diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 7be1885c70ab1..0dc864c51a07d 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -57,7 +57,7 @@ export const doSearch = async ( { 'process.start': forward ? 'asc' : 'desc' }, { '@timestamp': forward ? 'asc' : 'desc' }, ], - search_after: cursor ? [cursor] : undefined, + search_after: cursor ? [cursor, cursor] : undefined, }, }); From 82d4cd56dc6ba7fc0d43bb1c046f225f7c39636b Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Wed, 23 Mar 2022 18:28:39 -0400 Subject: [PATCH 099/132] [Dashboard][Controls] Add Control Group Search Settings (#128090) * Added ability to toggle hierarchical chaining, control validation, and query bar sync to the Control Group --- .../control_group/control_group_constants.ts | 26 + .../controls/common/control_group/types.ts | 3 + src/plugins/controls/common/index.ts | 1 + .../public/__stories__/controls.stories.tsx | 1 + .../control_group/control_group_strings.ts | 79 ++- .../editor/control_group_editor.tsx | 337 ++++++++--- .../control_group/editor/create_control.tsx | 2 +- .../editor/edit_control_group.tsx | 46 +- .../control_group/editor/editor_constants.ts | 8 +- .../control_group_chaining_system.ts | 80 +++ .../embeddable/control_group_container.tsx | 75 +-- .../control_group_container_factory.ts | 10 +- .../options_list/options_list_embeddable.tsx | 8 +- .../dashboard_container_persistable_state.ts | 7 +- .../embeddable/dashboard_control_group.ts | 61 +- .../common/saved_dashboard_references.ts | 2 - src/plugins/dashboard/common/types.ts | 20 +- .../dashboard_container_factory.tsx | 3 +- .../lib/dashboard_control_group.ts | 38 +- .../state/dashboard_state_slice.ts | 4 +- src/plugins/dashboard/public/types.ts | 11 +- .../server/saved_objects/dashboard.ts | 2 + src/plugins/embeddable/public/index.ts | 1 + .../embeddable/public/lib/containers/index.ts | 8 +- .../dashboard_controls_integration.ts | 566 ------------------ test/functional/apps/dashboard/index.ts | 1 - .../controls/control_group_chaining.ts | 146 +++++ .../controls/control_group_settings.ts | 103 ++++ .../controls/controls_callout.ts | 63 ++ .../apps/dashboard_elements/controls/index.ts | 54 ++ .../controls/options_list.ts | 369 ++++++++++++ .../apps/dashboard_elements/index.ts | 1 + .../page_objects/dashboard_page_controls.ts | 87 +++ .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 35 files changed, 1397 insertions(+), 834 deletions(-) create mode 100644 src/plugins/controls/common/control_group/control_group_constants.ts create mode 100644 src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts delete mode 100644 test/functional/apps/dashboard/dashboard_controls_integration.ts create mode 100644 test/functional/apps/dashboard_elements/controls/control_group_chaining.ts create mode 100644 test/functional/apps/dashboard_elements/controls/control_group_settings.ts create mode 100644 test/functional/apps/dashboard_elements/controls/controls_callout.ts create mode 100644 test/functional/apps/dashboard_elements/controls/index.ts create mode 100644 test/functional/apps/dashboard_elements/controls/options_list.ts diff --git a/src/plugins/controls/common/control_group/control_group_constants.ts b/src/plugins/controls/common/control_group/control_group_constants.ts new file mode 100644 index 0000000000000..467394614e12c --- /dev/null +++ b/src/plugins/controls/common/control_group/control_group_constants.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ControlGroupInput } from '..'; +import { ControlStyle, ControlWidth } from '../types'; + +export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; +export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; + +export const getDefaultControlGroupInput = (): Omit => ({ + panels: {}, + defaultControlWidth: DEFAULT_CONTROL_WIDTH, + controlStyle: DEFAULT_CONTROL_STYLE, + chainingSystem: 'HIERARCHICAL', + ignoreParentSettings: { + ignoreFilters: false, + ignoreQuery: false, + ignoreTimerange: false, + ignoreValidations: false, + }, +}); diff --git a/src/plugins/controls/common/control_group/types.ts b/src/plugins/controls/common/control_group/types.ts index 4e1bddc08143f..988109d237cdc 100644 --- a/src/plugins/controls/common/control_group/types.ts +++ b/src/plugins/controls/common/control_group/types.ts @@ -17,11 +17,14 @@ export interface ControlPanelState i18n.translate('controls.controlGroup.manageControl.titleInputTitle', { - defaultMessage: 'Title', + defaultMessage: 'Label', }), getControlTypeTitle: () => i18n.translate('controls.controlGroup.manageControl.controlTypesTitle', { @@ -82,10 +82,6 @@ export const ControlGroupStrings = { i18n.translate('controls.controlGroup.management.defaultWidthTitle', { defaultMessage: 'Default size', }), - getLayoutTitle: () => - i18n.translate('controls.controlGroup.management.layoutTitle', { - defaultMessage: 'Layout', - }), getDeleteButtonTitle: () => i18n.translate('controls.controlGroup.management.delete', { defaultMessage: 'Delete control', @@ -120,18 +116,22 @@ export const ControlGroupStrings = { defaultMessage: 'Large', }), }, - controlStyle: { - getDesignSwitchLegend: () => - i18n.translate('controls.controlGroup.management.layout.designSwitchLegend', { - defaultMessage: 'Switch control designs', + labelPosition: { + getLabelPositionTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.title', { + defaultMessage: 'Label position', + }), + getLabelPositionLegend: () => + i18n.translate('controls.controlGroup.management.labelPosition.designSwitchLegend', { + defaultMessage: 'Switch label position between inline and above', }), - getSingleLineTitle: () => - i18n.translate('controls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line', + getInlineTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.inline', { + defaultMessage: 'Inline', }), - getTwoLineTitle: () => - i18n.translate('controls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Double line', + getAboveTitle: () => + i18n.translate('controls.controlGroup.management.labelPosition.above', { + defaultMessage: 'Above', }), }, deleteControls: { @@ -192,6 +192,55 @@ export const ControlGroupStrings = { defaultMessage: 'Cancel', }), }, + validateSelections: { + getValidateSelectionsTitle: () => + i18n.translate('controls.controlGroup.management.validate.title', { + defaultMessage: 'Validate user selections', + }), + getValidateSelectionsSubTitle: () => + i18n.translate('controls.controlGroup.management.validate.subtitle', { + defaultMessage: + 'Automatically ignore any control selection that would result in no data.', + }), + }, + controlChaining: { + getHierarchyTitle: () => + i18n.translate('controls.controlGroup.management.hierarchy.title', { + defaultMessage: 'Chain controls', + }), + getHierarchySubTitle: () => + i18n.translate('controls.controlGroup.management.hierarchy.subtitle', { + defaultMessage: + 'Selections in one control narrow down available options in the next. Controls are chained from left to right.', + }), + }, + querySync: { + getQuerySettingsTitle: () => + i18n.translate('controls.controlGroup.management.query.searchSettingsTitle', { + defaultMessage: 'Sync with query bar', + }), + getQuerySettingsSubtitle: () => + i18n.translate('controls.controlGroup.management.query.useAllSearchSettingsTitle', { + defaultMessage: + 'Keeps the control group in sync with the query bar by applying time range, filter pills, and queries from the query bar', + }), + getAdvancedSettingsTitle: () => + i18n.translate('controls.controlGroup.management.query.advancedSettings', { + defaultMessage: 'Advanced', + }), + getIgnoreTimerangeTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreTimerange', { + defaultMessage: 'Ignore timerange', + }), + getIgnoreQueryTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreQuery', { + defaultMessage: 'Ignore query bar', + }), + getIgnoreFilterPillsTitle: () => + i18n.translate('controls.controlGroup.management.query.ignoreFilterPills', { + defaultMessage: 'Ignore filter pills', + }), + }, }, floatingActions: { getEditButtonTitle: () => diff --git a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx index 82eb4f4c2eb09..95e2066541b5f 100644 --- a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx @@ -14,7 +14,9 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import { omit } from 'lodash'; +import fastIsEqual from 'fast-deep-equal'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiFlyoutHeader, EuiButtonGroup, @@ -28,38 +30,101 @@ import { EuiButtonEmpty, EuiSpacer, EuiCheckbox, + EuiForm, + EuiAccordion, + useGeneratedHtmlId, + EuiSwitch, + EuiText, + EuiHorizontalRule, } from '@elastic/eui'; +import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlStyle, ControlWidth } from '../../types'; -import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants'; +import { ParentIgnoreSettings } from '../..'; +import { ControlsPanels } from '../types'; +import { ControlGroupInput } from '..'; +import { + DEFAULT_CONTROL_WIDTH, + getDefaultControlGroupInput, +} from '../../../common/control_group/control_group_constants'; interface EditControlGroupProps { - width: ControlWidth; - controlStyle: ControlStyle; - setAllWidths: boolean; + initialInput: ControlGroupInput; controlCount: number; - updateControlStyle: (controlStyle: ControlStyle) => void; - updateWidth: (newWidth: ControlWidth) => void; - updateAllControlWidths: (newWidth: ControlWidth) => void; - onCancel: () => void; + updateInput: (input: Partial) => void; + onDeleteAll: () => void; onClose: () => void; } +type EditorControlGroupInput = ControlGroupInput & + Required>; + +const editorControlGroupInputIsEqual = (a: ControlGroupInput, b: ControlGroupInput) => + fastIsEqual(a, b); + export const ControlGroupEditor = ({ - width, - controlStyle, - setAllWidths, controlCount, - updateControlStyle, - updateWidth, - updateAllControlWidths, - onCancel, + initialInput, + updateInput, + onDeleteAll, onClose, }: EditControlGroupProps) => { - const [currentControlStyle, setCurrentControlStyle] = useState(controlStyle); - const [currentWidth, setCurrentWidth] = useState(width); - const [applyToAll, setApplyToAll] = useState(setAllWidths); + const [resetAllWidths, setResetAllWidths] = useState(false); + const advancedSettingsAccordionId = useGeneratedHtmlId({ prefix: 'advancedSettingsAccordion' }); + + const [controlGroupEditorState, setControlGroupEditorState] = useState({ + defaultControlWidth: DEFAULT_CONTROL_WIDTH, + ...getDefaultControlGroupInput(), + ...initialInput, + }); + + const updateControlGroupEditorSetting = useCallback( + (newSettings: Partial) => { + setControlGroupEditorState({ + ...controlGroupEditorState, + ...newSettings, + }); + }, + [controlGroupEditorState] + ); + + const updateIgnoreSetting = useCallback( + (newSettings: Partial) => { + setControlGroupEditorState({ + ...controlGroupEditorState, + ignoreParentSettings: { + ...(controlGroupEditorState.ignoreParentSettings ?? {}), + ...newSettings, + }, + }); + }, + [controlGroupEditorState] + ); + + const fullQuerySyncActive = useMemo( + () => + !Object.values(omit(controlGroupEditorState.ignoreParentSettings, 'ignoreValidations')).some( + Boolean + ), + [controlGroupEditorState] + ); + + const applyChangesToInput = useCallback(() => { + const inputToApply = { ...controlGroupEditorState }; + if (resetAllWidths) { + const newPanels = {} as ControlsPanels; + Object.entries(initialInput.panels).forEach( + ([id, panel]) => + (newPanels[id] = { + ...panel, + width: inputToApply.defaultControlWidth, + }) + ); + inputToApply.panels = newPanels; + } + if (!editorControlGroupInputIsEqual(inputToApply, initialInput)) updateInput(inputToApply); + }, [controlGroupEditorState, resetAllWidths, initialInput, updateInput]); return ( <> @@ -69,57 +134,183 @@ export const ControlGroupEditor = ({ - - { - setCurrentControlStyle(newControlStyle as ControlStyle); - }} - /> - - - - { - setCurrentWidth(newWidth as ControlWidth); - }} - /> - - {controlCount > 0 ? ( - <> - - { - setApplyToAll(e.target.checked); + + + { + // The UI copy calls this setting labelPosition, but to avoid an unnecessary migration it will be left as controlStyle in the state. + updateControlGroupEditorSetting({ controlStyle: newControlStyle as ControlStyle }); }} /> - - - {ControlGroupStrings.management.getDeleteAllButtonTitle()} - - - ) : null} + + + + <> + { + updateControlGroupEditorSetting({ + defaultControlWidth: newWidth as ControlWidth, + }); + }} + /> + {controlCount > 0 && ( + <> + + { + setResetAllWidths(e.target.checked); + }} + /> + + )} + + + + + + + { + const newSetting = !e.target.checked; + updateIgnoreSetting({ + ignoreFilters: newSetting, + ignoreTimerange: newSetting, + ignoreQuery: newSetting, + }); + }} + /> + + + +

{ControlGroupStrings.management.querySync.getQuerySettingsTitle()}

+
+ +

{ControlGroupStrings.management.querySync.getQuerySettingsSubtitle()}

+
+ + + + + updateIgnoreSetting({ ignoreTimerange: e.target.checked })} + /> + + + updateIgnoreSetting({ ignoreQuery: e.target.checked })} + /> + + + updateIgnoreSetting({ ignoreFilters: e.target.checked })} + /> + + +
+
+ + + + + updateIgnoreSetting({ ignoreValidations: !e.target.checked })} + /> + + + +

+ {ControlGroupStrings.management.validateSelections.getValidateSelectionsTitle()} +

+
+ +

+ {ControlGroupStrings.management.validateSelections.getValidateSelectionsSubTitle()} +

+
+
+
+ + + + + + updateControlGroupEditorSetting({ + chainingSystem: e.target.checked ? 'HIERARCHICAL' : 'NONE', + }) + } + /> + + + +

{ControlGroupStrings.management.controlChaining.getHierarchyTitle()}

+
+ +

{ControlGroupStrings.management.controlChaining.getHierarchySubTitle()}

+
+
+
+ {controlCount > 0 && ( + <> + + + + {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + )} +
@@ -141,15 +332,7 @@ export const ControlGroupEditor = ({ color="primary" data-test-subj="control-group-editor-save" onClick={() => { - if (currentControlStyle && currentControlStyle !== controlStyle) { - updateControlStyle(currentControlStyle); - } - if (currentWidth && currentWidth !== width) { - updateWidth(currentWidth); - } - if (applyToAll) { - updateAllControlWidths(currentWidth); - } + applyChangesToInput(); onClose(); }} > diff --git a/src/plugins/controls/public/control_group/editor/create_control.tsx b/src/plugins/controls/public/control_group/editor/create_control.tsx index 218024433802b..005341359a8a9 100644 --- a/src/plugins/controls/public/control_group/editor/create_control.tsx +++ b/src/plugins/controls/public/control_group/editor/create_control.tsx @@ -12,10 +12,10 @@ import React from 'react'; import { pluginServices } from '../../services'; import { ControlEditor } from './control_editor'; import { OverlayRef } from '../../../../../core/public'; -import { DEFAULT_CONTROL_WIDTH } from './editor_constants'; import { ControlGroupStrings } from '../control_group_strings'; import { ControlWidth, ControlInput, IEditableControlFactory } from '../../types'; import { toMountPoint } from '../../../../kibana_react/public'; +import { DEFAULT_CONTROL_WIDTH } from '../../../common/control_group/control_group_constants'; export type CreateControlButtonTypes = 'toolbar' | 'callout'; export interface CreateControlButtonProps { diff --git a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx index 5595e5be24b04..f21d5d550f1a3 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control_group.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control_group.tsx @@ -9,34 +9,20 @@ import React from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_STYLE } from './editor_constants'; -import { ControlsPanels } from '../types'; -import { pluginServices } from '../../services'; -import { ControlStyle, ControlWidth } from '../../types'; -import { ControlGroupStrings } from '../control_group_strings'; import { toMountPoint } from '../../../../kibana_react/public'; -import { OverlayRef } from '../../../../../core/public'; +import { ControlGroupStrings } from '../control_group_strings'; import { ControlGroupEditor } from './control_group_editor'; +import { OverlayRef } from '../../../../../core/public'; +import { pluginServices } from '../../services'; +import { ControlGroupContainer } from '..'; export interface EditControlGroupButtonProps { - controlStyle: ControlStyle; - panels?: ControlsPanels; - defaultControlWidth?: ControlWidth; - setControlStyle: (setControlStyle: ControlStyle) => void; - setDefaultControlWidth: (defaultControlWidth: ControlWidth) => void; - setAllControlWidths: (defaultControlWidth: ControlWidth) => void; - removeEmbeddable?: (panelId: string) => void; + controlGroupContainer: ControlGroupContainer; closePopover: () => void; } export const EditControlGroup = ({ - panels, - defaultControlWidth, - controlStyle, - setControlStyle, - setDefaultControlWidth, - setAllControlWidths, - removeEmbeddable, + controlGroupContainer, closePopover, }: EditControlGroupButtonProps) => { const { overlays } = pluginServices.getServices(); @@ -45,15 +31,17 @@ export const EditControlGroup = ({ const editControlGroup = () => { const PresentationUtilProvider = pluginServices.getContextProvider(); - const onCancel = (ref: OverlayRef) => { - if (!removeEmbeddable || !panels) return; + const onDeleteAll = (ref: OverlayRef) => { openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(), buttonColor: 'danger', }).then((confirmed) => { - if (confirmed) Object.keys(panels).forEach((panelId) => removeEmbeddable(panelId)); + if (confirmed) + Object.keys(controlGroupContainer.getInput().panels).forEach((panelId) => + controlGroupContainer.removeEmbeddable(panelId) + ); ref.close(); }); }; @@ -62,14 +50,10 @@ export const EditControlGroup = ({ toMountPoint( onCancel(flyoutInstance)} + initialInput={controlGroupContainer.getInput()} + updateInput={(changes) => controlGroupContainer.updateInput(changes)} + controlCount={Object.keys(controlGroupContainer.getInput().panels ?? {}).length} + onDeleteAll={() => onDeleteAll(flyoutInstance)} onClose={() => flyoutInstance.close()} /> diff --git a/src/plugins/controls/public/control_group/editor/editor_constants.ts b/src/plugins/controls/public/control_group/editor/editor_constants.ts index 4c3c4c1af7938..5acad90cfbf8f 100644 --- a/src/plugins/controls/public/control_group/editor/editor_constants.ts +++ b/src/plugins/controls/public/control_group/editor/editor_constants.ts @@ -6,12 +6,8 @@ * Side Public License, v 1. */ -import { ControlStyle, ControlWidth } from '../../types'; import { ControlGroupStrings } from '../control_group_strings'; -export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto'; -export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine'; - export const CONTROL_WIDTH_OPTIONS = [ { id: `auto`, @@ -39,11 +35,11 @@ export const CONTROL_LAYOUT_OPTIONS = [ { id: `oneLine`, 'data-test-subj': 'control-editor-layout-oneLine', - label: ControlGroupStrings.management.controlStyle.getSingleLineTitle(), + label: ControlGroupStrings.management.labelPosition.getInlineTitle(), }, { id: `twoLine`, 'data-test-subj': 'control-editor-layout-twoLine', - label: ControlGroupStrings.management.controlStyle.getTwoLineTitle(), + label: ControlGroupStrings.management.labelPosition.getAboveTitle(), }, ]; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts new file mode 100644 index 0000000000000..f0acf9ca811e8 --- /dev/null +++ b/src/plugins/controls/public/control_group/embeddable/control_group_chaining_system.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Filter } from '@kbn/es-query'; + +import { Subject } from 'rxjs'; +import { ControlEmbeddable } from '../../types'; +import { ChildEmbeddableOrderCache } from './control_group_container'; +import { EmbeddableContainerSettings, isErrorEmbeddable } from '../../../../embeddable/public'; +import { ControlGroupChainingSystem, ControlGroupInput } from '../../../common/control_group/types'; + +interface GetPrecedingFiltersProps { + id: string; + childOrder: ChildEmbeddableOrderCache; + getChild: (id: string) => ControlEmbeddable; +} + +interface OnChildChangedProps { + childOutputChangedId: string; + recalculateFilters$: Subject; + childOrder: ChildEmbeddableOrderCache; + getChild: (id: string) => ControlEmbeddable; +} + +interface ChainingSystem { + getContainerSettings: ( + initialInput: ControlGroupInput + ) => EmbeddableContainerSettings | undefined; + getPrecedingFilters: (props: GetPrecedingFiltersProps) => Filter[] | undefined; + onChildChange: (props: OnChildChangedProps) => void; +} + +export const ControlGroupChainingSystems: { + [key in ControlGroupChainingSystem]: ChainingSystem; +} = { + HIERARCHICAL: { + getContainerSettings: (initialInput) => ({ + childIdInitializeOrder: Object.values(initialInput.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel) => panel.explicitInput.id), + initializeSequentially: true, + }), + getPrecedingFilters: ({ id, childOrder, getChild }) => { + let filters: Filter[] = []; + const order = childOrder.IdsToOrder?.[id]; + if (!order || order === 0) return filters; + for (let i = 0; i < order; i++) { + const embeddable = getChild(childOrder.idsInOrder[i]); + if (!embeddable || isErrorEmbeddable(embeddable)) return filters; + filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; + } + return filters; + }, + onChildChange: ({ childOutputChangedId, childOrder, recalculateFilters$, getChild }) => { + if (childOutputChangedId === childOrder.lastChildId) { + // the last control's output has updated, recalculate filters + recalculateFilters$.next(); + return; + } + + // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent + const nextOrder = childOrder.IdsToOrder[childOutputChangedId] + 1; + if (nextOrder >= childOrder.idsInOrder.length) return; + setTimeout( + () => getChild(childOrder.idsInOrder[nextOrder]).refreshInputFromParent(), + 1 // run on next tick + ); + }, + }, + NONE: { + getContainerSettings: () => undefined, + getPrecedingFilters: () => undefined, + onChildChange: ({ recalculateFilters$ }) => recalculateFilters$.next(), + }, +}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 4bae605e0ef49..e73aff832ab1e 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -40,18 +40,18 @@ import { pluginServices } from '../../services'; import { DataView } from '../../../../data_views/public'; import { ControlGroupStrings } from '../control_group_strings'; import { EditControlGroup } from '../editor/edit_control_group'; -import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; +import { Container, EmbeddableFactory } from '../../../../embeddable/public'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; +import { ControlGroupChainingSystems } from './control_group_chaining_system'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; -import { Container, EmbeddableFactory, isErrorEmbeddable } from '../../../../embeddable/public'; const ControlGroupReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren >(LazyReduxEmbeddableWrapper); -interface ChildEmbeddableOrderCache { +export interface ChildEmbeddableOrderCache { IdsToOrder: { [key: string]: number }; idsInOrder: string[]; lastChildId: string; @@ -104,22 +104,7 @@ export class ControlGroupContainer extends Container< }; private getEditControlGroupButton = (closePopover: () => void) => { - return ( - this.updateInput({ controlStyle })} - setDefaultControlWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })} - setAllControlWidths={(defaultControlWidth) => { - Object.keys(this.getInput().panels).forEach( - (panelId) => (this.getInput().panels[panelId].width = defaultControlWidth) - ); - }} - removeEmbeddable={(id) => this.removeEmbeddable(id)} - closePopover={closePopover} - /> - ); + return ; }; /** @@ -154,12 +139,7 @@ export class ControlGroupContainer extends Container< { embeddableLoaded: {} }, pluginServices.getServices().controls.getControlFactory, parent, - { - childIdInitializeOrder: Object.values(initialInput.panels) - .sort((a, b) => (a.order > b.order ? 1 : -1)) - .map((panel) => panel.explicitInput.id), - initializeSequentially: true, - } + ControlGroupChainingSystems[initialInput.chainingSystem]?.getContainerSettings(initialInput) ); this.recalculateFilters$ = new Subject(); @@ -226,20 +206,12 @@ export class ControlGroupContainer extends Container< .pipe(anyChildChangePipe) .subscribe((childOutputChangedId) => { this.recalculateDataViews(); - if (childOutputChangedId === this.childOrderCache.lastChildId) { - // the last control's output has updated, recalculate filters - this.recalculateFilters$.next(); - return; - } - - // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent - const nextOrder = this.childOrderCache.IdsToOrder[childOutputChangedId] + 1; - if (nextOrder >= Object.keys(this.children).length) return; - setTimeout( - () => - this.getChild(this.childOrderCache.idsInOrder[nextOrder]).refreshInputFromParent(), - 1 // run on next tick - ); + ControlGroupChainingSystems[this.getInput().chainingSystem].onChildChange({ + childOutputChangedId, + childOrder: this.childOrderCache, + getChild: (id) => this.getChild(id), + recalculateFilters$: this.recalculateFilters$, + }); }) ); @@ -251,18 +223,6 @@ export class ControlGroupContainer extends Container< ); }; - private getPrecedingFilters = (id: string) => { - let filters: Filter[] = []; - const order = this.childOrderCache.IdsToOrder?.[id]; - if (!order || order === 0) return filters; - for (let i = 0; i < order; i++) { - const embeddable = this.getChild(this.childOrderCache.idsInOrder[i]); - if (!embeddable || isErrorEmbeddable(embeddable)) return filters; - filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; - } - return filters; - }; - private getEmbeddableOrderCache = (): ChildEmbeddableOrderCache => { const panels = this.getInput().panels; const IdsToOrder: { [key: string]: number } = {}; @@ -314,20 +274,25 @@ export class ControlGroupContainer extends Container< } return { order: nextOrder, - width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH, + width: this.getInput().defaultControlWidth, ...panelState, } as ControlPanelState; } protected getInheritedInput(id: string): ControlInput { - const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); + const { filters, query, ignoreParentSettings, timeRange, chainingSystem } = this.getInput(); - const precedingFilters = this.getPrecedingFilters(id); + const precedingFilters = ControlGroupChainingSystems[chainingSystem].getPrecedingFilters({ + id, + childOrder: this.childOrderCache, + getChild: (getChildId: string) => this.getChild(getChildId), + }); const allFilters = [ ...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []), - ...precedingFilters, + ...(precedingFilters ?? []), ]; return { + ignoreParentSettings, filters: allFilters, query: ignoreParentSettings?.ignoreQuery ? undefined : query, timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts index d2e057a613070..11bf0bbc4aa7f 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container_factory.ts @@ -23,6 +23,7 @@ import { createControlGroupExtract, createControlGroupInject, } from '../../../common/control_group/control_group_persistable_state'; +import { getDefaultControlGroupInput } from '../../../common/control_group/control_group_constants'; export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition { public readonly isContainerType = true; @@ -42,14 +43,7 @@ export class ControlGroupContainerFactory implements EmbeddableFactoryDefinition }; public getDefaultInput(): Partial { - return { - panels: {}, - ignoreParentSettings: { - ignoreFilters: false, - ignoreQuery: false, - ignoreTimerange: false, - }, - }; + return getDefaultControlGroupInput(); } public create = async (initialInput: ControlGroupInput, parent?: Container) => { diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index 2575d5724535f..0f5a7524db02b 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -56,6 +56,7 @@ interface OptionsListDataFetchProps { search?: string; fieldName: string; dataViewId: string; + validate?: boolean; query?: ControlInput['query']; filters?: ControlInput['filters']; } @@ -115,6 +116,7 @@ export class OptionsListEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ + validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, @@ -218,12 +220,12 @@ export class OptionsListEmbeddable extends Embeddable `${state.explicitInput.id}:`; const controlGroupReferencePrefix = 'controlGroup_'; +const controlGroupId = 'dashboard_control_group'; export const createInject = ( persistableStateService: EmbeddablePersistableStateService @@ -89,11 +90,12 @@ export const createInject = ( { ...workingState.controlGroupInput, type: CONTROL_GROUP_TYPE, + id: controlGroupId, }, controlGroupReferences ); workingState.controlGroupInput = - injectedControlGroupState as DashboardContainerControlGroupInput; + injectedControlGroupState as unknown as DashboardContainerControlGroupInput; } return workingState as EmbeddableStateWithType; @@ -155,9 +157,10 @@ export const createExtract = ( persistableStateService.extract({ ...workingState.controlGroupInput, type: CONTROL_GROUP_TYPE, + id: controlGroupId, }); workingState.controlGroupInput = - extractedControlGroupState as DashboardContainerControlGroupInput; + extractedControlGroupState as unknown as DashboardContainerControlGroupInput; const prefixedControlGroupReferences = controlGroupReferences.map((reference) => ({ ...reference, name: `${controlGroupReferencePrefix}${reference.name}`, diff --git a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts index 95cb6c38ee9d7..ce6a1f358661e 100644 --- a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts +++ b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts @@ -7,57 +7,68 @@ */ import { SerializableRecord } from '@kbn/utility-types'; -import { ControlGroupInput } from '../../../controls/common'; -import { ControlStyle } from '../../../controls/common/types'; +import { ControlGroupInput, getDefaultControlGroupInput } from '../../../controls/common'; import { RawControlGroupAttributes } from '../types'; +export const getDefaultDashboardControlGroupInput = getDefaultControlGroupInput; + export const controlGroupInputToRawAttributes = ( controlGroupInput: Omit -): Omit => { +): RawControlGroupAttributes => { return { controlStyle: controlGroupInput.controlStyle, + chainingSystem: controlGroupInput.chainingSystem, panelsJSON: JSON.stringify(controlGroupInput.panels), + ignoreParentSettingsJSON: JSON.stringify(controlGroupInput.ignoreParentSettings), }; }; -export const getDefaultDashboardControlGroupInput = () => ({ - controlStyle: 'oneLine' as ControlGroupInput['controlStyle'], - panels: {}, -}); +const safeJSONParse = (jsonString?: string): OutType | undefined => { + if (!jsonString && typeof jsonString !== 'string') return; + try { + return JSON.parse(jsonString) as OutType; + } catch { + return; + } +}; export const rawAttributesToControlGroupInput = ( - rawControlGroupAttributes: Omit + rawControlGroupAttributes: RawControlGroupAttributes ): Omit | undefined => { - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + const defaultControlGroupInput = getDefaultControlGroupInput(); + const { chainingSystem, controlStyle, ignoreParentSettingsJSON, panelsJSON } = + rawControlGroupAttributes; + const panels = safeJSONParse(panelsJSON); + const ignoreParentSettings = + safeJSONParse(ignoreParentSettingsJSON); return { - controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: - rawControlGroupAttributes?.panelsJSON && - typeof rawControlGroupAttributes?.panelsJSON === 'string' - ? JSON.parse(rawControlGroupAttributes?.panelsJSON) - : defaultControlGroupInput.panels, + ...defaultControlGroupInput, + ...(chainingSystem ? { chainingSystem } : {}), + ...(controlStyle ? { controlStyle } : {}), + ...(ignoreParentSettings ? { ignoreParentSettings } : {}), + ...(panels ? { panels } : {}), }; }; export const rawAttributesToSerializable = ( rawControlGroupAttributes: Omit ): SerializableRecord => { - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + const defaultControlGroupInput = getDefaultControlGroupInput(); return { + chainingSystem: rawControlGroupAttributes?.chainingSystem, controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: - rawControlGroupAttributes?.panelsJSON && - typeof rawControlGroupAttributes?.panelsJSON === 'string' - ? (JSON.parse(rawControlGroupAttributes?.panelsJSON) as SerializableRecord) - : defaultControlGroupInput.panels, + ignoreParentSettings: safeJSONParse(rawControlGroupAttributes?.ignoreParentSettingsJSON) ?? {}, + panels: safeJSONParse(rawControlGroupAttributes?.panelsJSON) ?? {}, }; }; export const serializableToRawAttributes = ( - controlGroupInput: SerializableRecord -): Omit => { + serializable: SerializableRecord +): Omit => { return { - controlStyle: controlGroupInput.controlStyle as ControlStyle, - panelsJSON: JSON.stringify(controlGroupInput.panels), + controlStyle: serializable.controlStyle as RawControlGroupAttributes['controlStyle'], + chainingSystem: serializable.chainingSystem as RawControlGroupAttributes['chainingSystem'], + ignoreParentSettingsJSON: JSON.stringify(serializable.ignoreParentSettings), + panelsJSON: JSON.stringify(serializable.panels), }; }; diff --git a/src/plugins/dashboard/common/saved_dashboard_references.ts b/src/plugins/dashboard/common/saved_dashboard_references.ts index 346190e4fef91..fe549a4c13a1e 100644 --- a/src/plugins/dashboard/common/saved_dashboard_references.ts +++ b/src/plugins/dashboard/common/saved_dashboard_references.ts @@ -19,7 +19,6 @@ import { convertSavedDashboardPanelToPanelState, } from './embeddable/embeddable_saved_object_converters'; import { SavedDashboardPanel } from './types'; -import { CONTROL_GROUP_TYPE } from '../../controls/common'; export interface ExtractDeps { embeddablePersistableStateService: EmbeddablePersistableStateService; @@ -51,7 +50,6 @@ function dashboardAttributesToState(attributes: SavedObjectAttributes): { if (controlGroupPanels && typeof controlGroupPanels === 'object') { controlGroupInput = { ...rawControlGroupInput, - type: CONTROL_GROUP_TYPE, panels: controlGroupPanels, }; } diff --git a/src/plugins/dashboard/common/types.ts b/src/plugins/dashboard/common/types.ts index 29e3d48d7f0d5..49caa41251211 100644 --- a/src/plugins/dashboard/common/types.ts +++ b/src/plugins/dashboard/common/types.ts @@ -98,17 +98,19 @@ export type SavedDashboardPanel730ToLatest = Pick< // Making this interface because so much of the Container type from embeddable is tied up in public // Once that is all available from common, we should be able to move the dashboard_container type to our common as well -export interface DashboardContainerControlGroupInput extends EmbeddableStateWithType { - panels: ControlGroupInput['panels']; - controlStyle: ControlGroupInput['controlStyle']; - id: string; -} +// dashboard only persists part of the Control Group Input +export type DashboardContainerControlGroupInput = Pick< + ControlGroupInput, + 'panels' | 'chainingSystem' | 'controlStyle' | 'ignoreParentSettings' +>; -export interface RawControlGroupAttributes { - controlStyle: ControlGroupInput['controlStyle']; +export type RawControlGroupAttributes = Omit< + DashboardContainerControlGroupInput, + 'panels' | 'ignoreParentSettings' +> & { + ignoreParentSettingsJSON: string; panelsJSON: string; - id: string; -} +}; export interface DashboardContainerStateWithType extends EmbeddableStateWithType { panels: { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 2595824e8b02e..564080831607c 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { EmbeddablePersistableStateService } from 'src/plugins/embeddable/common'; +import { identity, pickBy } from 'lodash'; import { DashboardContainerInput } from '../..'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; import type { DashboardContainer, DashboardContainerServices } from './dashboard_container'; @@ -90,7 +91,7 @@ export class DashboardContainerFactoryDefinition const controlGroup = await controlsGroupFactory?.create({ id: `control_group_${id ?? 'new_dashboard'}`, ...getDefaultDashboardControlGroupInput(), - ...(controlGroupInput ?? {}), + ...pickBy(controlGroupInput, identity), // undefined keys in initialInput should not overwrite defaults timeRange, viewMode, filters, diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts index e421ec3477354..ba60af8d02aea 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -11,7 +11,8 @@ import deepEqual from 'fast-deep-equal'; import { compareFilters, COMPARE_ALL_OPTIONS, type Filter } from '@kbn/es-query'; import { distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs/operators'; -import { DashboardContainer } from '..'; +import { pick } from 'lodash'; +import { DashboardContainer, DashboardContainerControlGroupInput } from '..'; import { DashboardState } from '../../types'; import { DashboardContainerInput, DashboardSavedObject } from '../..'; import { ControlGroupContainer, ControlGroupInput } from '../../../../controls/public'; @@ -20,13 +21,6 @@ import { getDefaultDashboardControlGroupInput, rawAttributesToControlGroupInput, } from '../../../common'; - -// only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. -export interface DashboardControlGroupInput { - panels: ControlGroupInput['panels']; - controlStyle: ControlGroupInput['controlStyle']; -} - interface DiffChecks { [key: string]: (a?: unknown, b?: unknown) => boolean; } @@ -60,6 +54,8 @@ export const syncDashboardControlGroup = async ({ const controlGroupDiff: DiffChecks = { panels: deepEqual, controlStyle: deepEqual, + chainingSystem: deepEqual, + ignoreParentSettings: deepEqual, }; subscriptions.add( @@ -71,9 +67,12 @@ export const syncDashboardControlGroup = async ({ ) ) .subscribe(() => { - const { panels, controlStyle } = controlGroup.getInput(); + const { panels, controlStyle, chainingSystem, ignoreParentSettings } = + controlGroup.getInput(); if (!isControlGroupInputEqual()) { - dashboardContainer.updateInput({ controlGroupInput: { panels, controlStyle } }); + dashboardContainer.updateInput({ + controlGroupInput: { panels, controlStyle, chainingSystem, ignoreParentSettings }, + }); } }) ); @@ -154,17 +153,17 @@ export const syncDashboardControlGroup = async ({ }; export const controlGroupInputIsEqual = ( - a: DashboardControlGroupInput | undefined, - b: DashboardControlGroupInput | undefined + a: DashboardContainerControlGroupInput | undefined, + b: DashboardContainerControlGroupInput | undefined ) => { const defaultInput = getDefaultDashboardControlGroupInput(); const inputA = { - panels: a?.panels ?? defaultInput.panels, - controlStyle: a?.controlStyle ?? defaultInput.controlStyle, + ...defaultInput, + ...pick(a, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']), }; const inputB = { - panels: b?.panels ?? defaultInput.panels, - controlStyle: b?.controlStyle ?? defaultInput.controlStyle, + ...defaultInput, + ...pick(b, ['panels', 'chainingSystem', 'controlStyle', 'ignoreParentSettings']), }; if (deepEqual(inputA, inputB)) return true; return false; @@ -175,7 +174,12 @@ export const serializeControlGroupToDashboardSavedObject = ( dashboardState: DashboardState ) => { // only save to saved object if control group is not default - if (controlGroupInputIsEqual(dashboardState.controlGroupInput, {} as ControlGroupInput)) { + if ( + controlGroupInputIsEqual( + dashboardState.controlGroupInput, + getDefaultDashboardControlGroupInput() + ) + ) { dashboardSavedObject.controlGroupInput = undefined; return; } diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts index eea9edd13507f..ee403939a9e8c 100644 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts @@ -10,8 +10,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Filter, Query, TimeRange } from '../../services/data'; import { ViewMode } from '../../services/embeddable'; -import type { DashboardControlGroupInput } from '../lib/dashboard_control_group'; import { DashboardOptions, DashboardPanelMap, DashboardState } from '../../types'; +import { DashboardContainerControlGroupInput } from '../embeddable'; export const dashboardStateSlice = createSlice({ name: 'dashboardState', @@ -44,7 +44,7 @@ export const dashboardStateSlice = createSlice({ }, setControlGroupState: ( state, - action: PayloadAction + action: PayloadAction ) => { state.controlGroupInput = action.payload; }, diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index c1023f8e900bd..575124671cf2b 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -29,7 +29,11 @@ import { UrlForwardingStart } from '../../url_forwarding/public'; import { UsageCollectionSetup } from './services/usage_collection'; import { NavigationPublicPluginStart } from './services/navigation'; import { Query, RefreshInterval, TimeRange } from './services/data'; -import { DashboardPanelState, SavedDashboardPanel } from '../common/types'; +import { + DashboardContainerControlGroupInput, + DashboardPanelState, + SavedDashboardPanel, +} from '../common/types'; import { SavedObjectsTaggingApi } from './services/saved_objects_tagging_oss'; import { DataPublicPluginStart, DataViewsContract } from './services/data'; import { ContainerInput, EmbeddableInput, ViewMode } from './services/embeddable'; @@ -40,7 +44,6 @@ import type { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; import { SpacesPluginStart } from './services/spaces'; -import type { DashboardControlGroupInput } from './application/lib/dashboard_control_group'; export type { SavedDashboardPanel }; @@ -71,7 +74,7 @@ export interface DashboardState { panels: DashboardPanelMap; timeRange?: TimeRange; - controlGroupInput?: DashboardControlGroupInput; + controlGroupInput?: DashboardContainerControlGroupInput; } /** @@ -81,7 +84,7 @@ export type RawDashboardState = Omit & { panels: Saved export interface DashboardContainerInput extends ContainerInput { dashboardCapabilities?: DashboardAppCapabilities; - controlGroupInput?: DashboardControlGroupInput; + controlGroupInput?: DashboardContainerControlGroupInput; refreshConfig?: RefreshInterval; isEmbeddedExternally?: boolean; isFullScreenMode: boolean; diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 2ddbcfd9fdb74..69d0feffde27b 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -55,7 +55,9 @@ export const createDashboardSavedObjectType = ({ controlGroupInput: { properties: { controlStyle: { type: 'keyword', index: false, doc_values: false }, + chainingSystem: { type: 'keyword', index: false, doc_values: false }, panelsJSON: { type: 'text', index: false }, + ignoreParentSettingsJSON: { type: 'text', index: false }, }, }, timeFrom: { type: 'keyword', index: false, doc_values: false }, diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index f3759ffdb39e5..3aedd2c0a3b78 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -36,6 +36,7 @@ export type { EmbeddablePackageState, EmbeddableRendererProps, EmbeddableContainerContext, + EmbeddableContainerSettings, } from './lib'; export { ACTION_ADD_PANEL, diff --git a/src/plugins/embeddable/public/lib/containers/index.ts b/src/plugins/embeddable/public/lib/containers/index.ts index 041923188e175..655fd413e3bc0 100644 --- a/src/plugins/embeddable/public/lib/containers/index.ts +++ b/src/plugins/embeddable/public/lib/containers/index.ts @@ -6,6 +6,12 @@ * Side Public License, v 1. */ -export type { IContainer, PanelState, ContainerInput, ContainerOutput } from './i_container'; +export type { + IContainer, + PanelState, + ContainerInput, + ContainerOutput, + EmbeddableContainerSettings, +} from './i_container'; export { Container } from './container'; export * from './embeddable_child_panel'; diff --git a/test/functional/apps/dashboard/dashboard_controls_integration.ts b/test/functional/apps/dashboard/dashboard_controls_integration.ts deleted file mode 100644 index 2ccde5251250e..0000000000000 --- a/test/functional/apps/dashboard/dashboard_controls_integration.ts +++ /dev/null @@ -1,566 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import expect from '@kbn/expect'; - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); - const security = getService('security'); - const queryBar = getService('queryBar'); - const pieChart = getService('pieChart'); - const filterBar = getService('filterBar'); - const testSubjects = getService('testSubjects'); - const kibanaServer = getService('kibanaServer'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const find = getService('find'); - const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ - 'dashboardControls', - 'timePicker', - 'dashboard', - 'common', - 'header', - ]); - - describe('Dashboard controls integration', () => { - const clearAllControls = async () => { - const controlIds = await dashboardControls.getAllControlIds(); - for (const controlId of controlIds) { - await dashboardControls.removeExistingControl(controlId); - } - }; - - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - await kibanaServer.importExport.load( - 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' - ); - await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); - await kibanaServer.uiSettings.replace({ - defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', - }); - await common.navigateToApp('dashboard'); - await dashboardControls.enableControlsLab(); - await common.navigateToApp('dashboard'); - await dashboard.preserveCrossAppState(); - }); - - after(async () => { - await security.testUser.restoreDefaults(); - await kibanaServer.savedObjects.cleanStandardList(); - }); - - describe('Controls callout visibility', async () => { - before(async () => { - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await dashboard.saveDashboard('Test Controls Callout'); - }); - - describe('does not show the empty control callout on an empty dashboard', async () => { - it('in view mode', async () => { - await dashboard.clickCancelOutOfEditMode(); - await testSubjects.missingOrFail('controls-empty'); - }); - - it('in edit mode', async () => { - await dashboard.switchToEditMode(); - await testSubjects.missingOrFail('controls-empty'); - }); - }); - - it('show the empty control callout on a dashboard with panels', async () => { - await dashboard.switchToEditMode(); - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await testSubjects.existOrFail('controls-empty'); - }); - - it('adding control hides the empty control callout', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await testSubjects.missingOrFail('controls-empty'); - }); - - after(async () => { - await dashboard.clickCancelOutOfEditMode(); - await dashboard.gotoDashboardLandingPage(); - }); - }); - - describe('Control group settings', async () => { - before(async () => { - await dashboard.gotoDashboardLandingPage(); - await dashboard.clickNewDashboard(); - await dashboard.saveDashboard('Test Control Group Settings'); - }); - - it('adjust layout of controls', async () => { - await dashboard.switchToEditMode(); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await dashboardControls.adjustControlsLayout('twoLine'); - const controlGroupWrapper = await testSubjects.find('controls-group-wrapper'); - expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true); - }); - - describe('apply new default size', async () => { - it('to new controls only', async () => { - await dashboardControls.updateControlsSize('medium'); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'name.keyword', - }); - - const controlIds = await dashboardControls.getAllControlIds(); - const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`); - expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(false); - const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`); - expect(await secondControl.elementHasClass('controlFrameWrapper--medium')).to.be(true); - }); - - it('to all existing controls', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - width: 'large', - }); - - await dashboardControls.updateControlsSize('small', true); - const controlIds = await dashboardControls.getAllControlIds(); - for (const id of controlIds) { - const control = await find.byXPath(`//div[@data-control-id="${id}"]`); - expect(await control.elementHasClass('controlFrameWrapper--small')).to.be(true); - } - }); - }); - - describe('flyout only show settings that are relevant', async () => { - before(async () => { - await dashboard.switchToEditMode(); - }); - - it('when no controls', async () => { - await dashboardControls.deleteAllControls(); - await dashboardControls.openControlGroupSettingsFlyout(); - await testSubjects.missingOrFail('delete-all-controls-button'); - await testSubjects.missingOrFail('set-all-control-sizes-checkbox'); - }); - - it('when at least one control', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - await dashboardControls.openControlGroupSettingsFlyout(); - await testSubjects.existOrFail('delete-all-controls-button'); - await testSubjects.existOrFail('set-all-control-sizes-checkbox', { allowHidden: true }); - }); - - afterEach(async () => { - await testSubjects.click('euiFlyoutCloseButton'); - }); - - after(async () => { - await dashboardControls.deleteAllControls(); - }); - }); - - after(async () => { - await dashboard.clickCancelOutOfEditMode(); - await dashboard.gotoDashboardLandingPage(); - }); - }); - - describe('Options List Control creation and editing experience', async () => { - it('can add a new options list control from a blank state', async () => { - await dashboard.clickNewDashboard(); - await timePicker.setDefaultDataRange(); - await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - it('can add a second options list control with a non-default data view', async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - }); - expect(await dashboardControls.getControlsCount()).to.be(2); - - // data views should be properly propagated from the control group to the dashboard - expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); - }); - - it('renames an existing control', async () => { - const secondId = (await dashboardControls.getAllControlIds())[1]; - - const newTitle = 'wow! Animal sounds?'; - await dashboardControls.editExistingControl(secondId); - await dashboardControls.controlEditorSetTitle(newTitle); - await dashboardControls.controlEditorSave(); - expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); - }); - - it('can change the data view and field of an existing options list', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - await dashboardControls.editExistingControl(firstId); - - await dashboardControls.optionsListEditorSetDataView('animals-*'); - await dashboardControls.optionsListEditorSetfield('animal.keyword'); - await dashboardControls.controlEditorSave(); - - // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view - await retry.try(async () => { - await testSubjects.click('addFilter'); - const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); - await filterBar.ensureFieldEditorModalIsClosed(); - expect(indexPatternSelectExists).to.be(false); - }); - }); - - it('deletes an existing control', async () => { - const firstId = (await dashboardControls.getAllControlIds())[0]; - - await dashboardControls.removeExistingControl(firstId); - expect(await dashboardControls.getControlsCount()).to.be(1); - }); - - after(async () => { - await clearAllControls(); - }); - }); - - describe('Interactions between options list and dashboard', async () => { - let controlId: string; - before(async () => { - await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sounds', - }); - - controlId = (await dashboardControls.getAllControlIds())[0]; - }); - - describe('Apply dashboard query and filters to controls', async () => { - it('Applies dashboard query to options list control', async () => { - await queryBar.setQuery('isDog : true '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - ]); - }); - - await queryBar.setQuery(''); - await queryBar.submitQuery(); - }); - - it('Applies dashboard filters to options list control', async () => { - await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(3); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'ruff', - 'bark', - 'bow ow ow', - ]); - }); - }); - - it('Does not apply disabled dashboard filters to options list control', async () => { - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - }); - - await filterBar.toggleFilterEnabled('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - }); - - it('Negated filters apply to options control', async () => { - await filterBar.toggleFilterNegated('sound.keyword'); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'grrr', - 'meow', - 'growl', - 'grr', - ]); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - }); - }); - - describe('Selections made in control apply to dashboard', async () => { - it('Shows available options in options list', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can search options list for available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSearchForOption('meo'); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'meow', - ]); - }); - await dashboardControls.optionsListPopoverClearSearch(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can select multiple available options', async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('hiss'); - await dashboardControls.optionsListPopoverSelectOption('grr'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Selected options appear in control', async () => { - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - it('Applies options list control options to dashboard', async () => { - await retry.try(async () => { - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - }); - - it('Applies options list control options to dashboard by default on open', async () => { - await dashboard.gotoDashboardLandingPage(); - await header.waitUntilLoadingHasFinished(); - await dashboard.clickUnsavedChangesContinueEditing('New Dashboard'); - await header.waitUntilLoadingHasFinished(); - expect(await pieChart.getPieSliceCount()).to.be(2); - - const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); - expect(selectionString).to.be('hiss, grr'); - }); - - after(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - }); - - describe('Options List dashboard validation', async () => { - before(async () => { - await dashboardControls.optionsListOpenPopover(controlId); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListPopoverSelectOption('bark'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }); - - it('Can mark selections invalid with Query', async () => { - await queryBar.setQuery('isDog : false '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(4); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'meow', - 'growl', - 'grr', - 'Ignored selection', - 'bark', - ]); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - - it('can make invalid selections valid again if the parent filter changes', async () => { - await queryBar.setQuery(''); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(8); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'ruff', - 'bark', - 'grrr', - 'meow', - 'growl', - 'grr', - 'bow ow ow', - ]); - }); - - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - expect(await pieChart.getPieSliceCount()).to.be(2); - }); - - it('Can mark multiple selections invalid with Filter', async () => { - await filterBar.addFilter('sound.keyword', 'is', ['hiss']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(1); - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql([ - 'hiss', - 'Ignored selections', - 'meow', - 'bark', - ]); - }); - - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - // only valid selections are applied as filters. - expect(await pieChart.getPieSliceCount()).to.be(1); - }); - }); - - after(async () => { - await filterBar.removeAllFilters(); - await clearAllControls(); - }); - }); - - describe('Control group hierarchical chaining', async () => { - let controlIds: string[]; - - const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { - await dashboardControls.optionsListOpenPopover(controlId); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( - expectation - ); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); - }; - - before(async () => { - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'animal.keyword', - title: 'Animal', - }); - - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'name.keyword', - title: 'Animal Name', - }); - - await dashboardControls.createOptionsListControl({ - dataViewTitle: 'animals-*', - fieldName: 'sound.keyword', - title: 'Animal Sound', - }); - - controlIds = await dashboardControls.getAllControlIds(); - }); - - it('Shows all available options in first Options List control', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await retry.try(async () => { - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - }); - - it('Selecting an option in the first Options List will filter the second and third controls', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverSelectOption('cat'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - - await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); - await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); - }); - - it('Selecting an option in the second Options List will filter the third control', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[1]); - await dashboardControls.optionsListPopoverSelectOption('sylvester'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); - - await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); - }); - - it('Can select an option in the third Options List', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[2]); - await dashboardControls.optionsListPopoverSelectOption('meow'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); - }); - - it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { - await dashboardControls.optionsListOpenPopover(controlIds[0]); - await dashboardControls.optionsListPopoverClearSelections(); - await dashboardControls.optionsListPopoverSelectOption('dog'); - await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); - - await ensureAvailableOptionsEql(controlIds[1], [ - 'Fluffy', - 'Fee Fee', - 'Rover', - 'Ignored selection', - 'sylvester', - ]); - await ensureAvailableOptionsEql(controlIds[2], [ - 'ruff', - 'bark', - 'grrr', - 'bow ow ow', - 'grr', - 'Ignored selection', - 'meow', - ]); - }); - }); - }); -} diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 73a8754982e4f..c9a62447f223a 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -72,7 +72,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./full_screen_mode')); loadTestFile(require.resolve('./dashboard_filter_bar')); loadTestFile(require.resolve('./dashboard_filtering')); - loadTestFile(require.resolve('./dashboard_controls_integration')); loadTestFile(require.resolve('./panel_expand_toggle')); loadTestFile(require.resolve('./dashboard_grid')); loadTestFile(require.resolve('./view_edit')); diff --git a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts new file mode 100644 index 0000000000000..13ef3a248a583 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -0,0 +1,146 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const { dashboardControls, common, dashboard, timePicker } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + ]); + + describe('Dashboard control group hierarchical chaining', () => { + let controlIds: string[]; + + const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { + await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql(expectation); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + + // populate an initial set of controls and get their ids. + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + title: 'Animal Name', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sound', + }); + + controlIds = await dashboardControls.getAllControlIds(); + }); + + it('Shows all available options in first Options List control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + }); + + it('Selecting an option in the first Options List will filter the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); + await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); + }); + + it('Selecting an option in the second Options List will filter the third control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[1]); + await dashboardControls.optionsListPopoverSelectOption('sylvester'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); + + await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); + }); + + it('Can select an option in the third Options List', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[2]); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListPopoverSelectOption('dog'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Fee Fee', + 'Rover', + 'Ignored selection', + 'sylvester', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'ruff', + 'bark', + 'grrr', + 'bow ow ow', + 'grr', + 'Ignored selection', + 'meow', + ]); + }); + + describe('Hierarchical chaining off', async () => { + before(async () => { + await dashboardControls.updateChainingSystem('NONE'); + }); + + it('Selecting an option in the first Options List will not filter the second or third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Tiger', + 'sylvester', + 'Fee Fee', + 'Rover', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'hiss', + 'ruff', + 'bark', + 'grrr', + 'meow', + 'growl', + 'grr', + 'bow ow ow', + ]); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts new file mode 100644 index 0000000000000..ffda165443337 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -0,0 +1,103 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const { dashboardControls, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'dashboard', + 'common', + ]); + + describe('Dashboard control group settings', () => { + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await dashboard.saveDashboard('Test Control Group Settings'); + }); + + it('adjust layout of controls', async () => { + await dashboard.switchToEditMode(); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await dashboardControls.adjustControlsLayout('twoLine'); + const controlGroupWrapper = await testSubjects.find('controls-group-wrapper'); + expect(await controlGroupWrapper.elementHasClass('controlsWrapper--twoLine')).to.be(true); + }); + + describe('apply new default size', async () => { + it('to new controls only', async () => { + await dashboardControls.updateControlsSize('medium'); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + }); + + const controlIds = await dashboardControls.getAllControlIds(); + const firstControl = await find.byXPath(`//div[@data-control-id="${controlIds[0]}"]`); + expect(await firstControl.elementHasClass('controlFrameWrapper--medium')).to.be(false); + const secondControl = await find.byXPath(`//div[@data-control-id="${controlIds[1]}"]`); + expect(await secondControl.elementHasClass('controlFrameWrapper--medium')).to.be(true); + }); + + it('to all existing controls', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + width: 'large', + }); + + await dashboardControls.updateControlsSize('small', true); + const controlIds = await dashboardControls.getAllControlIds(); + for (const id of controlIds) { + const control = await find.byXPath(`//div[@data-control-id="${id}"]`); + expect(await control.elementHasClass('controlFrameWrapper--small')).to.be(true); + } + }); + }); + + describe('flyout only show settings that are relevant', async () => { + before(async () => { + await dashboard.switchToEditMode(); + }); + + it('when no controls', async () => { + await dashboardControls.deleteAllControls(); + await dashboardControls.openControlGroupSettingsFlyout(); + await testSubjects.missingOrFail('delete-all-controls-button'); + await testSubjects.missingOrFail('set-all-control-sizes-checkbox'); + }); + + it('when at least one control', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await dashboardControls.openControlGroupSettingsFlyout(); + await testSubjects.existOrFail('delete-all-controls-button'); + await testSubjects.existOrFail('set-all-control-sizes-checkbox', { allowHidden: true }); + }); + + afterEach(async () => { + await testSubjects.click('euiFlyoutCloseButton'); + }); + + after(async () => { + await dashboardControls.deleteAllControls(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/controls_callout.ts b/test/functional/apps/dashboard_elements/controls/controls_callout.ts new file mode 100644 index 0000000000000..fc6316940c8a4 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/controls_callout.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const { dashboardControls, timePicker, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Controls callout', () => { + describe('callout visibility', async () => { + before(async () => { + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + await dashboard.saveDashboard('Test Controls Callout'); + }); + + describe('does not show the empty control callout on an empty dashboard', async () => { + it('in view mode', async () => { + await dashboard.clickCancelOutOfEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + + it('in edit mode', async () => { + await dashboard.switchToEditMode(); + await testSubjects.missingOrFail('controls-empty'); + }); + }); + + it('show the empty control callout on a dashboard with panels', async () => { + await dashboard.switchToEditMode(); + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await testSubjects.existOrFail('controls-empty'); + }); + + it('adding control hides the empty control callout', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + await testSubjects.missingOrFail('controls-empty'); + }); + + after(async () => { + await dashboard.clickCancelOutOfEditMode(); + await dashboard.gotoDashboardLandingPage(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/index.ts b/test/functional/apps/dashboard_elements/controls/index.ts new file mode 100644 index 0000000000000..a29834c848094 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const security = getService('security'); + + const { dashboardControls, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'dashboard', + 'common', + ]); + + async function setup() { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/dashboard/current/data'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.importExport.load( + 'test/functional/fixtures/kbn_archiver/dashboard/current/kibana' + ); + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader', 'animals']); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + + // enable the controls lab and navigate to the dashboard listing page to start + await common.navigateToApp('dashboard'); + await dashboardControls.enableControlsLab(); + await common.navigateToApp('dashboard'); + await dashboard.preserveCrossAppState(); + } + + async function teardown() { + await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); + await security.testUser.restoreDefaults(); + await kibanaServer.savedObjects.cleanStandardList(); + } + + describe('Controls', function () { + before(setup); + after(teardown); + loadTestFile(require.resolve('./controls_callout')); + loadTestFile(require.resolve('./control_group_settings')); + loadTestFile(require.resolve('./options_list')); + loadTestFile(require.resolve('./control_group_chaining')); + }); +} diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts new file mode 100644 index 0000000000000..6272448a68f93 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -0,0 +1,369 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const queryBar = getService('queryBar'); + const pieChart = getService('pieChart'); + const filterBar = getService('filterBar'); + const testSubjects = getService('testSubjects'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const { dashboardControls, timePicker, common, dashboard, header } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + describe('Dashboard options list integration', () => { + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + }); + + describe('Options List Control creation and editing experience', async () => { + it('can add a new options list control from a blank state', async () => { + await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' }); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + it('can add a second options list control with a non-default data view', async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + expect(await dashboardControls.getControlsCount()).to.be(2); + + // data views should be properly propagated from the control group to the dashboard + expect(await filterBar.getIndexPatterns()).to.be('logstash-*,animals-*'); + }); + + it('renames an existing control', async () => { + const secondId = (await dashboardControls.getAllControlIds())[1]; + + const newTitle = 'wow! Animal sounds?'; + await dashboardControls.editExistingControl(secondId); + await dashboardControls.controlEditorSetTitle(newTitle); + await dashboardControls.controlEditorSave(); + expect(await dashboardControls.doesControlTitleExist(newTitle)).to.be(true); + }); + + it('can change the data view and field of an existing options list', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(firstId); + + await dashboardControls.optionsListEditorSetDataView('animals-*'); + await dashboardControls.optionsListEditorSetfield('animal.keyword'); + await dashboardControls.controlEditorSave(); + + // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); + }); + + it('deletes an existing control', async () => { + const firstId = (await dashboardControls.getAllControlIds())[0]; + + await dashboardControls.removeExistingControl(firstId); + expect(await dashboardControls.getControlsCount()).to.be(1); + }); + + after(async () => { + await dashboardControls.clearAllControls(); + }); + }); + + describe('Interactions between options list and dashboard', async () => { + let controlId: string; + + const allAvailableOptions = [ + 'hiss', + 'ruff', + 'bark', + 'grrr', + 'meow', + 'growl', + 'grr', + 'bow ow ow', + ]; + + const ensureAvailableOptionsEql = async (expectation: string[], skipOpen?: boolean) => { + if (!skipOpen) await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( + expectation + ); + }); + if (!skipOpen) await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sounds', + }); + + controlId = (await dashboardControls.getAllControlIds())[0]; + }); + + describe('Applies query settings to controls', async () => { + it('Applies dashboard query to options list control', async () => { + await queryBar.setQuery('isDog : true '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(['ruff', 'bark', 'grrr', 'bow ow ow', 'grr']); + + await queryBar.setQuery(''); + await queryBar.submitQuery(); + + // using the query hides the time range. Clicking anywhere else shows it again. + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Applies dashboard time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await dashboardControls.optionsListOpenPopover(controlId); + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(0); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await timePicker.setDefaultDataRange(); + }); + + describe('dashboard filters', async () => { + before(async () => { + await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Applies dashboard filters to options list control', async () => { + await ensureAvailableOptionsEql(['ruff', 'bark', 'bow ow ow']); + }); + + it('Does not apply disabled dashboard filters to options list control', async () => { + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(allAvailableOptions); + + await filterBar.toggleFilterEnabled('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + }); + + it('Negated filters apply to options control', async () => { + await filterBar.toggleFilterNegated('sound.keyword'); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + + await ensureAvailableOptionsEql(['hiss', 'grrr', 'meow', 'growl', 'grr']); + }); + + after(async () => { + await filterBar.removeAllFilters(); + }); + }); + }); + + describe('Does not apply query settings to controls', async () => { + before(async () => { + await dashboardControls.updateAllQuerySyncSettings(false); + }); + + after(async () => { + await dashboardControls.updateAllQuerySyncSettings(true); + }); + + it('Does not apply query to options list control', async () => { + await queryBar.setQuery('isDog : true '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await queryBar.setQuery(''); + await queryBar.submitQuery(); + }); + + it('Does not apply filters to options list control', async () => { + await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await filterBar.removeAllFilters(); + }); + + it('Does not apply time range to options list control', async () => { + // set time range to time with no documents + await timePicker.setAbsoluteRange( + 'Jan 1, 2017 @ 00:00:00.000', + 'Jan 1, 2017 @ 00:00:00.000' + ); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + await timePicker.setDefaultDataRange(); + }); + }); + + describe('Selections made in control apply to dashboard', async () => { + it('Shows available options in options list', async () => { + await ensureAvailableOptionsEql(allAvailableOptions); + }); + + it('Can search options list for available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('meo'); + await ensureAvailableOptionsEql(['meow'], true); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Can select multiple available options', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('hiss'); + await dashboardControls.optionsListPopoverSelectOption('grr'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + it('Selected options appear in control', async () => { + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + it('Applies options list control options to dashboard', async () => { + await retry.try(async () => { + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + }); + + it('Applies options list control options to dashboard by default on open', async () => { + await dashboard.gotoDashboardLandingPage(); + await header.waitUntilLoadingHasFinished(); + await dashboard.clickUnsavedChangesContinueEditing('New Dashboard'); + await header.waitUntilLoadingHasFinished(); + expect(await pieChart.getPieSliceCount()).to.be(2); + + const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); + expect(selectionString).to.be('hiss, grr'); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + }); + + describe('Options List dashboard validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + + after(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await filterBar.removeAllFilters(); + }); + + it('Can mark selections invalid with Query', async () => { + await queryBar.setQuery('isDog : false '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql([ + 'hiss', + 'meow', + 'growl', + 'grr', + 'Ignored selection', + 'bark', + ]); + + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + + it('can make invalid selections valid again if the parent filter changes', async () => { + await queryBar.setQuery(''); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(allAvailableOptions); + expect(await pieChart.getPieSliceCount()).to.be(2); + }); + + it('Can mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter('sound.keyword', 'is', ['hiss']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss', 'Ignored selections', 'meow', 'bark']); + + // only valid selections are applied as filters. + expect(await pieChart.getPieSliceCount()).to.be(1); + }); + }); + + describe('Options List dashboard no validation', async () => { + before(async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListPopoverSelectOption('bark'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + await dashboardControls.updateValidationSetting(false); + }); + + it('Does not mark selections invalid with Query', async () => { + await queryBar.setQuery('isDog : false '); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss', 'meow', 'growl', 'grr']); + }); + + it('Does not mark multiple selections invalid with Filter', async () => { + await filterBar.addFilter('sound.keyword', 'is', ['hiss']); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await ensureAvailableOptionsEql(['hiss']); + }); + }); + + after(async () => { + await filterBar.removeAllFilters(); + await dashboardControls.clearAllControls(); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard_elements/index.ts b/test/functional/apps/dashboard_elements/index.ts index 4866754c3907b..059576389f32e 100644 --- a/test/functional/apps/dashboard_elements/index.ts +++ b/test/functional/apps/dashboard_elements/index.ts @@ -27,6 +27,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { this.tags('ciGroup10'); loadTestFile(require.resolve('./input_control_vis')); + loadTestFile(require.resolve('./controls')); loadTestFile(require.resolve('./_markdown_vis')); }); }); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 33053306243fe..c57c6d304e1e5 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -9,6 +9,7 @@ import expect from '@kbn/expect'; import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; import { OPTIONS_LIST_CONTROL, ControlWidth } from '../../../src/plugins/controls/common'; +import { ControlGroupChainingSystem } from '../../../src/plugins/controls/common/control_group/types'; import { FtrService } from '../ftr_provider_context'; @@ -63,6 +64,13 @@ export class DashboardPageControls extends FtrService { return allTitles.length; } + public async clearAllControls() { + const controlIds = await this.getAllControlIds(); + for (const controlId of controlIds) { + await this.removeExistingControl(controlId); + } + } + public async openCreateControlFlyout(type: string) { this.log.debug(`Opening flyout for ${type} control`); await this.testSubjects.click('dashboard-controls-menu-button'); @@ -119,6 +127,85 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click('control-group-editor-save'); } + public async updateChainingSystem(chainingSystem: ControlGroupChainingSystem) { + this.log.debug(`Update control group chaining system to ${chainingSystem}`); + await this.openControlGroupSettingsFlyout(); + await this.testSubjects.existOrFail('control-group-chaining'); + // currently there are only two chaining systems, so a switch is used. + const switchStateToChainingSystem: { [key: string]: ControlGroupChainingSystem } = { + true: 'HIERARCHICAL', + false: 'NONE', + }; + + const switchState = await this.testSubjects.getAttribute('control-group-chaining', 'checked'); + if (chainingSystem !== switchStateToChainingSystem[switchState]) { + await this.testSubjects.click('control-group-chaining'); + } + await this.testSubjects.click('control-group-editor-save'); + } + + public async setSwitchState(goalState: boolean, subject: string) { + await this.testSubjects.existOrFail(subject); + const currentStateIsChecked = + (await this.testSubjects.getAttribute(subject, 'aria-checked')) === 'true'; + if (currentStateIsChecked !== goalState) { + await this.testSubjects.click(subject); + } + await this.retry.try(async () => { + const stateIsChecked = (await this.testSubjects.getAttribute(subject, 'checked')) === 'true'; + expect(stateIsChecked).to.be(goalState); + }); + } + + public async updateValidationSetting(validate: boolean) { + this.log.debug(`Update control group validation setting to ${validate}`); + await this.openControlGroupSettingsFlyout(); + await this.setSwitchState(validate, 'control-group-validate-selections'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateAllQuerySyncSettings(querySync: boolean) { + this.log.debug(`Update all control group query sync settings to ${querySync}`); + await this.openControlGroupSettingsFlyout(); + await this.setSwitchState(querySync, 'control-group-query-sync'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async ensureAdvancedQuerySyncIsOpened() { + const advancedAccordion = await this.testSubjects.find(`control-group-query-sync-advanced`); + const opened = await advancedAccordion.elementHasClass('euiAccordion-isOpen'); + if (!opened) { + await this.testSubjects.click(`control-group-query-sync-advanced`); + await this.retry.try(async () => { + expect(await advancedAccordion.elementHasClass('euiAccordion-isOpen')).to.be(true); + }); + } + } + + public async updateSyncTimeRangeAdvancedSetting(syncTimeRange: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncTimeRange}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncTimeRange, 'control-group-query-sync-time-range'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateSyncQueryAdvancedSetting(syncQuery: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncQuery}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncQuery, 'control-group-query-sync-query'); + await this.testSubjects.click('control-group-editor-save'); + } + + public async updateSyncFilterAdvancedSetting(syncFilters: boolean) { + this.log.debug(`Update filter sync advanced setting to ${syncFilters}`); + await this.openControlGroupSettingsFlyout(); + await this.ensureAdvancedQuerySyncIsOpened(); + await this.setSwitchState(syncFilters, 'control-group-query-sync-filters'); + await this.testSubjects.click('control-group-editor-save'); + } + /* ----------------------------------------------------------- Individual controls functions ----------------------------------------------------------- */ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9d1ec062fe1b3..44cd0b1a20c8d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1221,13 +1221,9 @@ "controls.controlGroup.management.flyoutTitle": "コントロールを構成", "controls.controlGroup.management.layout.auto": "自動", "controls.controlGroup.management.layout.controlWidthLegend": "コントロールサイズを変更", - "controls.controlGroup.management.layout.designSwitchLegend": "コントロール設計を切り替え", "controls.controlGroup.management.layout.large": "大", "controls.controlGroup.management.layout.medium": "中", - "controls.controlGroup.management.layout.singleLine": "1行", "controls.controlGroup.management.layout.small": "小", - "controls.controlGroup.management.layout.twoLine": "2行", - "controls.controlGroup.management.layoutTitle": "レイアウト", "controls.controlGroup.management.setAllWidths": "すべてのサイズをデフォルトに設定", "controls.controlGroup.title": "コントロールグループ", "controls.optionsList.editor.allowMultiselectTitle": "ドロップダウンでの複数選択を許可", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b055d663f9e69..abfb0a4dc2a82 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1227,13 +1227,9 @@ "controls.controlGroup.management.flyoutTitle": "配置控件", "controls.controlGroup.management.layout.auto": "自动", "controls.controlGroup.management.layout.controlWidthLegend": "更改控件大小", - "controls.controlGroup.management.layout.designSwitchLegend": "切换控件设计", "controls.controlGroup.management.layout.large": "大", "controls.controlGroup.management.layout.medium": "中", - "controls.controlGroup.management.layout.singleLine": "单行", "controls.controlGroup.management.layout.small": "小", - "controls.controlGroup.management.layout.twoLine": "双行", - "controls.controlGroup.management.layoutTitle": "布局", "controls.controlGroup.management.setAllWidths": "将所有大小设为默认值", "controls.controlGroup.title": "控件组", "controls.optionsList.editor.allowMultiselectTitle": "下拉列表中允许多选", From fe9fb3ee3d226422a4ca2aa22577122e63ec6ed5 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Wed, 23 Mar 2022 18:16:48 -0500 Subject: [PATCH 100/132] [Security Solution] update blocklist form copy (#128385) --- .../management/pages/blocklist/translations.ts | 10 +++++++++- .../pages/blocklist/view/blocklist.tsx | 6 ++++-- .../view/components/blocklist_form.tsx | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts index f7e4344cee23c..e905cef582964 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/translations.ts @@ -15,7 +15,8 @@ export const DETAILS_HEADER = i18n.translate('xpack.securitySolution.blocklists. export const DETAILS_HEADER_DESCRIPTION = i18n.translate( 'xpack.securitySolution.blocklists.details.header.description', { - defaultMessage: 'Add a blocklist to prevent selected applications from running on your hosts.', + defaultMessage: + 'The blocklist prevents selected applications from running on your hosts by extending the list of processes the Endpoint considers malicious.', } ); @@ -61,6 +62,13 @@ export const VALUE_LABEL = i18n.translate('xpack.securitySolution.blocklists.val defaultMessage: 'Value', }); +export const VALUE_LABEL_HELPER = i18n.translate( + 'xpack.securitySolution.blocklists.value.label.helper', + { + defaultMessage: 'Type or copy & paste one or multiple comma delimited values', + } +); + export const CONDITION_FIELD_TITLE: { [K in ConditionEntryField]: string } = { [ConditionEntryField.HASH]: i18n.translate('xpack.securitySolution.blocklists.entry.field.hash', { defaultMessage: 'Hash', diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx index 45d76614ddce2..75d4b22fe16a1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx @@ -18,14 +18,16 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Blocklist', }), pageAboutInfo: i18n.translate('xpack.securitySolution.blocklist.pageAboutInfo', { - defaultMessage: 'Add a blocklist to block applications or files from running on the endpoint.', + defaultMessage: + 'The blocklist prevents selected applications from running on your hosts by extending the list of processes the Endpoint considers malicious.', }), pageAddButtonTitle: i18n.translate('xpack.securitySolution.blocklist.pageAddButtonTitle', { defaultMessage: 'Add blocklist entry', }), getShowingCountLabel: (total) => i18n.translate('xpack.securitySolution.blocklist.showingTotal', { - defaultMessage: 'Showing {total} {total, plural, one {blocklist} other {blocklists}}', + defaultMessage: + 'Showing {total} {total, plural, one {blocklist entry} other {blocklist entries}}', values: { total }, }), cardActionEditLabel: i18n.translate('xpack.securitySolution.blocklist.cardActionEditLabel', { diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx index 379b8f932ba9d..ff4325a38757d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/components/blocklist_form.tsx @@ -21,6 +21,8 @@ import { EuiTitle, EuiFlexGroup, EuiFlexItem, + EuiToolTip, + EuiIcon, } from '@elastic/eui'; import { OperatingSystem, @@ -49,6 +51,7 @@ import { SELECT_OS_LABEL, VALUE_LABEL, ERRORS, + VALUE_LABEL_HELPER, } from '../../translations'; import { EffectedPolicySelect, @@ -165,6 +168,18 @@ export const BlockListForm = memo( return selectableFields; }, [selectedOs]); + const valueLabel = useMemo(() => { + return ( +
+ + <> + {VALUE_LABEL} + + +
+ ); + }, []); + const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; const { @@ -432,7 +447,7 @@ export const BlockListForm = memo(
Date: Wed, 23 Mar 2022 23:04:29 -0500 Subject: [PATCH 101/132] [Security Solution] update blocklist fields to use file prefix (#128291) --- .../src/path_validations/index.ts | 16 +++- .../endpoint/types/exception_list_items.ts | 11 +-- .../utils/exception_list_items/mappers.ts | 89 +++++++++++-------- .../pages/blocklist/translations.ts | 28 +++--- .../view/components/blocklist_form.tsx | 38 ++++---- 5 files changed, 101 insertions(+), 81 deletions(-) diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts index b64cb4cf6a052..665b1a0838346 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -22,6 +22,20 @@ export const enum ConditionEntryField { SIGNER = 'process.Ext.code_signature', } +export const enum EntryFieldType { + HASH = '.hash.', + EXECUTABLE = '.executable.caseless', + PATH = '.path', + SIGNER = '.Ext.code_signature', +} + +export type TrustedAppConditionEntryField = + | 'process.hash.*' + | 'process.executable.caseless' + | 'process.Ext.code_signature'; +export type BlocklistConditionEntryField = 'file.hash.*' | 'file.path' | 'file.Ext.code_signature'; +export type AllConditionEntryFields = TrustedAppConditionEntryField | BlocklistConditionEntryField; + export const enum OperatingSystem { LINUX = 'linux', MAC = 'macos', @@ -91,7 +105,7 @@ export const isPathValid = ({ value, }: { os: OperatingSystem; - field: ConditionEntryField | 'file.path.text'; + field: AllConditionEntryFields | 'file.path.text'; type: EntryTypes; value: string; }): boolean => { diff --git a/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts b/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts index efdbe42465a5a..bcb452abd50e0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/exception_list_items.ts @@ -5,17 +5,14 @@ * 2.0. */ -import { ConditionEntryField, EntryTypes } from '@kbn/securitysolution-utils'; +import { AllConditionEntryFields, EntryTypes } from '@kbn/securitysolution-utils'; export type ConditionEntriesMap = { - [K in ConditionEntryField]?: T; + [K in AllConditionEntryFields]?: T; }; -export interface ConditionEntry< - F extends ConditionEntryField = ConditionEntryField, - T extends EntryTypes = EntryTypes -> { - field: F; +export interface ConditionEntry { + field: AllConditionEntryFields; type: T; operator: 'included'; value: string | string[]; diff --git a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts index e04d059a515d4..bfd844caad1b4 100644 --- a/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts +++ b/x-pack/plugins/security_solution/public/common/utils/exception_list_items/mappers.ts @@ -13,7 +13,7 @@ import { EntryNested, NestedEntriesArray, } from '@kbn/securitysolution-io-ts-list-types'; -import { ConditionEntryField, EntryTypes } from '@kbn/securitysolution-utils'; +import { AllConditionEntryFields, EntryFieldType, EntryTypes } from '@kbn/securitysolution-utils'; import { ConditionEntriesMap, ConditionEntry } from '../../../../common/endpoint/types'; @@ -46,12 +46,12 @@ const createEntryNested = (field: string, entries: NestedEntriesArray): EntryNes return { field, entries, type: 'nested' }; }; -function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { +function groupHashEntry(prefix: 'process' | 'file', conditionEntry: ConditionEntry): EntriesArray { const entriesArray: EntriesArray = []; if (!Array.isArray(conditionEntry.value)) { const entry = createEntryMatch( - `process.hash.${hashType(conditionEntry.value)}`, + `${prefix}${EntryFieldType.HASH}${hashType(conditionEntry.value)}`, conditionEntry.value.toLowerCase() ); entriesArray.push(entry); @@ -80,7 +80,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { return; } - const entry = createEntryMatchAny(`process.hash.${type}`, values); + const entry = createEntryMatchAny(`${prefix}${EntryFieldType.HASH}${type}`, values); entriesArray.push(entry); }); @@ -88,6 +88,7 @@ function groupHashEntry(conditionEntry: ConditionEntry): EntriesArray { } function createNestedSignatureEntry( + field: AllConditionEntryFields, value: string | string[], isTrustedApp: boolean = false ): EntryNested { @@ -97,19 +98,23 @@ function createNestedSignatureEntry( const nestedEntries: EntryNested['entries'] = []; if (isTrustedApp) nestedEntries.push(createEntryMatch('trusted', 'true')); nestedEntries.push(subjectNameMatch); - return createEntryNested('process.Ext.code_signature', nestedEntries); + return createEntryNested(field, nestedEntries); } -function createWildcardPathEntry(value: string | string[]): EntryMatchWildcard | EntryMatchAny { +function createWildcardPathEntry( + field: AllConditionEntryFields, + value: string | string[] +): EntryMatchWildcard | EntryMatchAny { return Array.isArray(value) - ? createEntryMatchAny('process.executable.caseless', value) - : createEntryMatchWildcard('process.executable.caseless', value); + ? createEntryMatchAny(field, value) + : createEntryMatchWildcard(field, value); } -function createPathEntry(value: string | string[]): EntryMatch | EntryMatchAny { - return Array.isArray(value) - ? createEntryMatchAny('process.executable.caseless', value) - : createEntryMatch('process.executable.caseless', value); +function createPathEntry( + field: AllConditionEntryFields, + value: string | string[] +): EntryMatch | EntryMatchAny { + return Array.isArray(value) ? createEntryMatchAny(field, value) : createEntryMatch(field, value); } export const conditionEntriesToEntries = ( @@ -119,19 +124,25 @@ export const conditionEntriesToEntries = ( const entriesArray: EntriesArray = []; conditionEntries.forEach((conditionEntry) => { - if (conditionEntry.field === ConditionEntryField.HASH) { - groupHashEntry(conditionEntry).forEach((entry) => entriesArray.push(entry)); - } else if (conditionEntry.field === ConditionEntryField.SIGNER) { - const entry = createNestedSignatureEntry(conditionEntry.value, isTrustedApp); + if (conditionEntry.field.includes(EntryFieldType.HASH)) { + const prefix = conditionEntry.field.split('.')[0] as 'process' | 'file'; + groupHashEntry(prefix, conditionEntry).forEach((entry) => entriesArray.push(entry)); + } else if (conditionEntry.field.includes(EntryFieldType.SIGNER)) { + const entry = createNestedSignatureEntry( + conditionEntry.field, + conditionEntry.value, + isTrustedApp + ); entriesArray.push(entry); } else if ( - conditionEntry.field === ConditionEntryField.PATH && + (conditionEntry.field.includes(EntryFieldType.EXECUTABLE) || + conditionEntry.field.includes(EntryFieldType.PATH)) && conditionEntry.type === 'wildcard' ) { - const entry = createWildcardPathEntry(conditionEntry.value); + const entry = createWildcardPathEntry(conditionEntry.field, conditionEntry.value); entriesArray.push(entry); } else { - const entry = createPathEntry(conditionEntry.value); + const entry = createPathEntry(conditionEntry.field, conditionEntry.value); entriesArray.push(entry); } }); @@ -140,49 +151,51 @@ export const conditionEntriesToEntries = ( }; const createConditionEntry = ( - field: ConditionEntryField, + field: AllConditionEntryFields, type: EntryTypes, value: string | string[] ): ConditionEntry => { return { field, value, type, operator: OPERATOR_VALUE }; }; +function createWildcardHashField( + field: string +): Extract { + const prefix = field.split('.')[0] as 'process' | 'file'; + return `${prefix}${EntryFieldType.HASH}*`; +} + export const entriesToConditionEntriesMap = ( entries: EntriesArray ): ConditionEntriesMap => { return entries.reduce((memo: ConditionEntriesMap, entry) => { - if (entry.field.startsWith('process.hash') && entry.type === 'match') { + const field = entry.field as AllConditionEntryFields; + if (field.includes(EntryFieldType.HASH) && entry.type === 'match') { + const wildcardHashField = createWildcardHashField(field); return { ...memo, - [ConditionEntryField.HASH]: createConditionEntry( - ConditionEntryField.HASH, - entry.type, - entry.value - ), + [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, entry.value), } as ConditionEntriesMap; - } else if (entry.field.startsWith('process.hash') && entry.type === 'match_any') { - const currentValues = (memo[ConditionEntryField.HASH]?.value as string[]) ?? []; + } else if (field.includes(EntryFieldType.HASH) && entry.type === 'match_any') { + const wildcardHashField = createWildcardHashField(field); + const currentValues = (memo[wildcardHashField]?.value as string[]) ?? []; return { ...memo, - [ConditionEntryField.HASH]: createConditionEntry(ConditionEntryField.HASH, entry.type, [ + [wildcardHashField]: createConditionEntry(wildcardHashField, entry.type, [ ...currentValues, ...entry.value, ]), } as ConditionEntriesMap; } else if ( - entry.field === ConditionEntryField.PATH && + (field.includes(EntryFieldType.EXECUTABLE) || field.includes(EntryFieldType.PATH)) && (entry.type === 'match' || entry.type === 'match_any' || entry.type === 'wildcard') ) { return { ...memo, - [ConditionEntryField.PATH]: createConditionEntry( - ConditionEntryField.PATH, - entry.type, - entry.value - ), + [field]: createConditionEntry(field, entry.type, entry.value), } as ConditionEntriesMap; - } else if (entry.field === ConditionEntryField.SIGNER && entry.type === 'nested') { + } else if (field.includes(EntryFieldType.SIGNER) && entry.type === 'nested') { const subjectNameCondition = entry.entries.find((subEntry): subEntry is EntryMatch => { return ( subEntry.field === 'subject_name' && @@ -193,8 +206,8 @@ export const entriesToConditionEntriesMap = {message}
; } -function getDropdownDisplay(field: ConditionEntryField): React.ReactNode { +function getDropdownDisplay(field: BlocklistConditionEntryField): React.ReactNode { return ( <> {CONDITION_FIELD_TITLE[field]} @@ -118,7 +118,7 @@ export const BlockListForm = memo( const blocklistEntry = useMemo((): BlocklistEntry => { if (!item.entries.length) { return { - field: ConditionEntryField.HASH, + field: 'file.hash.*', operator: 'included', type: 'match_any', value: [], @@ -148,20 +148,19 @@ export const BlockListForm = memo( [] ); - const fieldOptions: Array> = useMemo(() => { - const selectableFields: Array> = [ - ConditionEntryField.HASH, - ConditionEntryField.PATH, - ].map((field) => ({ + const fieldOptions: Array> = useMemo(() => { + const selectableFields: Array> = ( + ['file.hash.*', 'file.path'] as BlocklistConditionEntryField[] + ).map((field) => ({ value: field, inputDisplay: CONDITION_FIELD_TITLE[field], dropdownDisplay: getDropdownDisplay(field), })); if (selectedOs === OperatingSystem.WINDOWS) { selectableFields.push({ - value: ConditionEntryField.SIGNER, - inputDisplay: CONDITION_FIELD_TITLE[ConditionEntryField.SIGNER], - dropdownDisplay: getDropdownDisplay(ConditionEntryField.SIGNER), + value: 'file.Ext.code_signature', + inputDisplay: CONDITION_FIELD_TITLE['file.Ext.code_signature'], + dropdownDisplay: getDropdownDisplay('file.Ext.code_signature'), }); } @@ -183,7 +182,7 @@ export const BlockListForm = memo( const validateValues = useCallback((nextItem: ArtifactFormComponentProps['item']) => { const os = ((nextItem.os_types ?? [])[0] as OperatingSystem) ?? OperatingSystem.WINDOWS; const { - field = ConditionEntryField.HASH, + field = 'file.hash.*', type = 'match_any', value: values = [], } = (nextItem.entries[0] ?? {}) as BlocklistEntry; @@ -203,20 +202,20 @@ export const BlockListForm = memo( } // error if invalid hash - if (field === ConditionEntryField.HASH && values.some((value) => !isValidHash(value))) { + if (field === 'file.hash.*' && values.some((value) => !isValidHash(value))) { newValueErrors.push(createValidationMessage(ERRORS.INVALID_HASH)); } const isInvalidPath = values.some((value) => !isPathValid({ os, field, type, value })); // warn if invalid path - if (field !== ConditionEntryField.HASH && isInvalidPath) { + if (field !== 'file.hash.*' && isInvalidPath) { newValueWarnings.push(createValidationMessage(ERRORS.INVALID_PATH)); } // warn if wildcard if ( - field !== ConditionEntryField.HASH && + field !== 'file.hash.*' && !isInvalidPath && values.some((value) => !hasSimpleExecutableName({ os, type, value })) ) { @@ -275,9 +274,8 @@ export const BlockListForm = memo( { ...blocklistEntry, field: - os !== OperatingSystem.WINDOWS && - blocklistEntry.field === ConditionEntryField.SIGNER - ? ConditionEntryField.HASH + os !== OperatingSystem.WINDOWS && blocklistEntry.field === 'file.Ext.code_signature' + ? 'file.hash.*' : blocklistEntry.field, }, ], @@ -293,7 +291,7 @@ export const BlockListForm = memo( ); const handleOnFieldChange = useCallback( - (field: ConditionEntryField) => { + (field: BlocklistConditionEntryField) => { const nextItem = { ...item, entries: [{ ...blocklistEntry, field }], From 2d12c94c2f03f648293ce2c8429fe8d4fc4f3789 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Wed, 23 Mar 2022 22:56:48 -0700 Subject: [PATCH 102/132] Allow add_prepackaged_rules to change rule types (#128283) --- .../rules/add_prepackaged_rules_route.ts | 6 +- .../detection_engine/rules/create_rules.ts | 12 +- .../lib/detection_engine/rules/types.ts | 1 + .../rules/update_prepacked_rules.test.ts | 11 +- .../rules/update_prepacked_rules.ts | 195 ++++++++++++------ .../server/lib/detection_engine/types.ts | 1 + 6 files changed, 156 insertions(+), 70 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 691548c0a9efd..d5c6c0da2cec7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -73,7 +73,7 @@ export const addPrepackedRulesRoute = (router: SecuritySolutionPluginRouter) => ); }; -class PrepackagedRulesError extends Error { +export class PrepackagedRulesError extends Error { public readonly statusCode: number; constructor(message: string, statusCode: number) { super(message); @@ -147,10 +147,10 @@ export const createPrepackagedRules = async ( await updatePrepackagedRules( rulesClient, savedObjectsClient, - context.getSpaceId(), rulesToUpdate, signalsIndex, - ruleRegistryEnabled + ruleRegistryEnabled, + context.getRuleExecutionLog() ); const prepackagedRulesOutput: PrePackagedRulesAndTimelinesSchema = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 5ff5358fbc4cd..ef9d198d2040f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -12,7 +12,7 @@ import { normalizeThresholdObject, } from '../../../../common/detection_engine/utils'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; -import { SanitizedAlert } from '../../../../../alerting/common'; +import { AlertTypeParams, SanitizedAlert } from '../../../../../alerting/common'; import { DEFAULT_INDICATOR_SOURCE_PATH, NOTIFICATION_THROTTLE_NO_ACTIONS, @@ -20,7 +20,7 @@ import { } from '../../../../common/constants'; import { CreateRulesOptions } from './types'; import { addTags } from './add_tags'; -import { PartialFilter, RuleTypeParams } from '../types'; +import { PartialFilter } from '../types'; import { transformToAlertThrottle, transformToNotifyWhen } from './utils'; export const createRules = async ({ @@ -76,8 +76,12 @@ export const createRules = async ({ exceptionsList, actions, isRuleRegistryEnabled, -}: CreateRulesOptions): Promise> => { - const rule = await rulesClient.create({ + id, +}: CreateRulesOptions): Promise> => { + const rule = await rulesClient.create({ + options: { + id, + }, data: { name, tags: addTags(tags, ruleId, immutable), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 74fb5bfe672a0..7e66f1d0aa7a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -194,6 +194,7 @@ export interface CreateRulesOptions { actions: RuleAlertAction[]; isRuleRegistryEnabled: boolean; namespace?: NamespaceOrUndefined; + id?: string; } export interface UpdateRulesOptions { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index 703b0a4f5aec1..44a7fa58a385f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit } from '../routes/__mocks__/request_response import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; import { getAddPrepackagedRulesSchemaDecodedMock } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock'; +import { ruleExecutionLogMock } from '../rule_execution_log/__mocks__'; jest.mock('./patch_rules'); @@ -20,10 +21,12 @@ describe.each([ ])('updatePrepackagedRules - %s', (_, isRuleRegistryEnabled) => { let rulesClient: ReturnType; let savedObjectsClient: ReturnType; + let ruleExecutionLog: ReturnType; beforeEach(() => { rulesClient = rulesClientMock.create(); savedObjectsClient = savedObjectsClientMock.create(); + ruleExecutionLog = ruleExecutionLogMock.forRoutes.create(); }); it('should omit actions and enabled when calling patchRules', async () => { @@ -42,10 +45,10 @@ describe.each([ await updatePrepackagedRules( rulesClient, savedObjectsClient, - 'default', [{ ...prepackagedRule, actions }], outputIndex, - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); expect(patchRules).toHaveBeenCalledWith( @@ -73,10 +76,10 @@ describe.each([ await updatePrepackagedRules( rulesClient, savedObjectsClient, - 'default', [{ ...prepackagedRule, ...updatedThreatParams }], 'output-index', - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); expect(patchRules).toHaveBeenCalledWith( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index f6b4508405c5e..ceb6a3739bd6c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -15,6 +15,11 @@ import { readRules } from './read_rules'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; import { legacyMigrate } from './utils'; +import { deleteRules } from './delete_rules'; +import { PrepackagedRulesError } from '../routes/rules/add_prepackaged_rules_route'; +import { IRuleExecutionLogForRoutes } from '../rule_execution_log'; +import { createRules } from './create_rules'; +import { transformAlertToRuleAction } from '../../../../common/detection_engine/transform_actions'; /** * Updates the prepackaged rules given a set of rules and output index. @@ -28,20 +33,20 @@ import { legacyMigrate } from './utils'; export const updatePrepackagedRules = async ( rulesClient: RulesClient, savedObjectsClient: SavedObjectsClientContract, - spaceId: string, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string, - isRuleRegistryEnabled: boolean + isRuleRegistryEnabled: boolean, + ruleExecutionLog: IRuleExecutionLogForRoutes ): Promise => { const ruleChunks = chunk(MAX_RULES_TO_UPDATE_IN_PARALLEL, rules); for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, savedObjectsClient, - spaceId, ruleChunk, outputIndex, - isRuleRegistryEnabled + isRuleRegistryEnabled, + ruleExecutionLog ); await Promise.all(rulePromises); } @@ -58,10 +63,10 @@ export const updatePrepackagedRules = async ( export const createPromises = ( rulesClient: RulesClient, savedObjectsClient: SavedObjectsClientContract, - spaceId: string, rules: AddPrepackagedRulesSchemaDecoded[], outputIndex: string, - isRuleRegistryEnabled: boolean + isRuleRegistryEnabled: boolean, + ruleExecutionLog: IRuleExecutionLogForRoutes ): Array | null>> => { return rules.map(async (rule) => { const { @@ -128,58 +133,130 @@ export const createPromises = ( rule: existingRule, }); - // Note: we do not pass down enabled as we do not want to suddenly disable - // or enable rules on the user when they were not expecting it if a rule updates - return patchRules({ - rulesClient, - author, - buildingBlockType, - description, - eventCategoryOverride, - falsePositives, - from, - query, - language, - license, - outputIndex, - rule: migratedRule, - savedId, - meta, - filters, - index, - interval, - maxSignals, - riskScore, - riskScoreMapping, - ruleNameOverride, - name, - severity, - severityMapping, - tags, - timestampOverride, - to, - type, - threat, - threshold, - threatFilters, - threatIndex, - threatIndicatorPath, - threatQuery, - threatMapping, - threatLanguage, - concurrentSearches, - itemsPerSearch, - references, - version, - note, - anomalyThreshold, - enabled: undefined, - timelineId, - timelineTitle, - machineLearningJobId, - exceptionsList, - throttle, - actions: undefined, - }); + if (!migratedRule) { + throw new PrepackagedRulesError(`Failed to find rule ${ruleId}`, 500); + } + + // If we're trying to change the type of a prepackaged rule, we need to delete the old one + // and replace it with the new rule, keeping the enabled setting, actions, throttle, id, + // and exception lists from the old rule + if (type !== migratedRule.params.type) { + await deleteRules({ + ruleId: migratedRule.id, + rulesClient, + ruleExecutionLog, + }); + + return (await createRules({ + id: migratedRule.id, + isRuleRegistryEnabled, + rulesClient, + anomalyThreshold, + author, + buildingBlockType, + description, + enabled: migratedRule.enabled, // Enabled comes from existing rule + eventCategoryOverride, + falsePositives, + from, + immutable: true, // At the moment we force all prepackaged rules to be immutable + query, + language, + license, + machineLearningJobId, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + to, + type, + threat, + threatFilters, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + threatQuery, + threatIndex, + threatIndicatorPath, + threshold, + throttle: migratedRule.throttle, // Throttle comes from the existing rule + timestampOverride, + references, + note, + version, + // The exceptions list passed in to this function has already been merged with the exceptions list of + // the existing rule + exceptionsList, + actions: migratedRule.actions.map(transformAlertToRuleAction), // Actions come from the existing rule + })) as PartialAlert; // TODO: Replace AddPrepackagedRulesSchema with type specific rules schema so we can clean up these types + } else { + // Note: we do not pass down enabled as we do not want to suddenly disable + // or enable rules on the user when they were not expecting it if a rule updates + return patchRules({ + rulesClient, + author, + buildingBlockType, + description, + eventCategoryOverride, + falsePositives, + from, + query, + language, + license, + outputIndex, + rule: migratedRule, + savedId, + meta, + filters, + index, + interval, + maxSignals, + riskScore, + riskScoreMapping, + ruleNameOverride, + name, + severity, + severityMapping, + tags, + timestampOverride, + to, + type, + threat, + threshold, + threatFilters, + threatIndex, + threatIndicatorPath, + threatQuery, + threatMapping, + threatLanguage, + concurrentSearches, + itemsPerSearch, + references, + version, + note, + anomalyThreshold, + enabled: undefined, + timelineId, + timelineTitle, + machineLearningJobId, + exceptionsList, + throttle, + actions: undefined, + }); + } }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 206ccb3b78351..f25c23d2d5ed3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -80,6 +80,7 @@ export interface RuleTypeParams extends AlertTypeParams { query?: QueryOrUndefined; filters?: unknown[]; maxSignals: MaxSignals; + namespace?: string; riskScore: RiskScore; riskScoreMapping: RiskScoreMappingOrUndefined; ruleNameOverride: RuleNameOverrideOrUndefined; From 968f350989c42054b465ee77f40d8aa3fcc597a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 24 Mar 2022 08:23:21 +0100 Subject: [PATCH 103/132] Create generic get filter method to be used with an array of list id's (#127983) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/typescript_types/index.ts | 4 +- .../src/use_exception_lists/index.ts | 14 +- .../get_event_filters_filter/index.test.ts | 39 -- .../src/get_event_filters_filter/index.ts | 27 -- .../src/get_filters/index.test.ts | 333 +++--------------- .../src/get_filters/index.ts | 31 +- .../index.test.ts | 49 --- .../index.ts | 27 -- .../src/get_trusted_apps_filter/index.test.ts | 39 -- .../src/get_trusted_apps_filter/index.ts | 27 -- .../src/index.ts | 1 - .../hooks/use_exception_lists.test.ts | 231 +----------- .../rules/all/exceptions/exceptions_table.tsx | 5 +- 13 files changed, 77 insertions(+), 750 deletions(-) delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts delete mode 100644 packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts index bf3d066d59f25..a5eb4f976debd 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/typescript_types/index.ts @@ -41,9 +41,7 @@ export interface UseExceptionListsProps { namespaceTypes: NamespaceType[]; notifications: NotificationsStart; initialPagination?: Pagination; - showTrustedApps: boolean; - showEventFilters: boolean; - showHostIsolationExceptions: boolean; + hideLists?: readonly string[]; } export interface UseExceptionListProps { diff --git a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts index 55c1d4dfaa853..c73405f1950b8 100644 --- a/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts +++ b/packages/kbn-securitysolution-list-hooks/src/use_exception_lists/index.ts @@ -39,9 +39,7 @@ const DEFAULT_PAGINATION = { * @param filterOptions filter by certain fields * @param namespaceTypes spaces to be searched * @param notifications kibana service for displaying toasters - * @param showTrustedApps boolean - include/exclude trusted app lists - * @param showEventFilters boolean - include/exclude event filters lists - * @param showHostIsolationExceptions boolean - include/exclude host isolation exceptions lists + * @param hideLists a list of listIds we don't want to query * @param initialPagination * */ @@ -52,9 +50,7 @@ export const useExceptionLists = ({ filterOptions = {}, namespaceTypes, notifications, - showTrustedApps = false, - showEventFilters = false, - showHostIsolationExceptions = false, + hideLists = [], }: UseExceptionListsProps): ReturnExceptionLists => { const [exceptionLists, setExceptionLists] = useState([]); const [pagination, setPagination] = useState(initialPagination); @@ -67,11 +63,9 @@ export const useExceptionLists = ({ getFilters({ filters: filterOptions, namespaceTypes, - showTrustedApps, - showEventFilters, - showHostIsolationExceptions, + hideLists, }), - [namespaceTypes, filterOptions, showTrustedApps, showEventFilters, showHostIsolationExceptions] + [namespaceTypes, filterOptions, hideLists] ); const fetchData = useCallback(async (): Promise => { diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts deleted file mode 100644 index 934a9cbff56a6..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.test.ts +++ /dev/null @@ -1,39 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getEventFiltersFilter } from '.'; - -describe('getEventFiltersFilter', () => { - test('it returns filter to search for "exception-list" namespace trusted apps', () => { - const filter = getEventFiltersFilter(true, ['exception-list']); - - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_event_filters*)'); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getEventFiltersFilter(true, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace trusted apps', () => { - const filter = getEventFiltersFilter(false, ['exception-list']); - - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_event_filters*)'); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getEventFiltersFilter(false, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts deleted file mode 100644 index 7e55073228fca..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_event_filters_filter/index.ts +++ /dev/null @@ -1,27 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getEventFiltersFilter = ( - showEventFilter: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showEventFilter) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_EVENT_FILTERS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts index 6484ac002d56d..8636984135792 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.test.ts @@ -10,423 +10,198 @@ import { getFilters } from '.'; describe('getFilters', () => { describe('single', () => { - test('it properly formats when no filters passed "showTrustedApps", "showEventFilters", and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3*)' ); }); - test('it properly formats when no filters passed "showTrustedApps", "showEventFilters", and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual('(not exception-list.attributes.list_id: listId-1*)'); }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it if filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it if filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1*)' ); }); - - test('it if filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample)' ); }); }); describe('agnostic', () => { - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual('(not exception-list-agnostic.attributes.list_id: listId-1*)'); }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it if filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it if filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it if filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample)' ); }); }); describe('single, agnostic', () => { - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { + test('it properly formats when no filters and hide lists contains few list ids', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2* AND not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3* AND not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - test('it properly formats when no filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { + test('it properly formats when no filters and hide lists contains one list id', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is false', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it properly formats when filters passed and "showTrustedApps", "showEventFilters" and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: { created_by: 'moi', name: 'Sample' }, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: true, - showHostIsolationExceptions: true, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showTrustedApps" is true', () => { + test('it properly formats when no filters and no hide lists', () => { const filter = getFilters({ filters: {}, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: [], }); - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); + expect(filter).toEqual(''); }); - - test('it properly formats when filters passed and "showTrustedApps" is true', () => { + test('it properly formats when filters passed and hide lists contains few list ids', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: true, - showEventFilters: false, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it properly formats when no filters passed and "showEventFilters" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, + hideLists: ['listId-1', 'listId-2', 'listId-3'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*) AND (not exception-list.attributes.list_id: listId-2* AND not exception-list-agnostic.attributes.list_id: listId-2*) AND (not exception-list.attributes.list_id: listId-3* AND not exception-list-agnostic.attributes.list_id: listId-3*)' ); }); - - test('it properly formats when filters passed and "showEventFilters" is true', () => { + test('it properly formats when filters passed and hide lists contains one list id', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: true, - showHostIsolationExceptions: false, - }); - - expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - test('it properly formats when no filters passed and "showHostIsolationExceptions" is true', () => { - const filter = getFilters({ - filters: {}, - namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: ['listId-1'], }); expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)' ); }); - - test('it properly formats when filters passed and "showHostIsolationExceptions" is true', () => { + test('it properly formats when filters passed and no hide lists', () => { const filter = getFilters({ filters: { created_by: 'moi', name: 'Sample' }, namespaceTypes: ['single', 'agnostic'], - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: true, + hideLists: [], }); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample)' ); }); }); diff --git a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts index e8e9e6a581828..214fd396d0918 100644 --- a/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/get_filters/index.ts @@ -9,34 +9,23 @@ import { ExceptionListFilter, NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import { getGeneralFilters } from '../get_general_filters'; import { getSavedObjectTypes } from '../get_saved_object_types'; -import { getTrustedAppsFilter } from '../get_trusted_apps_filter'; -import { getEventFiltersFilter } from '../get_event_filters_filter'; -import { getHostIsolationExceptionsFilter } from '../get_host_isolation_exceptions_filter'; - export interface GetFiltersParams { filters: ExceptionListFilter; namespaceTypes: NamespaceType[]; - showTrustedApps: boolean; - showEventFilters: boolean; - showHostIsolationExceptions: boolean; + hideLists: readonly string[]; } -export const getFilters = ({ - filters, - namespaceTypes, - showTrustedApps, - showEventFilters, - showHostIsolationExceptions, -}: GetFiltersParams): string => { +export const getFilters = ({ filters, namespaceTypes, hideLists }: GetFiltersParams): string => { const namespaces = getSavedObjectTypes({ namespaceType: namespaceTypes }); const generalFilters = getGeneralFilters(filters, namespaces); - const trustedAppsFilter = getTrustedAppsFilter(showTrustedApps, namespaces); - const eventFiltersFilter = getEventFiltersFilter(showEventFilters, namespaces); - const hostIsolationExceptionsFilter = getHostIsolationExceptionsFilter( - showHostIsolationExceptions, - namespaces - ); - return [generalFilters, trustedAppsFilter, eventFiltersFilter, hostIsolationExceptionsFilter] + const hideListsFilters = hideLists.map((listId) => { + const filtersByNamespace = namespaces.map((namespace) => { + return `not ${namespace}.attributes.list_id: ${listId}*`; + }); + return `(${filtersByNamespace.join(' AND ')})`; + }); + + return [generalFilters, ...hideListsFilters] .filter((filter) => filter.trim() !== '') .join(' AND '); }; diff --git a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts deleted file mode 100644 index 30466f459cf65..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.test.ts +++ /dev/null @@ -1,49 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getHostIsolationExceptionsFilter } from '.'; - -describe('getHostIsolationExceptionsFilter', () => { - test('it returns filter to search for "exception-list" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(true, ['exception-list']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(true, [ - 'exception-list', - 'exception-list-agnostic', - ]); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(false, ['exception-list']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace host isolation exceptions', () => { - const filter = getHostIsolationExceptionsFilter(false, [ - 'exception-list', - 'exception-list-agnostic', - ]); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts deleted file mode 100644 index d61f8fe7dac19..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_host_isolation_exceptions_filter/index.ts +++ /dev/null @@ -1,27 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getHostIsolationExceptionsFilter = ( - showFilter: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showFilter) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts b/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts deleted file mode 100644 index da178b15390e6..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.test.ts +++ /dev/null @@ -1,39 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { getTrustedAppsFilter } from '.'; - -describe('getTrustedAppsFilter', () => { - test('it returns filter to search for "exception-list" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(true, ['exception-list']); - - expect(filter).toEqual('(exception-list.attributes.list_id: endpoint_trusted_apps*)'); - }); - - test('it returns filter to search for "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(true, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' - ); - }); - - test('it returns filter to exclude "exception-list" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(false, ['exception-list']); - - expect(filter).toEqual('(not exception-list.attributes.list_id: endpoint_trusted_apps*)'); - }); - - test('it returns filter to exclude "exception-list" and "agnostic" namespace trusted apps', () => { - const filter = getTrustedAppsFilter(false, ['exception-list', 'exception-list-agnostic']); - - expect(filter).toEqual( - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' - ); - }); -}); diff --git a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts deleted file mode 100644 index 9c969068d4edf..0000000000000 --- a/packages/kbn-securitysolution-list-utils/src/get_trusted_apps_filter/index.ts +++ /dev/null @@ -1,27 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; -import { SavedObjectType } from '../types'; - -export const getTrustedAppsFilter = ( - showTrustedApps: boolean, - namespaceTypes: SavedObjectType[] -): string => { - if (showTrustedApps) { - const filters = namespaceTypes.map((namespace) => { - return `${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; - }); - return `(${filters.join(' OR ')})`; - } else { - const filters = namespaceTypes.map((namespace) => { - return `not ${namespace}.attributes.list_id: ${ENDPOINT_TRUSTED_APPS_LIST_ID}*`; - }); - return `(${filters.join(' AND ')})`; - } -}; diff --git a/packages/kbn-securitysolution-list-utils/src/index.ts b/packages/kbn-securitysolution-list-utils/src/index.ts index 9e88cac6b5d19..a9fb3d9c3dbc7 100644 --- a/packages/kbn-securitysolution-list-utils/src/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/index.ts @@ -13,7 +13,6 @@ export * from './get_general_filters'; export * from './get_ids_and_namespaces'; export * from './get_saved_object_type'; export * from './get_saved_object_types'; -export * from './get_trusted_apps_filter'; export * from './has_large_value_list'; export * from './helpers'; export * from './types'; diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts index bb4ad821b39cc..69b157835e882 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -48,9 +48,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); await waitForNextUpdate(); @@ -86,9 +83,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -112,7 +106,7 @@ describe('useExceptionLists', () => { }); }); - test('fetches trusted apps lists if "showTrustedApps" is true', async () => { + test('does not fetch specific list id if it is added to the hideLists array', async () => { const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); await act(async () => { @@ -120,6 +114,7 @@ describe('useExceptionLists', () => { useExceptionLists({ errorMessage: 'Uh oh', filterOptions: {}, + hideLists: ['listId-1'], http: mockKibanaHttpService, initialPagination: { page: 1, @@ -128,9 +123,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: true, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -140,192 +132,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch trusted apps lists if "showTrustedApps" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('fetches event filters lists if "showEventFilters" is true', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: true, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (exception-list.attributes.list_id: endpoint_event_filters* OR exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch event filters lists if "showEventFilters" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('fetches host isolation exceptions lists if "hostIsolationExceptionsFilter" is true', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: true, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (exception-list.attributes.list_id: endpoint_host_isolation_exceptions* OR exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', - http: mockKibanaHttpService, - namespaceTypes: 'single,agnostic', - pagination: { page: 1, perPage: 20 }, - signal: new AbortController().signal, - }); - }); - }); - - test('does not fetch host isolation exceptions lists if "showHostIsolationExceptions" is false', async () => { - const spyOnfetchExceptionLists = jest.spyOn(api, 'fetchExceptionLists'); - - await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useExceptionLists({ - errorMessage: 'Uh oh', - filterOptions: {}, - http: mockKibanaHttpService, - initialPagination: { - page: 1, - perPage: 20, - total: 0, - }, - namespaceTypes: ['single', 'agnostic'], - notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, - }) - ); - // NOTE: First `waitForNextUpdate` is initialization - // Second call applies the params - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ - filters: - '(not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', + '(not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -345,6 +152,7 @@ describe('useExceptionLists', () => { created_by: 'Moi', name: 'Sample Endpoint', }, + hideLists: ['listId-1'], http: mockKibanaHttpService, initialPagination: { page: 1, @@ -353,9 +161,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -365,7 +170,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*) AND (not exception-list.attributes.list_id: endpoint_event_filters* AND not exception-list-agnostic.attributes.list_id: endpoint_event_filters*) AND (not exception-list.attributes.list_id: endpoint_host_isolation_exceptions* AND not exception-list-agnostic.attributes.list_id: endpoint_host_isolation_exceptions*)', + '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: listId-1* AND not exception-list-agnostic.attributes.list_id: listId-1*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, @@ -381,16 +186,7 @@ describe('useExceptionLists', () => { UseExceptionListsProps, ReturnExceptionLists >( - ({ - errorMessage, - filterOptions, - http, - initialPagination, - namespaceTypes, - notifications, - showEventFilters, - showTrustedApps, - }) => + ({ errorMessage, filterOptions, http, initialPagination, namespaceTypes, notifications }) => useExceptionLists({ errorMessage, filterOptions, @@ -398,9 +194,6 @@ describe('useExceptionLists', () => { initialPagination, namespaceTypes, notifications, - showEventFilters, - showHostIsolationExceptions: false, - showTrustedApps, }), { initialProps: { @@ -414,9 +207,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }, } ); @@ -436,9 +226,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }); // NOTE: Only need one call here because hook already initilaized await waitForNextUpdate(); @@ -465,9 +252,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization @@ -505,9 +289,6 @@ describe('useExceptionLists', () => { }, namespaceTypes: ['single', 'agnostic'], notifications: mockKibanaNotificationsService, - showEventFilters: false, - showHostIsolationExceptions: false, - showTrustedApps: false, }) ); // NOTE: First `waitForNextUpdate` is initialization diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 65684a7c7d9de..72984a8bcbe92 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -40,6 +40,7 @@ import { userHasPermissions } from '../../helpers'; import { useListsConfig } from '../../../../../containers/detection_engine/lists/use_lists_config'; import { ExceptionsTableItem } from './types'; import { MissingPrivilegesCallOut } from '../../../../../components/callouts/missing_privileges_callout'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../../../../common/endpoint/service/artifacts/constants'; export type Func = () => Promise; @@ -84,9 +85,7 @@ export const ExceptionListsTable = React.memo(() => { http, namespaceTypes: ['single', 'agnostic'], notifications, - showTrustedApps: false, - showEventFilters: false, - showHostIsolationExceptions: false, + hideLists: ALL_ENDPOINT_ARTIFACT_LIST_IDS, }); const [loadingTableInfo, exceptionListsWithRuleRefs, exceptionsListsRef] = useAllExceptionLists({ exceptionLists: exceptions ?? [], From f289a5d78b278466172d0baec25792f9a3a7286d Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Thu, 24 Mar 2022 09:31:40 +0100 Subject: [PATCH 104/132] Add Events tab and External alerts tab to the User page and the User details page (#127953) * Add Events tab to the User page and the User details page * Add External alerts tab to the User page and the User details page * Add cypress tests * Add unit test to EventsQueryTabBody * Memoize navTabs on Users page --- .../common/types/timeline/index.ts | 4 + .../users/users_events_tab.spec.ts | 26 ++++ .../users/users_external_alerts_tab.spec.ts | 29 +++++ .../cypress/screens/users/user_events.ts | 9 ++ .../screens/users/user_external_alerts.ts | 9 ++ .../events_tab/events_query_tab_body.test.tsx | 114 ++++++++++++++++++ .../events_tab}/events_query_tab_body.tsx | 46 ++++--- .../public/common/mock/global_state.ts | 5 +- .../hosts/pages/details/details_tabs.tsx | 9 +- .../public/hosts/pages/hosts_tabs.tsx | 6 +- .../public/hosts/pages/navigation/index.ts | 1 - .../components/events_by_dataset/index.tsx | 6 +- .../user_risk_score_table/index.tsx | 1 + .../public/users/pages/constants.ts | 4 +- .../users/pages/details/details_tabs.tsx | 31 ++++- .../public/users/pages/details/helpers.ts | 32 +++++ .../public/users/pages/details/index.tsx | 8 +- .../public/users/pages/details/nav_tabs.tsx | 24 +++- .../public/users/pages/details/types.ts | 10 +- .../public/users/pages/details/utils.ts | 2 + .../public/users/pages/index.tsx | 83 +++++++------ .../public/users/pages/nav_tabs.tsx | 12 ++ .../navigation/all_users_query_tab_body.tsx | 5 +- .../public/users/pages/navigation/types.ts | 6 +- .../public/users/pages/translations.ts | 14 +++ .../public/users/pages/users.tsx | 8 +- .../public/users/pages/users_tabs.tsx | 14 +++ .../public/users/store/actions.ts | 1 + .../public/users/store/model.ts | 6 + .../public/users/store/reducer.ts | 22 +++- .../timelines/common/types/timeline/index.ts | 2 + .../timelines/public/store/t_grid/types.ts | 2 + 32 files changed, 472 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/users/users_events_tab.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/screens/users/user_events.ts create mode 100644 x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx rename x-pack/plugins/security_solution/public/{hosts/pages/navigation => common/components/events_tab}/events_query_tab_body.tsx (72%) diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index ab60d87973983..5e933efbbc61d 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -314,6 +314,8 @@ export type TimelineWithoutExternalRefs = Omit { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders events tab`, () => { + cy.get(EVENTS_TAB).click(); + + cy.get(EVENTS_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts new file mode 100644 index 0000000000000..a2b62bc892032 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/users/users_external_alerts_tab.spec.ts @@ -0,0 +1,29 @@ +/* + * 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 { + EXTERNAL_ALERTS_TAB, + EXTERNAL_ALERTS_TAB_CONTENT, +} from '../../screens/users/user_external_alerts'; +import { cleanKibana } from '../../tasks/common'; + +import { loginAndWaitForPage } from '../../tasks/login'; + +import { USERS_URL } from '../../urls/navigation'; + +describe('Users external alerts tab', () => { + before(() => { + cleanKibana(); + loginAndWaitForPage(USERS_URL); + }); + + it(`renders external alerts tab`, () => { + cy.get(EXTERNAL_ALERTS_TAB).click(); + + cy.get(EXTERNAL_ALERTS_TAB_CONTENT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts new file mode 100644 index 0000000000000..c2bcd30f9d1c2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_events.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EVENTS_TAB = '[data-test-subj="navigation-events"]'; +export const EVENTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts new file mode 100644 index 0000000000000..bc98b3bc59f37 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/users/user_external_alerts.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EXTERNAL_ALERTS_TAB = '[data-test-subj="navigation-externalAlerts"]'; +export const EXTERNAL_ALERTS_TAB_CONTENT = '[data-test-subj="events-viewer-panel"]'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx new file mode 100644 index 0000000000000..7abca14a2e55f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { TimelineId } from '../../../../common/types'; +import { HostsType } from '../../../hosts/store/model'; +import { TestProviders } from '../../mock'; +import { EventsQueryTabBody, EventsQueryTabBodyComponentProps } from './events_query_tab_body'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import * as tGridActions from '../../../../../timelines/public/store/t_grid/actions'; + +jest.mock('../../lib/kibana', () => { + const original = jest.requireActual('../../lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + ...original.useKibana().services, + cases: { + ui: { + getCasesContext: jest.fn(), + }, + }, + }, + }), + }; +}); + +const FakeStatefulEventsViewer = () =>
{'MockedStatefulEventsViewer'}
; +jest.mock('../events_viewer', () => ({ StatefulEventsViewer: FakeStatefulEventsViewer })); + +jest.mock('../../containers/use_full_screen', () => ({ + useGlobalFullScreen: jest.fn().mockReturnValue({ + globalFullScreen: true, + }), +})); + +describe('EventsQueryTabBody', () => { + const commonProps: EventsQueryTabBodyComponentProps = { + indexNames: ['test-index'], + setQuery: jest.fn(), + timelineId: TimelineId.test, + type: HostsType.page, + endDate: new Date('2000').toISOString(), + startDate: new Date('2000').toISOString(), + }; + + it('renders EventsViewer', () => { + const { queryByText } = render( + + + + ); + + expect(queryByText('MockedStatefulEventsViewer')).toBeInTheDocument(); + }); + + it('renders the matrix histogram when globalFullScreen is false', () => { + (useGlobalFullScreen as jest.Mock).mockReturnValue({ + globalFullScreen: false, + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('eventsHistogramQueryPanel')).toBeInTheDocument(); + }); + + it("doesn't render the matrix histogram when globalFullScreen is true", () => { + (useGlobalFullScreen as jest.Mock).mockReturnValue({ + globalFullScreen: true, + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('eventsHistogramQueryPanel')).not.toBeInTheDocument(); + }); + + it('deletes query when unmouting', () => { + const mockDeleteQuery = jest.fn(); + const { unmount } = render( + + + + ); + unmount(); + + expect(mockDeleteQuery).toHaveBeenCalled(); + }); + + it('initializes t-grid', () => { + const spy = jest.spyOn(tGridActions, 'initializeTGridSettings'); + render( + + + + ); + + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx similarity index 72% rename from x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx rename to x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index 59c3322fb02ed..cfd6546470d4a 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -8,27 +8,28 @@ import React, { useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; +import { Filter } from '@kbn/es-query'; import { TimelineId } from '../../../../common/types/timeline'; -import { StatefulEventsViewer } from '../../../common/components/events_viewer'; +import { StatefulEventsViewer } from '../events_viewer'; import { timelineActions } from '../../../timelines/store/timeline'; -import { HostsComponentsQueryProps } from './types'; -import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; -import { - MatrixHistogramOption, - MatrixHistogramConfigs, -} from '../../../common/components/matrix_histogram/types'; -import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; -import * as i18n from '../translations'; +import { eventsDefaultModel } from '../events_viewer/default_model'; + +import { MatrixHistogram } from '../matrix_histogram'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; +import * as i18n from '../../../hosts/pages/translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { SourcererScopeName } from '../../store/sourcerer/model'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; -import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions'; import { getEventsHistogramLensAttributes } from '../../../common/components/visualization_actions/lens_attributes/hosts/events'; +import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; +import { GlobalTimeArgs } from '../../containers/use_global_time'; +import { MatrixHistogramConfigs, MatrixHistogramOption } from '../matrix_histogram/types'; +import { QueryTabBodyProps as UserQueryTabBodyProps } from '../../../users/pages/navigation/types'; +import { QueryTabBodyProps as HostQueryTabBodyProps } from '../../../hosts/pages/navigation/types'; const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery'; @@ -61,7 +62,17 @@ export const histogramConfigs: MatrixHistogramConfigs = { getLensAttributes: getEventsHistogramLensAttributes, }; -const EventsQueryTabBodyComponent: React.FC = ({ +type QueryTabBodyProps = UserQueryTabBodyProps | HostQueryTabBodyProps; + +export type EventsQueryTabBodyComponentProps = QueryTabBodyProps & { + deleteQuery?: GlobalTimeArgs['deleteQuery']; + indexNames: string[]; + pageFilters?: Filter[]; + setQuery: GlobalTimeArgs['setQuery']; + timelineId: TimelineId; +}; + +const EventsQueryTabBodyComponent: React.FC = ({ deleteQuery, endDate, filterQuery, @@ -69,6 +80,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ pageFilters, setQuery, startDate, + timelineId, }) => { const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); @@ -78,7 +90,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ - id: TimelineId.hostsPageEvents, + id: timelineId, defaultColumns: eventsDefaultModel.columns.map((c) => !tGridEnabled && c.initialWidth == null ? { @@ -89,7 +101,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ ), }) ); - }, [dispatch, tGridEnabled]); + }, [dispatch, tGridEnabled, timelineId]); useEffect(() => { return () => { @@ -119,7 +131,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ defaultModel={eventsDefaultModel} end={endDate} entityType="events" - id={TimelineId.hostsPageEvents} + id={timelineId} leadingControlColumns={leadingControlColumns} pageFilters={pageFilters} renderCellValue={DefaultCellRenderer} diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 7795e76c5fbbb..52f0b1a682097 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -203,7 +203,6 @@ export const mockGlobalState: State = { [usersModel.UsersTableType.allUsers]: { activePage: 0, limit: 10, - // TODO sort: { field: RiskScoreFields.riskScore, direction: Direction.desc }, }, [usersModel.UsersTableType.anomalies]: null, [usersModel.UsersTableType.risk]: { @@ -215,11 +214,15 @@ export const mockGlobalState: State = { }, severitySelection: [], }, + [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 }, + [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 }, }, }, details: { queries: { [usersModel.UsersTableType.anomalies]: null, + [usersModel.UsersTableType.events]: { activePage: 0, limit: 10 }, + [usersModel.UsersTableType.alerts]: { activePage: 0, limit: 10 }, }, }, }, diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx index 891db470161d4..142f3b922f842 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/details_tabs.tsx @@ -15,6 +15,7 @@ import { HostsTableType } from '../../store/model'; import { AnomaliesQueryTabBody } from '../../../common/containers/anomalies/anomalies_query_tab_body'; import { useGlobalTime } from '../../../common/containers/use_global_time'; import { AnomaliesHostTable } from '../../../common/components/ml/tables/anomalies_host_table'; +import { EventsQueryTabBody } from '../../../common/components/events_tab/events_query_tab_body'; import { HostDetailsTabsProps } from './types'; import { type } from './utils'; @@ -23,10 +24,10 @@ import { HostsQueryTabBody, AuthenticationsQueryTabBody, UncommonProcessQueryTabBody, - EventsQueryTabBody, HostAlertsQueryTabBody, HostRiskTabBody, } from '../navigation'; +import { TimelineId } from '../../../../common/types'; export const HostDetailsTabs = React.memo( ({ @@ -98,7 +99,11 @@ export const HostDetailsTabs = React.memo( - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx index 07979c289309a..d7c615c08ec28 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts_tabs.tsx @@ -15,15 +15,17 @@ import { HostsTableType } from '../store/model'; import { AnomaliesQueryTabBody } from '../../common/containers/anomalies/anomalies_query_tab_body'; import { AnomaliesHostTable } from '../../common/components/ml/tables/anomalies_host_table'; import { UpdateDateRange } from '../../common/components/charts/common'; +import { EventsQueryTabBody } from '../../common/components/events_tab/events_query_tab_body'; import { HOSTS_PATH } from '../../../common/constants'; + import { HostsQueryTabBody, HostRiskScoreQueryTabBody, AuthenticationsQueryTabBody, UncommonProcessQueryTabBody, - EventsQueryTabBody, } from './navigation'; import { HostAlertsQueryTabBody } from './navigation/alerts_query_tab_body'; +import { TimelineId } from '../../../common/types'; export const HostsTabs = memo( ({ @@ -96,7 +98,7 @@ export const HostsTabs = memo( - + diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts b/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts index 6b74418549164..3ef211e1aef33 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/index.ts @@ -6,7 +6,6 @@ */ export * from './authentications_query_tab_body'; -export * from './events_query_tab_body'; export * from './hosts_query_tab_body'; export * from './uncommon_process_query_tab_body'; export * from './alerts_query_tab_body'; diff --git a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx index 55903a8b47665..08639f48864b3 100644 --- a/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/events_by_dataset/index.tsx @@ -22,10 +22,12 @@ import { MatrixHistogramConfigs, MatrixHistogramOption, } from '../../../common/components/matrix_histogram/types'; -import { eventsStackByOptions } from '../../../hosts/pages/navigation'; import { convertToBuildEsQuery } from '../../../common/lib/keury'; import { useKibana, useUiSetting$ } from '../../../common/lib/kibana'; -import { histogramConfigs } from '../../../hosts/pages/navigation/events_query_tab_body'; +import { + eventsStackByOptions, + histogramConfigs, +} from '../../../common/components/events_tab/events_query_tab_body'; import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; import { HostsTableType } from '../../../hosts/store/model'; import { InputsModelId } from '../../../common/store/inputs/constants'; diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx index 810525d4f1ca7..0b87165cbe8ac 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_score_table/index.tsx @@ -120,6 +120,7 @@ const UserRiskScoreTableComponent: React.FC = ({ dispatch( usersActions.updateTableSorting({ sort: newSort as RiskScoreSortField, + tableType, }) ); } diff --git a/x-pack/plugins/security_solution/public/users/pages/constants.ts b/x-pack/plugins/security_solution/public/users/pages/constants.ts index 95c0e361e82d8..793d7c6164b2d 100644 --- a/x-pack/plugins/security_solution/public/users/pages/constants.ts +++ b/x-pack/plugins/security_solution/public/users/pages/constants.ts @@ -10,6 +10,6 @@ import { UsersTableType } from '../store/model'; export const usersDetailsPagePath = `${USERS_PATH}/:detailName`; -export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk})`; +export const usersTabPath = `${USERS_PATH}/:tabName(${UsersTableType.allUsers}|${UsersTableType.anomalies}|${UsersTableType.risk}|${UsersTableType.events}|${UsersTableType.alerts})`; -export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies})`; +export const usersDetailsTabPath = `${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.alerts})`; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx index 966fe067fde88..25ada310b74b7 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/details_tabs.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Route, Switch } from 'react-router-dom'; import { UsersTableType } from '../../store/model'; @@ -16,6 +16,10 @@ import { scoreIntervalToDateTime } from '../../../common/components/ml/score/sco import { UpdateDateRange } from '../../../common/components/charts/common'; import { Anomaly } from '../../../common/components/ml/types'; import { usersDetailsPagePath } from '../constants'; +import { TimelineId } from '../../../../common/types'; +import { EventsQueryTabBody } from '../../../common/components/events_tab/events_query_tab_body'; +import { AlertsView } from '../../../common/components/alerts_viewer'; +import { filterUserExternalAlertData } from './helpers'; export const UsersDetailsTabs = React.memo( ({ @@ -29,6 +33,7 @@ export const UsersDetailsTabs = React.memo( type, setAbsoluteRangeDatePicker, detailName, + pageFilters, }) => { const narrowDateRange = useCallback( (score: Anomaly, interval: string) => { @@ -57,6 +62,14 @@ export const UsersDetailsTabs = React.memo( [setAbsoluteRangeDatePicker] ); + const alertsPageFilters = useMemo( + () => + pageFilters != null + ? [...filterUserExternalAlertData, ...pageFilters] + : filterUserExternalAlertData, + [pageFilters] + ); + const tabProps = { deleteQuery, endDate: to, @@ -76,6 +89,22 @@ export const UsersDetailsTabs = React.memo( + + + + + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts b/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts index c96d21d3110e4..daa02df2fb9ca 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/helpers.ts @@ -30,3 +30,35 @@ export const getUsersDetailsPageFilters = (userName: string): Filter[] => [ }, }, ]; + +export const filterUserExternalAlertData: Filter[] = [ + { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + exists: { + field: 'user.name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: + '{"query": {"bool": {"filter": [{"bool": {"should": [{"exists": {"field": "user.name"}}],"minimum_should_match": 1}}]}}}', + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx index e68c37d6b4042..36ace6a6b4543 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/index.tsx @@ -50,6 +50,8 @@ import { useQueryInspector } from '../../../common/components/page/manage_query' import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type'; import { UsersType } from '../../store/model'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; const QUERY_ID = 'UsersDetailsQueryId'; const UsersDetailsComponent: React.FC = ({ @@ -110,6 +112,8 @@ const UsersDetailsComponent: React.FC = ({ skip: selectedPatterns.length === 0, }); + const capabilities = useMlCapabilities(); + useQueryInspector({ setQuery, deleteQuery, refetch, inspect, loading, queryId: QUERY_ID }); return ( @@ -165,7 +169,9 @@ const UsersDetailsComponent: React.FC = ({ - + diff --git a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx index 47bc406876c22..9671bd4ee38d0 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/details/nav_tabs.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash/fp'; import * as i18n from '../translations'; import { UsersDetailsNavTab } from './types'; import { UsersTableType } from '../../store/model'; @@ -13,13 +14,32 @@ import { USERS_PATH } from '../../../../common/constants'; const getTabsOnUsersDetailsUrl = (userName: string, tabName: UsersTableType) => `${USERS_PATH}/${userName}/${tabName}`; -export const navTabsUsersDetails = (userName: string): UsersDetailsNavTab => { - return { +export const navTabsUsersDetails = ( + userName: string, + hasMlUserPermissions: boolean +): UsersDetailsNavTab => { + const userDetailsNavTabs = { [UsersTableType.anomalies]: { id: UsersTableType.anomalies, name: i18n.NAVIGATION_ANOMALIES_TITLE, href: getTabsOnUsersDetailsUrl(userName, UsersTableType.anomalies), disabled: false, }, + [UsersTableType.events]: { + id: UsersTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.events), + disabled: false, + }, + [UsersTableType.alerts]: { + id: UsersTableType.alerts, + name: i18n.NAVIGATION_ALERTS_TITLE, + href: getTabsOnUsersDetailsUrl(userName, UsersTableType.alerts), + disabled: false, + }, }; + + return hasMlUserPermissions + ? userDetailsNavTabs + : omit([UsersTableType.anomalies], userDetailsNavTabs); }; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/types.ts b/x-pack/plugins/security_solution/public/users/pages/details/types.ts index 69974678bf4d9..1608d4b735b59 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/types.ts @@ -44,7 +44,15 @@ export type UsersDetailsComponentProps = UsersDetailsComponentReduxProps & UsersDetailsComponentDispatchProps & UsersQueryProps; -type KeyUsersDetailsNavTab = UsersTableType.anomalies; +export type KeyUsersDetailsNavTabWithoutMlPermission = UsersTableType.events & + UsersTableType.alerts; + +type KeyUsersDetailsNavTabWithMlPermission = KeyUsersDetailsNavTabWithoutMlPermission & + UsersTableType.anomalies; + +type KeyUsersDetailsNavTab = + | KeyUsersDetailsNavTabWithoutMlPermission + | KeyUsersDetailsNavTabWithMlPermission; export type UsersDetailsNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts index eb2820c6d4869..f4bdd7e6caa67 100644 --- a/x-pack/plugins/security_solution/public/users/pages/details/utils.ts +++ b/x-pack/plugins/security_solution/public/users/pages/details/utils.ts @@ -24,6 +24,8 @@ const TabNameMappedToI18nKey: Record = { [UsersTableType.allUsers]: i18n.NAVIGATION_ALL_USERS_TITLE, [UsersTableType.anomalies]: i18n.NAVIGATION_ANOMALIES_TITLE, [UsersTableType.risk]: i18n.NAVIGATION_RISK_TITLE, + [UsersTableType.events]: i18n.NAVIGATION_EVENTS_TITLE, + [UsersTableType.alerts]: i18n.NAVIGATION_ALERTS_TITLE, }; export const getBreadcrumbs = ( diff --git a/x-pack/plugins/security_solution/public/users/pages/index.tsx b/x-pack/plugins/security_solution/public/users/pages/index.tsx index 0b6b103b78176..f1f4e545ae9fd 100644 --- a/x-pack/plugins/security_solution/public/users/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/index.tsx @@ -12,44 +12,53 @@ import { UsersTableType } from '../store/model'; import { Users } from './users'; import { UsersDetails } from './details'; import { usersDetailsPagePath, usersDetailsTabPath, usersTabPath } from './constants'; +import { useMlCapabilities } from '../../common/components/ml/hooks/use_ml_capabilities'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; -export const UsersContainer = React.memo(() => ( - - - - +export const UsersContainer = React.memo(() => { + const capabilities = useMlCapabilities(); + const hasMlPermissions = hasMlUserPermissions(capabilities); - } - /> - ( - - )} - /> - ( - - )} - /> - -)); + return ( + + + + + + } + /> + ( + + )} + /> + ( + + )} + /> + + ); +}); UsersContainer.displayName = 'UsersContainer'; diff --git a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx index 35124d1deddb1..254807eae27cc 100644 --- a/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/nav_tabs.tsx @@ -38,6 +38,18 @@ export const navTabsUsers = ( href: getTabsOnUsersUrl(UsersTableType.risk), disabled: false, }, + [UsersTableType.events]: { + id: UsersTableType.events, + name: i18n.NAVIGATION_EVENTS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.events), + disabled: false, + }, + [UsersTableType.alerts]: { + id: UsersTableType.alerts, + name: i18n.NAVIGATION_ALERTS_TITLE, + href: getTabsOnUsersUrl(UsersTableType.alerts), + disabled: false, + }, }; if (!hasMlUserPermissions) { diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx index 8fa963ef179f2..b5c8b199fda54 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/all_users_query_tab_body.tsx @@ -42,12 +42,11 @@ export const AllUsersQueryTabBody = ({ indexNames, skip: querySkip, startDate, - // TODO Fix me + // TODO Move authentication table and hook store to 'public/common' folder when 'usersEnabled' FF is removed // @ts-ignore type, deleteQuery, }); - // TODO Use a different table return ( diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts index f3fd099d78548..d5c49590dad60 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/types.ts @@ -10,7 +10,11 @@ import { ESTermQuery } from '../../../../common/typed_json'; import { DocValueFields } from '../../../../../timelines/common'; import { NavTab } from '../../../common/components/navigation/types'; -type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & UsersTableType.risk; +type KeyUsersNavTabWithoutMlPermission = UsersTableType.allUsers & + UsersTableType.risk & + UsersTableType.events & + UsersTableType.alerts; + type KeyUsersNavTabWithMlPermission = KeyUsersNavTabWithoutMlPermission & UsersTableType.anomalies; type KeyUsersNavTab = KeyUsersNavTabWithoutMlPermission | KeyUsersNavTabWithMlPermission; diff --git a/x-pack/plugins/security_solution/public/users/pages/translations.ts b/x-pack/plugins/security_solution/public/users/pages/translations.ts index 7744ef125ffa2..96dcf8d2c8871 100644 --- a/x-pack/plugins/security_solution/public/users/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/users/pages/translations.ts @@ -31,3 +31,17 @@ export const NAVIGATION_RISK_TITLE = i18n.translate( defaultMessage: 'Users by risk', } ); + +export const NAVIGATION_EVENTS_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.eventsTitle', + { + defaultMessage: 'Events', + } +); + +export const NAVIGATION_ALERTS_TITLE = i18n.translate( + 'xpack.securitySolution.users.navigation.alertsTitle', + { + defaultMessage: 'External alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/users/pages/users.tsx b/x-pack/plugins/security_solution/public/users/pages/users.tsx index bd6cc2d097c46..6acd2ddf32a3c 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users.tsx @@ -162,6 +162,10 @@ const UsersComponent = () => { const capabilities = useMlCapabilities(); const riskyUsersFeatureEnabled = useIsExperimentalFeatureEnabled('riskyUsersEnabled'); + const navTabs = useMemo( + () => navTabsUsers(hasMlUserPermissions(capabilities), riskyUsersFeatureEnabled), + [capabilities, riskyUsersFeatureEnabled] + ); return ( <> @@ -197,9 +201,7 @@ const UsersComponent = () => { - + diff --git a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx index 50de49d1e4af1..522ff4c009504 100644 --- a/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/users_tabs.tsx @@ -19,6 +19,9 @@ import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_ import { UpdateDateRange } from '../../common/components/charts/common'; import { UserRiskScoreQueryTabBody } from './navigation/user_risk_score_tab_body'; +import { EventsQueryTabBody } from '../../common/components/events_tab/events_query_tab_body'; +import { TimelineId } from '../../../common/types'; +import { AlertsView } from '../../common/components/alerts_viewer'; export const UsersTabs = memo( ({ @@ -83,6 +86,17 @@ export const UsersTabs = memo( + + + + + + ); } diff --git a/x-pack/plugins/security_solution/public/users/store/actions.ts b/x-pack/plugins/security_solution/public/users/store/actions.ts index 262604f68bdf5..b1d83f29da8c8 100644 --- a/x-pack/plugins/security_solution/public/users/store/actions.ts +++ b/x-pack/plugins/security_solution/public/users/store/actions.ts @@ -31,6 +31,7 @@ export const updateTableActivePage = actionCreator<{ export const updateTableSorting = actionCreator<{ sort: RiskScoreSortField; + tableType: usersModel.UsersTableType.risk; }>('UPDATE_USERS_SORTING'); export const updateUserRiskScoreSeverityFilter = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/users/store/model.ts b/x-pack/plugins/security_solution/public/users/store/model.ts index 22630d34d48a8..6e4a3730eca86 100644 --- a/x-pack/plugins/security_solution/public/users/store/model.ts +++ b/x-pack/plugins/security_solution/public/users/store/model.ts @@ -16,6 +16,8 @@ export enum UsersTableType { allUsers = 'allUsers', anomalies = 'anomalies', risk = 'userRisk', + events = 'events', + alerts = 'externalAlerts', } export type AllUsersTables = UsersTableType; @@ -36,10 +38,14 @@ export interface UsersQueries { [UsersTableType.allUsers]: AllUsersQuery; [UsersTableType.anomalies]: null | undefined; [UsersTableType.risk]: UsersRiskScoreQuery; + [UsersTableType.events]: BasicQueryPaginated; + [UsersTableType.alerts]: BasicQueryPaginated; } export interface UserDetailsQueries { [UsersTableType.anomalies]: null | undefined; + [UsersTableType.events]: BasicQueryPaginated; + [UsersTableType.alerts]: BasicQueryPaginated; } export interface UsersPageModel { diff --git a/x-pack/plugins/security_solution/public/users/store/reducer.ts b/x-pack/plugins/security_solution/public/users/store/reducer.ts index 26b2e8a225d5a..4b263eecb8c5a 100644 --- a/x-pack/plugins/security_solution/public/users/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/users/store/reducer.ts @@ -37,11 +37,27 @@ export const initialUsersState: UsersModel = { severitySelection: [], }, [UsersTableType.anomalies]: null, + [UsersTableType.events]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, + [UsersTableType.alerts]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, }, }, details: { queries: { [UsersTableType.anomalies]: null, + [UsersTableType.events]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, + [UsersTableType.alerts]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + }, }, }, }; @@ -80,14 +96,14 @@ export const usersReducer = reducerWithInitialState(initialUsersState) }, }, })) - .case(updateTableSorting, (state, { sort }) => ({ + .case(updateTableSorting, (state, { sort, tableType }) => ({ ...state, page: { ...state.page, queries: { ...state.page.queries, - [UsersTableType.risk]: { - ...state.page.queries[UsersTableType.risk], + [tableType]: { + ...state.page.queries[tableType], sort, }, }, diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index a6c8ed1b74bff..1e12baf13c2db 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -310,6 +310,8 @@ export type SavedTimelineNote = runtimeTypes.TypeOf Date: Thu, 24 Mar 2022 09:42:46 +0100 Subject: [PATCH 105/132] added missing package field mappings (#128391) --- .../elasticsearch/template/template.test.ts | 49 +++++++++++++++++++ .../epm/elasticsearch/template/template.ts | 5 +- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 86edf1c5e4064..77ce3779f2319 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -157,6 +157,27 @@ describe('EPM template', () => { expect(mappings).toEqual(longWithIndexFalseMapping); }); + it('tests processing keyword field with doc_values false', () => { + const keywordWithIndexFalseYml = ` +- name: keywordIndexFalse + type: keyword + doc_values: false +`; + const keywordWithIndexFalseMapping = { + properties: { + keywordIndexFalse: { + ignore_above: 1024, + type: 'keyword', + doc_values: false, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithIndexFalseYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithIndexFalseMapping); + }); + it('tests processing text field with multi fields', () => { const textWithMultiFieldsLiteralYml = ` - name: textWithMultiFields @@ -378,6 +399,34 @@ describe('EPM template', () => { expect(mappings).toEqual(keywordWithMultiFieldsMapping); }); + it('tests processing wildcard field with multi fields with match_only_text type', () => { + const wildcardWithMultiFieldsLiteralYml = ` +- name: wildcardWithMultiFields + type: wildcard + multi_fields: + - name: text + type: match_only_text +`; + + const wildcardWithMultiFieldsMapping = { + properties: { + wildcardWithMultiFields: { + ignore_above: 1024, + type: 'wildcard', + fields: { + text: { + type: 'match_only_text', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(wildcardWithMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(wildcardWithMultiFieldsMapping); + }); + it('tests processing object field with no other attributes', () => { const objectFieldLiteralYml = ` - name: objectField diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 21c7351b31384..909b593649fcd 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -244,9 +244,8 @@ function generateMultiFields(fields: Fields): MultiFields { multiFields[f.name] = { ...generateKeywordMapping(f), type: f.type }; break; case 'long': - multiFields[f.name] = { type: f.type }; - break; case 'double': + case 'match_only_text': multiFields[f.name] = { type: f.type }; break; } @@ -302,7 +301,7 @@ function getDefaultProperties(field: Field): Properties { if (field.index !== undefined) { properties.index = field.index; } - if (field.doc_values) { + if (field.doc_values !== undefined) { properties.doc_values = field.doc_values; } if (field.copy_to) { From 8f6322596c8765eaf61d6fb99908cdf0004dda01 Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Thu, 24 Mar 2022 09:45:19 +0100 Subject: [PATCH 106/132] Add events-first (reverse) search for IM rule (#127428) * WIP * Add tets and refactoring * Add abstraction to run threat im rule * Add per page to search threat indicators * Fix tests and linting * fix tests * Add integrations tests * Fix IP Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../build_threat_mapping_filter.test.ts | 66 ++- .../build_threat_mapping_filter.ts | 15 +- .../threat_mapping/create_event_signal.ts | 155 +++++++ .../threat_mapping/create_threat_signal.ts | 1 + .../threat_mapping/create_threat_signals.ts | 186 +++++--- .../enrich_signal_threat_matches.test.ts | 105 +++++ .../enrich_signal_threat_matches.ts | 59 ++- .../signals/threat_mapping/get_event_count.ts | 54 ++- .../signals/threat_mapping/get_threat_list.ts | 26 +- .../signals/threat_mapping/types.ts | 72 +++ .../signals/threat_mapping/utils.ts | 6 +- .../tests/create_threat_matching.ts | 439 +++++++++++++++++- .../filebeat/threat_intel/data.json | 142 ++++++ 13 files changed, 1238 insertions(+), 88 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts index a96eb50af3c50..1b4baaa0607b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.test.ts @@ -36,6 +36,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1025, + entryKey: 'value', }) ).toThrow('chunk sizes cannot exceed 1024 in size'); }); @@ -44,28 +45,28 @@ describe('build_threat_mapping_filter', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; expect(() => - buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023 }) + buildThreatMappingFilter({ threatMapping, threatList, chunkSize: 1023, entryKey: 'value' }) ).not.toThrow(); }); test('it should create the correct entries when using the default mocks', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - const filter = buildThreatMappingFilter({ threatMapping, threatList }); + const filter = buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(filter).toEqual(getThreatMappingFilterMock()); }); test('it should not mutate the original threatMapping', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - buildThreatMappingFilter({ threatMapping, threatList }); + buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(threatMapping).toEqual(getThreatMappingMock()); }); test('it should not mutate the original threatListItem', () => { const threatMapping = getThreatMappingMock(); const threatList = getThreatListSearchResponseMock().hits.hits; - buildThreatMappingFilter({ threatMapping, threatList }); + buildThreatMappingFilter({ threatMapping, threatList, entryKey: 'value' }); expect(threatList).toEqual(getThreatListSearchResponseMock().hits.hits); }); }); @@ -75,7 +76,7 @@ describe('build_threat_mapping_filter', () => { const threatMapping = getThreatMappingMock(); const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const item = filterThreatMapping({ threatMapping, threatListItem }); + const item = filterThreatMapping({ threatMapping, threatListItem, entryKey: 'value' }); const expected = getFilterThreatMapping(); expect(item).toEqual(expected); }); @@ -84,7 +85,11 @@ describe('build_threat_mapping_filter', () => { const [firstElement] = getThreatMappingMock(); // get only the first element const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const item = filterThreatMapping({ threatMapping: [firstElement], threatListItem }); + const item = filterThreatMapping({ + threatMapping: [firstElement], + threatListItem, + entryKey: 'value', + }); const [firstElementFilter] = getFilterThreatMapping(); // get only the first element to compare expect(item).toEqual([firstElementFilter]); }); @@ -96,6 +101,7 @@ describe('build_threat_mapping_filter', () => { filterThreatMapping({ threatMapping, threatListItem, + entryKey: 'value', }); expect(threatMapping).toEqual(getThreatMappingMock()); }); @@ -107,6 +113,7 @@ describe('build_threat_mapping_filter', () => { filterThreatMapping({ threatMapping, threatListItem, + entryKey: 'value', }); expect(threatListItem).toEqual(getThreatListSearchResponseMock().hits.hits[0]); }); @@ -142,6 +149,7 @@ describe('build_threat_mapping_filter', () => { 'host.name': ['host-1'], }, }), + entryKey: 'value', }); expect(item).toEqual([]); }); @@ -185,6 +193,7 @@ describe('build_threat_mapping_filter', () => { 'host.name': ['host-1'], }, }), + entryKey: 'value', }); expect(item).toEqual([ { @@ -204,7 +213,11 @@ describe('build_threat_mapping_filter', () => { test('it should return two clauses given a single entry', () => { const [{ entries: threatMappingEntries }] = getThreatMappingMock(); // get the first element const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -219,7 +232,11 @@ describe('build_threat_mapping_filter', () => { test('it should return an empty array given an empty array', () => { const threatListItem = getThreatListItemMock(); - const innerClause = createInnerAndClauses({ threatMappingEntries: [], threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries: [], + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual([]); }); @@ -234,7 +251,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -263,7 +284,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); const { bool: { should: [ @@ -290,7 +315,11 @@ describe('build_threat_mapping_filter', () => { }, ]; const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createInnerAndClauses({ threatMappingEntries, threatListItem }); + const innerClause = createInnerAndClauses({ + threatMappingEntries, + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual([]); }); }); @@ -299,7 +328,7 @@ describe('build_threat_mapping_filter', () => { test('it should return all clauses given the entries', () => { const threatMapping = getThreatMappingMock(); const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createAndOrClauses({ threatMapping, threatListItem }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem, entryKey: 'value' }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); @@ -310,13 +339,17 @@ describe('build_threat_mapping_filter', () => { ...getThreatListSearchResponseMock().hits.hits[0]._source, foo: 'bar', }; - const innerClause = createAndOrClauses({ threatMapping, threatListItem }); + const innerClause = createAndOrClauses({ threatMapping, threatListItem, entryKey: 'value' }); expect(innerClause).toEqual(getThreatMappingFilterShouldMock()); }); test('it should return an empty boolean given an empty array', () => { const threatListItem = getThreatListSearchResponseMock().hits.hits[0]; - const innerClause = createAndOrClauses({ threatMapping: [], threatListItem }); + const innerClause = createAndOrClauses({ + threatMapping: [], + threatListItem, + entryKey: 'value', + }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); @@ -325,6 +358,7 @@ describe('build_threat_mapping_filter', () => { const innerClause = createAndOrClauses({ threatMapping, threatListItem: getThreatListItemMock({ _source: {}, fields: {} }), + entryKey: 'value', }); expect(innerClause).toEqual({ bool: { minimum_should_match: 1, should: [] } }); }); @@ -338,6 +372,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [getThreatMappingFilterShouldMock()], minimum_should_match: 1 }, @@ -352,6 +387,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [], minimum_should_match: 1 }, @@ -365,6 +401,7 @@ describe('build_threat_mapping_filter', () => { threatMapping: [], threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [], minimum_should_match: 1 }, @@ -399,6 +436,7 @@ describe('build_threat_mapping_filter', () => { threatMapping, threatList, chunkSize: 1024, + entryKey: 'value', }); const expected: BooleanFilter = { bool: { should: [getThreatMappingFilterShouldMock()], minimum_should_match: 1 }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts index dfc66f7c5222e..82b6c5a6c523f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/build_threat_mapping_filter.ts @@ -25,6 +25,7 @@ export const buildThreatMappingFilter = ({ threatMapping, threatList, chunkSize, + entryKey = 'value', }: BuildThreatMappingFilterOptions): Filter => { const computedChunkSize = chunkSize ?? MAX_CHUNK_SIZE; if (computedChunkSize > 1024) { @@ -34,6 +35,7 @@ export const buildThreatMappingFilter = ({ threatMapping, threatList, chunkSize: computedChunkSize, + entryKey, }); const filterChunk: Filter = { meta: { @@ -52,11 +54,12 @@ export const buildThreatMappingFilter = ({ export const filterThreatMapping = ({ threatMapping, threatListItem, + entryKey, }: FilterThreatMappingOptions): ThreatMapping => threatMapping .map((threatMap) => { const atLeastOneItemMissingInThreatList = threatMap.entries.some((entry) => { - const itemValue = get(entry.value, threatListItem.fields); + const itemValue = get(entry[entryKey], threatListItem.fields); return itemValue == null || itemValue.length !== 1; }); if (atLeastOneItemMissingInThreatList) { @@ -70,9 +73,10 @@ export const filterThreatMapping = ({ export const createInnerAndClauses = ({ threatMappingEntries, threatListItem, + entryKey, }: CreateInnerAndClausesOptions): BooleanFilter[] => { return threatMappingEntries.reduce((accum, threatMappingEntry) => { - const value = get(threatMappingEntry.value, threatListItem.fields); + const value = get(threatMappingEntry[entryKey], threatListItem.fields); if (value != null && value.length === 1) { // These values could be potentially 10k+ large so mutating the array intentionally accum.push({ @@ -80,7 +84,7 @@ export const createInnerAndClauses = ({ should: [ { match: { - [threatMappingEntry.field]: { + [threatMappingEntry[entryKey === 'field' ? 'value' : 'field']]: { query: value[0], _name: encodeThreatMatchNamedQuery({ id: threatListItem._id, @@ -103,11 +107,13 @@ export const createInnerAndClauses = ({ export const createAndOrClauses = ({ threatMapping, threatListItem, + entryKey, }: CreateAndOrClausesOptions): BooleanFilter => { const should = threatMapping.reduce((accum, threatMap) => { const innerAndClauses = createInnerAndClauses({ threatMappingEntries: threatMap.entries, threatListItem, + entryKey, }); if (innerAndClauses.length !== 0) { // These values could be potentially 10k+ large so mutating the array intentionally @@ -124,15 +130,18 @@ export const buildEntriesMappingFilter = ({ threatMapping, threatList, chunkSize, + entryKey, }: BuildEntriesMappingFilterOptions): BooleanFilter => { const combinedShould = threatList.reduce((accum, threatListSearchItem) => { const filteredEntries = filterThreatMapping({ threatMapping, threatListItem: threatListSearchItem, + entryKey, }); const queryWithAndOrClause = createAndOrClauses({ threatMapping: filteredEntries, threatListItem: threatListSearchItem, + entryKey, }); if (queryWithAndOrClause.bool.should.length !== 0) { // These values can be 10k+ large, so using a push here for performance diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts new file mode 100644 index 0000000000000..c1beb55e90a85 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_event_signal.ts @@ -0,0 +1,155 @@ +/* + * 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 { buildThreatMappingFilter } from './build_threat_mapping_filter'; +import { getFilter } from '../get_filter'; +import { searchAfterAndBulkCreate } from '../search_after_bulk_create'; +import { buildReasonMessageForThreatMatchAlert } from '../reason_formatters'; +import { CreateEventSignalOptions } from './types'; +import { SearchAfterAndBulkCreateReturnType, SignalSearchResponse } from '../types'; +import { getAllThreatListHits } from './get_threat_list'; +import { + enrichSignalThreatMatches, + getSignalMatchesFromThreatList, +} from './enrich_signal_threat_matches'; + +export const createEventSignal = async ({ + alertId, + buildRuleMessage, + bulkCreate, + completeRule, + currentResult, + currentEventList, + eventsTelemetry, + exceptionItems, + filters, + inputIndex, + language, + listClient, + logger, + outputIndex, + query, + savedId, + searchAfterSize, + services, + threatMapping, + tuple, + type, + wrapHits, + threatQuery, + threatFilters, + threatLanguage, + threatIndex, + threatListConfig, + threatIndicatorPath, + perPage, +}: CreateEventSignalOptions): Promise => { + const threatFilter = buildThreatMappingFilter({ + threatMapping, + threatList: currentEventList, + entryKey: 'field', + }); + + if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { + // empty event list and we do not want to return everything as being + // a hit so opt to return the existing result. + logger.debug( + buildRuleMessage( + 'Indicator items are empty after filtering for missing data, returning without attempting a match' + ) + ); + return currentResult; + } else { + const threatListHits = await getAllThreatListHits({ + esClient: services.scopedClusterClient.asCurrentUser, + exceptionItems, + threatFilters: [...threatFilters, threatFilter], + query: threatQuery, + language: threatLanguage, + index: threatIndex, + logger, + buildRuleMessage, + threatListConfig: { + _source: [`${threatIndicatorPath}.*`, 'threat.feed.*'], + fields: undefined, + }, + perPage, + }); + + const signalMatches = getSignalMatchesFromThreatList(threatListHits); + + const ids = signalMatches.map((item) => item.signalId); + + const indexFilter = { + query: { + bool: { + filter: { + ids: { values: ids }, + }, + }, + }, + }; + + const esFilter = await getFilter({ + type, + filters: [...filters, indexFilter], + language, + query, + savedId, + services, + index: inputIndex, + lists: exceptionItems, + }); + + logger.debug( + buildRuleMessage( + `${ids?.length} matched signals found from ${threatListHits.length} indicators` + ) + ); + + const threatEnrichment = (signals: SignalSearchResponse): Promise => + enrichSignalThreatMatches( + signals, + () => Promise.resolve(threatListHits), + threatIndicatorPath, + signalMatches + ); + + const result = await searchAfterAndBulkCreate({ + buildReasonMessage: buildReasonMessageForThreatMatchAlert, + buildRuleMessage, + bulkCreate, + completeRule, + enrichment: threatEnrichment, + eventsTelemetry, + exceptionsList: exceptionItems, + filter: esFilter, + id: alertId, + inputIndexPattern: inputIndex, + listClient, + logger, + pageSize: searchAfterSize, + services, + signalsIndex: outputIndex, + sortOrder: 'desc', + trackTotalHits: false, + tuple, + wrapHits, + }); + + logger.debug( + buildRuleMessage( + `${ + threatFilter.query?.bool.should.length + } items have completed match checks and the total times to search were ${ + result.searchAfterTimes.length !== 0 ? result.searchAfterTimes : '(unknown) ' + }ms` + ) + ); + return result; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index bf72a13ba0450..220bebbaa4d21 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -41,6 +41,7 @@ export const createThreatSignal = async ({ const threatFilter = buildThreatMappingFilter({ threatMapping, threatList: currentThreatList, + entryKey: 'value', }); if (!threatFilter.query || threatFilter.query?.bool.should.length === 0) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 292a5f897885f..eecc55a67ad52 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -7,13 +7,17 @@ import chunk from 'lodash/fp/chunk'; import { getThreatList, getThreatListCount } from './get_threat_list'; - -import { CreateThreatSignalsOptions } from './types'; +import { + CreateThreatSignalsOptions, + CreateSignalInterface, + GetDocumentListInterface, +} from './types'; import { createThreatSignal } from './create_threat_signal'; +import { createEventSignal } from './create_event_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; import { buildExecutionIntervalValidator, combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; -import { getEventCount } from './get_event_count'; +import { getEventCount, getEventList } from './get_event_count'; import { getMappingFilters } from './get_mapping_filters'; export const createThreatSignals = async ({ @@ -85,7 +89,7 @@ export const createThreatSignals = async ({ return results; } - let threatListCount = await getThreatListCount({ + const threatListCount = await getThreatListCount({ esClient: services.scopedClusterClient.asCurrentUser, exceptionItems, threatFilters: allThreatFilters, @@ -101,20 +105,6 @@ export const createThreatSignals = async ({ _source: false, }; - let threatList = await getThreatList({ - esClient: services.scopedClusterClient.asCurrentUser, - exceptionItems, - threatFilters: allThreatFilters, - query: threatQuery, - language: threatLanguage, - index: threatIndex, - searchAfter: undefined, - logger, - buildRuleMessage, - perPage, - threatListConfig, - }); - const threatEnrichment = buildThreatEnrichment({ buildRuleMessage, exceptionItems, @@ -127,12 +117,124 @@ export const createThreatSignals = async ({ threatQuery, }); - while (threatList.hits.hits.length !== 0) { - verifyExecutionCanProceed(); - const chunks = chunk(itemsPerSearch, threatList.hits.hits); - logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); - const concurrentSearchesPerformed = chunks.map>( - (slicedChunk) => + const createSignals = async ({ + getDocumentList, + createSignal, + totalDocumentCount, + }: { + getDocumentList: GetDocumentListInterface; + createSignal: CreateSignalInterface; + totalDocumentCount: number; + }) => { + let list = await getDocumentList({ searchAfter: undefined }); + let documentCount = totalDocumentCount; + + while (list.hits.hits.length !== 0) { + verifyExecutionCanProceed(); + const chunks = chunk(itemsPerSearch, list.hits.hits); + logger.debug( + buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`) + ); + const concurrentSearchesPerformed = + chunks.map>(createSignal); + const searchesPerformed = await Promise.all(concurrentSearchesPerformed); + results = combineConcurrentResults(results, searchesPerformed); + documentCount -= list.hits.hits.length; + logger.debug( + buildRuleMessage( + `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, + `search times of ${results.searchAfterTimes}ms,`, + `bulk create times ${results.bulkCreateTimes}ms,`, + `all successes are ${results.success}` + ) + ); + if (results.createdSignalsCount >= params.maxSignals) { + logger.debug( + buildRuleMessage( + `Indicator match has reached its max signals count ${params.maxSignals}. Additional documents not checked are ${documentCount}` + ) + ); + break; + } + logger.debug(buildRuleMessage(`Documents items left to check are ${documentCount}`)); + + list = await getDocumentList({ + searchAfter: list.hits.hits[list.hits.hits.length - 1].sort, + }); + } + }; + + if (eventCount < threatListCount) { + await createSignals({ + totalDocumentCount: eventCount, + getDocumentList: async ({ searchAfter }) => + getEventList({ + services, + exceptionItems, + filters: allEventFilters, + query, + language, + index: inputIndex, + searchAfter, + logger, + buildRuleMessage, + perPage, + tuple, + }), + + createSignal: (slicedChunk) => + createEventSignal({ + alertId, + buildRuleMessage, + bulkCreate, + completeRule, + currentResult: results, + currentEventList: slicedChunk, + eventsTelemetry, + exceptionItems, + filters: allEventFilters, + inputIndex, + language, + listClient, + logger, + outputIndex, + query, + savedId, + searchAfterSize, + services, + threatEnrichment, + threatMapping, + tuple, + type, + wrapHits, + threatQuery, + threatFilters: allThreatFilters, + threatLanguage, + threatIndex, + threatListConfig, + threatIndicatorPath, + perPage, + }), + }); + } else { + await createSignals({ + totalDocumentCount: threatListCount, + getDocumentList: async ({ searchAfter }) => + getThreatList({ + esClient: services.scopedClusterClient.asCurrentUser, + exceptionItems, + threatFilters: allThreatFilters, + query: threatQuery, + language: threatLanguage, + index: threatIndex, + searchAfter, + logger, + buildRuleMessage, + perPage, + threatListConfig, + }), + + createSignal: (slicedChunk) => createThreatSignal({ alertId, buildRuleMessage, @@ -157,41 +259,7 @@ export const createThreatSignals = async ({ tuple, type, wrapHits, - }) - ); - const searchesPerformed = await Promise.all(concurrentSearchesPerformed); - results = combineConcurrentResults(results, searchesPerformed); - threatListCount -= threatList.hits.hits.length; - logger.debug( - buildRuleMessage( - `Concurrent indicator match searches completed with ${results.createdSignalsCount} signals found`, - `search times of ${results.searchAfterTimes}ms,`, - `bulk create times ${results.bulkCreateTimes}ms,`, - `all successes are ${results.success}` - ) - ); - if (results.createdSignalsCount >= params.maxSignals) { - logger.debug( - buildRuleMessage( - `Indicator match has reached its max signals count ${params.maxSignals}. Additional indicator items not checked are ${threatListCount}` - ) - ); - break; - } - logger.debug(buildRuleMessage(`Indicator items left to check are ${threatListCount}`)); - - threatList = await getThreatList({ - esClient: services.scopedClusterClient.asCurrentUser, - exceptionItems, - query: threatQuery, - language: threatLanguage, - threatFilters: allThreatFilters, - index: threatIndex, - searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, - buildRuleMessage, - logger, - perPage, - threatListConfig, + }), }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts index 4e249711bb890..66e44e5796eb6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.test.ts @@ -14,6 +14,7 @@ import { buildEnrichments, enrichSignalThreatMatches, groupAndMergeSignalMatches, + getSignalMatchesFromThreatList, } from './enrich_signal_threat_matches'; import { getNamedQueryMock, @@ -793,3 +794,107 @@ describe('enrichSignalThreatMatches', () => { ]); }); }); + +describe('getSignalMatchesFromThreatList', () => { + it('return empty array if there no threat indicators', () => { + const signalMatches = getSignalMatchesFromThreatList(); + expect(signalMatches).toEqual([]); + }); + + it("return empty array if threat indicators doesn't have matched query", () => { + const signalMatches = getSignalMatchesFromThreatList([getThreatListItemMock()]); + expect(signalMatches).toEqual([]); + }); + + it('return signal mathces from threat indicators', () => { + const signalMatches = getSignalMatchesFromThreatList([ + getThreatListItemMock({ + _id: 'threatId', + matched_queries: [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId1', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId2', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + ], + }), + ]); + + const queries = [ + { + field: 'event.domain', + value: 'threat.indicator.domain', + index: 'threat_index', + id: 'threatId', + }, + ]; + + expect(signalMatches).toEqual([ + { + signalId: 'signalId1', + queries, + }, + { + signalId: 'signalId2', + queries, + }, + ]); + }); + + it('merge signal mathces if different threat indicators matched the same signal', () => { + const matchedQuery = [ + encodeThreatMatchNamedQuery( + getNamedQueryMock({ + id: 'signalId', + index: 'source_index', + value: 'threat.indicator.domain', + field: 'event.domain', + }) + ), + ]; + const signalMatches = getSignalMatchesFromThreatList([ + getThreatListItemMock({ + _id: 'threatId1', + matched_queries: matchedQuery, + }), + getThreatListItemMock({ + _id: 'threatId2', + matched_queries: matchedQuery, + }), + ]); + + const query = { + field: 'event.domain', + value: 'threat.indicator.domain', + index: 'threat_index', + id: 'threatId', + }; + + expect(signalMatches).toEqual([ + { + signalId: 'signalId', + queries: [ + { + ...query, + id: 'threatId1', + }, + { + ...query, + id: 'threatId2', + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts index 8c7b0b89a0cb7..c1fb88176fd4c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/enrich_signal_threat_matches.ts @@ -14,9 +14,43 @@ import type { ThreatEnrichment, ThreatListItem, ThreatMatchNamedQuery, + SignalMatch, } from './types'; import { extractNamedQueries } from './utils'; +export const getSignalMatchesFromThreatList = ( + threatList: ThreatListItem[] = [] +): SignalMatch[] => { + const signalMap: { [key: string]: ThreatMatchNamedQuery[] } = {}; + + threatList.forEach((threatHit) => + extractNamedQueries(threatHit).forEach((item) => { + const signalId = item.id; + if (!signalId) { + return; + } + + if (!signalMap[signalId]) { + signalMap[signalId] = []; + } + + signalMap[signalId].push({ + id: threatHit._id, + index: threatHit._index, + field: item.field, + value: item.value, + }); + }) + ); + + const signalMatches = Object.entries(signalMap).map(([key, value]) => ({ + signalId: key, + queries: value, + })); + + return signalMatches; +}; + const getSignalId = (signal: SignalSourceHit): string => signal._id; export const groupAndMergeSignalMatches = (signalHits: SignalSourceHit[]): SignalSourceHit[] => { @@ -77,7 +111,8 @@ export const buildEnrichments = ({ export const enrichSignalThreatMatches = async ( signals: SignalSearchResponse, getMatchedThreats: GetMatchedThreats, - indicatorPath: string + indicatorPath: string, + signalMatchesArg?: SignalMatch[] ): Promise => { const signalHits = signals.hits.hits; if (signalHits.length === 0) { @@ -85,13 +120,27 @@ export const enrichSignalThreatMatches = async ( } const uniqueHits = groupAndMergeSignalMatches(signalHits); - const signalMatches = uniqueHits.map((signalHit) => extractNamedQueries(signalHit)); - const matchedThreatIds = [...new Set(signalMatches.flat().map(({ id }) => id))]; + const signalMatches: SignalMatch[] = signalMatchesArg + ? signalMatchesArg + : uniqueHits.map((signalHit) => ({ + signalId: signalHit._id, + queries: extractNamedQueries(signalHit), + })); + + const matchedThreatIds = [ + ...new Set( + signalMatches + .map((signalMatch) => signalMatch.queries) + .flat() + .map(({ id }) => id) + ), + ]; const matchedThreats = await getMatchedThreats(matchedThreatIds); - const enrichmentsWithoutAtomic = signalMatches.map((queries) => + + const enrichmentsWithoutAtomic = signalMatches.map((signalMatch) => buildEnrichments({ indicatorPath, - queries, + queries: signalMatch.queries, threats: matchedThreats, }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts index 28a994280abed..2c6d3bd8cc38d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_event_count.ts @@ -5,10 +5,62 @@ * 2.0. */ -import { EventCountOptions } from './types'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { EventCountOptions, EventsOptions, EventDoc } from './types'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; +import { singleSearchAfter } from '../../signals/single_search_after'; import { buildEventsSearchQuery } from '../build_events_query'; +export const MAX_PER_PAGE = 9000; + +export const getEventList = async ({ + services, + query, + language, + index, + perPage, + searchAfter, + exceptionItems, + filters, + buildRuleMessage, + logger, + tuple, + timestampOverride, +}: EventsOptions): Promise> => { + const calculatedPerPage = perPage ?? MAX_PER_PAGE; + if (calculatedPerPage > 10000) { + throw new TypeError('perPage cannot exceed the size of 10000'); + } + + logger.debug( + buildRuleMessage( + `Querying the events items from the index: "${index}" with searchAfter: "${searchAfter}" for up to ${calculatedPerPage} indicator items` + ) + ); + + const filter = getQueryFilter(query, language ?? 'kuery', filters, index, exceptionItems); + + const { searchResult } = await singleSearchAfter({ + buildRuleMessage, + searchAfterSortIds: searchAfter, + index, + from: tuple.from.toISOString(), + to: tuple.to.toISOString(), + services, + logger, + filter, + pageSize: Math.ceil(Math.min(tuple.maxSignals, calculatedPerPage)), + timestampOverride, + sortOrder: 'desc', + trackTotalHits: false, + }); + + logger.debug( + buildRuleMessage(`Retrieved events items of size: ${searchResult.hits.hits.length}`) + ); + return searchResult; +}; + export const getEventCount = async ({ esClient, query, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts index f31c1fbfdaec3..9f2fcef2f6883 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/get_threat_list.ts @@ -7,7 +7,12 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getQueryFilter } from '../../../../../common/detection_engine/get_query_filter'; -import { GetThreatListOptions, ThreatListCountOptions, ThreatListDoc } from './types'; +import { + GetThreatListOptions, + ThreatListCountOptions, + ThreatListDoc, + ThreatListItem, +} from './types'; /** * This should not exceed 10000 (10k) @@ -89,3 +94,22 @@ export const getThreatListCount = async ({ }); return response.count; }; + +export const getAllThreatListHits = async ( + params: Omit +): Promise => { + let allThreatListHits: ThreatListItem[] = []; + let threatList = await getThreatList({ ...params, searchAfter: undefined }); + + allThreatListHits = allThreatListHits.concat(threatList.hits.hits); + + while (threatList.hits.hits.length !== 0) { + threatList = await getThreatList({ + ...params, + searchAfter: threatList.hits.hits[threatList.hits.hits.length - 1].sort, + }); + + allThreatListHits = allThreatListHits.concat(threatList.hits.hits); + } + return allThreatListHits; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts index 45fa47288a958..8beabe072c13f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/types.ts @@ -94,31 +94,70 @@ export interface CreateThreatSignalOptions { wrapHits: WrapHits; } +export interface CreateEventSignalOptions { + alertId: string; + buildRuleMessage: BuildRuleMessage; + bulkCreate: BulkCreate; + completeRule: CompleteRule; + currentResult: SearchAfterAndBulkCreateReturnType; + currentEventList: EventItem[]; + eventsTelemetry: ITelemetryEventsSender | undefined; + exceptionItems: ExceptionListItemSchema[]; + filters: unknown[]; + inputIndex: string[]; + language: LanguageOrUndefined; + listClient: ListClient; + logger: Logger; + outputIndex: string; + query: string; + savedId: string | undefined; + searchAfterSize: number; + services: AlertServices; + threatEnrichment: SignalsEnrichment; + tuple: RuleRangeTuple; + type: Type; + wrapHits: WrapHits; + threatFilters: unknown[]; + threatIndex: ThreatIndex; + threatIndicatorPath: ThreatIndicatorPath; + threatLanguage: ThreatLanguageOrUndefined; + threatMapping: ThreatMapping; + threatQuery: ThreatQuery; + threatListConfig: ThreatListConfig; + perPage?: number; +} + +type EntryKey = 'field' | 'value'; export interface BuildThreatMappingFilterOptions { chunkSize?: number; threatList: ThreatListItem[]; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface FilterThreatMappingOptions { threatListItem: ThreatListItem; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface CreateInnerAndClausesOptions { threatListItem: ThreatListItem; threatMappingEntries: ThreatMappingEntries; + entryKey: EntryKey; } export interface CreateAndOrClausesOptions { threatListItem: ThreatListItem; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface BuildEntriesMappingFilterOptions { chunkSize: number; threatList: ThreatListItem[]; threatMapping: ThreatMapping; + entryKey: EntryKey; } export interface SplitShouldClausesOptions { @@ -199,6 +238,26 @@ export interface BuildThreatEnrichmentOptions { threatQuery: ThreatQuery; } +export interface EventsOptions { + services: AlertServices; + query: string; + buildRuleMessage: BuildRuleMessage; + language: ThreatLanguageOrUndefined; + exceptionItems: ExceptionListItemSchema[]; + index: string[]; + searchAfter: estypes.SortResults | undefined; + perPage?: number; + logger: Logger; + filters: unknown[]; + timestampOverride?: string; + tuple: RuleRangeTuple; +} + +export interface EventDoc { + [key: string]: unknown; +} + +export type EventItem = estypes.SearchHit; export interface EventCountOptions { esClient: ElasticsearchClient; exceptionItems: ExceptionListItemSchema[]; @@ -209,3 +268,16 @@ export interface EventCountOptions { tuple: RuleRangeTuple; timestampOverride?: string; } + +export interface SignalMatch { + signalId: string; + queries: ThreatMatchNamedQuery[]; +} + +export type GetDocumentListInterface = (params: { + searchAfter: estypes.SortResults | undefined; +}) => Promise>; + +export type CreateSignalInterface = ( + params: EventItem[] | ThreatListItem[] +) => Promise; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 99f6609faec91..2918bffec3631 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -9,7 +9,7 @@ import moment from 'moment'; import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; import { parseInterval } from '../utils'; -import { ThreatMatchNamedQuery } from './types'; +import { ThreatMatchNamedQuery, ThreatListItem } from './types'; /** * Given two timers this will take the max of each and add them to each other and return that addition. @@ -147,7 +147,9 @@ export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQu return query; }; -export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => +export const extractNamedQueries = ( + hit: SignalSourceHit | ThreatListItem +): ThreatMatchNamedQuery[] => hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; export const buildExecutionIntervalValidator: (interval: string) => () => void = (interval) => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index f5c066f61db1c..278018796d10a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -493,7 +493,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe('indicator enrichment', () => { + describe('indicator enrichment: threat-first search', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); }); @@ -513,7 +513,440 @@ export default ({ getService }: FtrProviderContext) => { language: 'kuery', rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', - query: '*:*', + query: '*:*', // narrow events down to 2 with a destination.ip + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.domain: 159.89.119.67', // narrow things down to indicators with a domain + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source?.threat); + expect(threats).to.eql([ + { + enrichments: [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + type: 'url', + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ], + }, + { + enrichments: [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + type: 'url', + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ], + }, + ]); + }); + + it('enriches signals with multiple indicators if several matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'NOT source.port:35326', // specify query to have signals more than treat indicators, but only 1 will match + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.ip: *', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const [threat] = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threat.enrichments, [ + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + provider: 'other_provider', + type: 'ip', + }, + + matched: { + atomic: '45.115.45.3', + id: '978787', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + + it('adds a single indicator that matched multiple fields', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'NOT source.port:35326', // specify query to have signals more than treat indicators, but only 1 will match + threat_indicator_path: 'threat.indicator', + threat_query: 'threat.indicator.port: 57324 or threat.indicator.ip:45.115.45.3', // narrow our query to a single indicator + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(1); + + const { hits } = signalsOpen.hits; + const [threat] = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threat.enrichments, [ + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + // We do not merge matched indicators during enrichment, so in + // certain circumstances a given indicator document could appear + // multiple times in an enriched alert (albeit with different + // threat.indicator.matched data). That's the case with the + // first and third indicators matched, here. + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + + matched: { + atomic: 57324, + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.port', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + provider: 'other_provider', + type: 'ip', + }, + matched: { + atomic: '45.115.45.3', + id: '978787', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + + it('generates multiple signals with multiple matches', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + threat_language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', // narrow our query to a single record that matches two indicators + threat_indicator_path: 'threat.indicator', + threat_query: '*:*', + threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module + threat_mapping: [ + { + entries: [ + { + value: 'threat.indicator.port', + field: 'source.port', + type: 'mapping', + }, + { + value: 'threat.indicator.ip', + field: 'source.ip', + type: 'mapping', + }, + ], + }, + { + entries: [ + { + value: 'threat.indicator.domain', + field: 'destination.ip', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + }; + + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 2, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).equal(2); + + const { hits } = signalsOpen.hits; + const threats = hits.map((hit) => hit._source?.threat) as Array<{ + enrichments: unknown[]; + }>; + + assertContains(threats[0].enrichments, [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: '45.115.45.3', + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + { + feed: {}, + indicator: { + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', + port: 57324, + provider: 'geenensp', + type: 'url', + }, + matched: { + atomic: 57324, + id: '978785', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'source.port', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + + assertContains(threats[1].enrichments, [ + { + feed: {}, + indicator: { + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', + provider: 'geenensp', + type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, + }, + matched: { + atomic: '159.89.119.67', + id: '978783', + index: 'filebeat-8.0.0-2021.01.26-000001', + field: 'destination.ip', + type: ENRICHMENT_TYPES.IndicatorMatchRule, + }, + }, + ]); + }); + }); + + describe('indicator enrichment: event-first search', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/filebeat/threat_intel'); + }); + + it('enriches signals with the single indicator that matched', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a rule id', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: 'destination.ip:159.89.119.67', threat_indicator_path: 'threat.indicator', threat_query: 'threat.indicator.domain: *', // narrow things down to indicators with a domain threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module @@ -797,7 +1230,7 @@ export default ({ getService }: FtrProviderContext) => { threat_language: 'kuery', rule_id: 'rule-1', from: '1900-01-01T00:00:00.000Z', - query: '*:*', // narrow our query to a single record that matches two indicators + query: '(source.port:57324 and source.ip:45.115.45.3) or destination.ip:159.89.119.67', // narrow our query to a single record that matches two indicators threat_indicator_path: 'threat.indicator', threat_query: '*:*', threat_index: ['filebeat-*'], // Mimics indicators from the filebeat MISP module diff --git a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json index f426ffae33e1c..80ccf200301c7 100644 --- a/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json +++ b/x-pack/test/functional/es_archives/filebeat/threat_intel/data.json @@ -274,3 +274,145 @@ } } } + +{ + "type": "doc", + "value": { + "id": "978766", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "ti_abusech.malware", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "172.16.0.0", + "ip": "8.8.8.8", + "port": 777, + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "978767", + "index": "filebeat-8.0.0-2021.01.26-000001", + "source": { + "@timestamp": "2021-01-26T11:09:05.529Z", + "agent": { + "ephemeral_id": "b7b56c3e-1f27-4c69-96f4-aa9ca47888d0", + "id": "69acb5f0-1e79-4cfe-a4dc-e0dbf229ff51", + "name": "MacBook-Pro-de-Gloria.local", + "type": "filebeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "category": "threat", + "created": "2021-01-26T11:09:05.529Z", + "dataset": "ti_abusech.malware", + "ingested": "2021-01-26T11:09:06.595350Z", + "kind": "enrichment", + "module": "threatintel", + "reference": "https://urlhaus.abuse.ch/url/978783/", + "type": "indicator" + }, + "fileset": { + "name": "abuseurl" + }, + "input": { + "type": "httpjson" + }, + "service": { + "type": "threatintel" + }, + "tags": [ + "threatintel-abuseurls", + "forwarded" + ], + "threat": { + "indicator": { + "description": "domain should match the auditbeat hosts' data's source.ip", + "domain": "172.16.0.0", + "ip": "9.9.9.9", + "port": 123, + "first_seen": "2021-01-26T11:09:04.000Z", + "provider": "geenensp", + "type": "url", + "url": { + "full": "http://159.89.119.67:59600/bin.sh", + "scheme": "http" + } + } + }, + "threatintel": { + "abuseurl": { + "blacklists": { + "spamhaus_dbl": "not listed", + "surbl": "not listed" + }, + "larted": false, + "tags": null, + "threat": "malware_download", + "url_status": "online" + } + } + } + } +} \ No newline at end of file From 55d747617202d1b52967ec1f4e907f0f850b6c28 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 24 Mar 2022 10:51:02 +0100 Subject: [PATCH 107/132] [Actionable Observability] rules page read permissions & snoozed status & no data screen (#128108) * users with read permissions can not edit/delete/create rules * users with read permissions can not change the status * clean up unused code * localization for change status aria label * remove console log * add muted status * rename to snoozed * remove unused imports * rename snoozed to snoozed permanently * localize statuses * implement no data and no permission screen * fix prompt filenames * fix i18n error * change permanently to indefinitely * do not show noData screen when filters are applied and don't match any results * add centered spinner on initial load * move currrent functionality from triggers_actions_ui related to pagination to the use_fetch_rules hook * disable status column if license is not enabled Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/hooks/use_fetch_rules.ts | 24 +- .../components/center_justified_spinner.tsx | 25 ++ .../public/pages/rules/components/name.tsx | 17 +- .../components/prompts/no_data_prompt.tsx | 69 +++++ .../prompts/no_permission_prompt.tsx | 44 ++++ .../public/pages/rules/components/status.tsx | 23 +- .../pages/rules/components/status_context.tsx | 31 ++- .../public/pages/rules/config.ts | 16 +- .../public/pages/rules/index.tsx | 244 +++++++++++------- .../public/pages/rules/translations.ts | 28 ++ .../observability/public/pages/rules/types.ts | 6 + .../triggers_actions_ui/public/index.ts | 1 + 12 files changed, 400 insertions(+), 128 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx create mode 100644 x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx create mode 100644 x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index b81046df99d28..53b2f68821710 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -6,6 +6,7 @@ */ import { useEffect, useState, useCallback } from 'react'; +import { isEmpty } from 'lodash'; import { loadRules, Rule } from '../../../triggers_actions_ui/public'; import { RULES_LOAD_ERROR } from '../pages/rules/translations'; import { FetchRulesProps } from '../pages/rules/types'; @@ -19,7 +20,13 @@ interface RuleState { totalItemCount: number; } -export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort }: FetchRulesProps) { +export function useFetchRules({ + searchText, + ruleLastResponseFilter, + setPage, + page, + sort, +}: FetchRulesProps) { const { http } = useKibana().services; const [rulesState, setRulesState] = useState({ @@ -29,6 +36,9 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } totalItemCount: 0, }); + const [noData, setNoData] = useState(true); + const [initialLoad, setInitialLoad] = useState(true); + const fetchRules = useCallback(async () => { setRulesState((oldState) => ({ ...oldState, isLoading: true })); @@ -47,10 +57,18 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } data: response.data, totalItemCount: response.total, })); + + if (!response.data?.length && page.index > 0) { + setPage({ ...page, index: 0 }); + } + const isFilterApplied = !(isEmpty(searchText) && isEmpty(ruleLastResponseFilter)); + + setNoData(response.data.length === 0 && !isFilterApplied); } catch (_e) { setRulesState((oldState) => ({ ...oldState, isLoading: false, error: RULES_LOAD_ERROR })); } - }, [http, page, searchText, ruleLastResponseFilter, sort]); + setInitialLoad(false); + }, [http, page, setPage, searchText, ruleLastResponseFilter, sort]); useEffect(() => { fetchRules(); }, [fetchRules]); @@ -59,5 +77,7 @@ export function useFetchRules({ searchText, ruleLastResponseFilter, page, sort } rulesState, reload: fetchRules, setRulesState, + noData, + initialLoad, }; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx b/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx new file mode 100644 index 0000000000000..867d530eb4e2f --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/center_justified_spinner.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiLoadingSpinnerSize } from '@elastic/eui/src/components/loading/loading_spinner'; + +interface Props { + size?: EuiLoadingSpinnerSize; +} + +export function CenterJustifiedSpinner({ size }: Props) { + return ( + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index 2b1f831256910..cbde68ea27eb4 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; import { RuleNameProps } from '../types'; import { useKibana } from '../../../utils/kibana_react'; @@ -34,17 +33,5 @@ export function Name({ name, rule }: RuleNameProps) { ); - return ( - <> - {link} - {rule.enabled && rule.muteAll && ( - - - - )} - - ); + return <>{link}; } diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx new file mode 100644 index 0000000000000..b9c0e24160004 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_data_prompt.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiButton, EuiEmptyPrompt, EuiLink, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui'; + +export function NoDataPrompt({ + onCTAClicked, + documentationLink, +}: { + onCTAClicked: () => void; + documentationLink: string; +}) { + return ( + + + + + } + body={ +

+ +

+ } + actions={[ + + + , + + + Documentation + + , + ]} + /> +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx new file mode 100644 index 0000000000000..edfe1c6840d8b --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rules/components/prompts/no_permission_prompt.tsx @@ -0,0 +1,44 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui'; + +export function NoPermissionPrompt() { + return ( + + + + + } + body={ +

+ +

+ } + /> +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status.tsx b/x-pack/plugins/observability/public/pages/rules/components/status.tsx index abc2dc8bfa492..612d6f8f30bdd 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status.tsx @@ -5,19 +5,28 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiBadge } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import { StatusProps } from '../types'; import { statusMap } from '../config'; +import { RULES_CHANGE_STATUS } from '../translations'; -export function Status({ type, onClick }: StatusProps) { +export function Status({ type, disabled, onClick }: StatusProps) { + const props = useMemo( + () => ({ + color: statusMap[type].color, + ...(!disabled ? { onClick } : { onClick: noop }), + ...(!disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), + }), + [disabled, onClick, type] + ); return ( {statusMap[type].label} diff --git a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx index 49761d7c43154..c7bd29d85b17a 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/status_context.tsx @@ -18,19 +18,26 @@ import { statusMap } from '../config'; export function StatusContext({ item, + disabled = false, onStatusChanged, enableRule, disableRule, muteRule, + unMuteRule, }: StatusContextProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); - const currentStatus = item.enabled ? RuleStatus.enabled : RuleStatus.disabled; + let currentStatus: RuleStatus; + if (item.enabled) { + currentStatus = item.muteAll ? RuleStatus.snoozed : RuleStatus.enabled; + } else { + currentStatus = RuleStatus.disabled; + } const popOverButton = useMemo( - () => , - [currentStatus, togglePopover] + () => , + [disabled, currentStatus, togglePopover] ); const onContextMenuItemClick = useCallback( @@ -41,15 +48,30 @@ export function StatusContext({ if (status === RuleStatus.enabled) { await enableRule({ ...item, enabled: true }); + if (item.muteAll) { + await unMuteRule({ ...item, muteAll: false }); + } } else if (status === RuleStatus.disabled) { await disableRule({ ...item, enabled: false }); + } else if (status === RuleStatus.snoozed) { + await muteRule({ ...item, muteAll: true }); } setIsUpdating(false); onStatusChanged(status); } }, - [item, togglePopover, enableRule, disableRule, currentStatus, onStatusChanged] + [ + item, + togglePopover, + enableRule, + disableRule, + muteRule, + unMuteRule, + currentStatus, + onStatusChanged, + ] ); + const panelItems = useMemo( () => Object.values(RuleStatus).map((status: RuleStatus) => ( @@ -57,6 +79,7 @@ export function StatusContext({ icon={status === currentStatus ? 'check' : 'empty'} key={status} onClick={() => onContextMenuItemClick(status)} + disabled={status === RuleStatus.snoozed && currentStatus === RuleStatus.disabled} > {statusMap[status].label} diff --git a/x-pack/plugins/observability/public/pages/rules/config.ts b/x-pack/plugins/observability/public/pages/rules/config.ts index afff097776e19..736f538ee7b21 100644 --- a/x-pack/plugins/observability/public/pages/rules/config.ts +++ b/x-pack/plugins/observability/public/pages/rules/config.ts @@ -13,6 +13,9 @@ import { RULE_STATUS_PENDING, RULE_STATUS_UNKNOWN, RULE_STATUS_WARNING, + RULE_STATUS_ENABLED, + RULE_STATUS_DISABLED, + RULE_STATUS_SNOOZED_INDEFINITELY, } from './translations'; import { AlertExecutionStatuses } from '../../../../alerting/common'; import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/public'; @@ -20,11 +23,15 @@ import { Rule, RuleTypeIndex, RuleType } from '../../../../triggers_actions_ui/p export const statusMap: Status = { [RuleStatus.enabled]: { color: 'primary', - label: 'Enabled', + label: RULE_STATUS_ENABLED, }, [RuleStatus.disabled]: { color: 'default', - label: 'Disabled', + label: RULE_STATUS_DISABLED, + }, + [RuleStatus.snoozed]: { + color: 'warning', + label: RULE_STATUS_SNOOZED_INDEFINITELY, }, }; @@ -93,3 +100,8 @@ export function convertRulesToTableItems( enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, })); } + +type Capabilities = Record; + +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index 8c44fa90fb3d1..21664ca63507d 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -32,6 +32,9 @@ import { ExecutionStatus } from './components/execution_status'; import { LastRun } from './components/last_run'; import { EditRuleFlyout } from './components/edit_rule_flyout'; import { DeleteModalConfirmation } from './components/delete_modal_confirmation'; +import { NoDataPrompt } from './components/prompts/no_data_prompt'; +import { NoPermissionPrompt } from './components/prompts/no_permission_prompt'; +import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { deleteRules, RuleTableItem, @@ -39,6 +42,7 @@ import { disableRule, muteRule, useLoadRuleTypes, + unmuteRule, } from '../../../../triggers_actions_ui/public'; import { AlertExecutionStatus, ALERTS_FEATURE_ID } from '../../../../alerting/common'; import { Pagination } from './types'; @@ -46,6 +50,7 @@ import { DEFAULT_SEARCH_PAGE_SIZE, convertRulesToTableItems, OBSERVABILITY_SOLUTIONS, + hasExecuteActionsCapability, } from './config'; import { LAST_RESPONSE_COLUMN_TITLE, @@ -73,9 +78,12 @@ export function RulesPage() { http, docLinks, triggersActionsUi, + application: { capabilities }, notifications: { toasts }, } = useKibana().services; - + const documentationLink = docLinks.links.alerting.guide; + const ruleTypeRegistry = triggersActionsUi.ruleTypeRegistry; + const canExecuteActions = hasExecuteActionsCapability(capabilities); const [page, setPage] = useState({ index: 0, size: DEFAULT_SEARCH_PAGE_SIZE }); const [sort, setSort] = useState['sort']>({ field: 'name', @@ -90,6 +98,9 @@ export function RulesPage() { const [rulesToDelete, setRulesToDelete] = useState([]); const [createRuleFlyoutVisibility, setCreateRuleFlyoutVisibility] = useState(false); + const isRuleTypeEditableInContext = (ruleTypeId: string) => + ruleTypeRegistry.has(ruleTypeId) ? !ruleTypeRegistry.get(ruleTypeId).requiresAppContext : false; + const onRuleEdit = (ruleItem: RuleTableItem) => { setCurrentRuleToEdit(ruleItem); }; @@ -102,14 +113,22 @@ export function RulesPage() { setRefreshInterval(refreshIntervalChanged); }; - const { rulesState, setRulesState, reload } = useFetchRules({ + const { rulesState, setRulesState, reload, noData, initialLoad } = useFetchRules({ searchText, ruleLastResponseFilter, page, + setPage, sort, }); const { data: rules, totalItemCount, error } = rulesState; - const { ruleTypeIndex } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS }); + const { ruleTypeIndex, ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + const authorizedRuleTypes = [...ruleTypes.values()]; + + const authorizedToCreateAnyRules = authorizedRuleTypes.some( + (ruleType) => ruleType.authorizedConsumers[ALERTS_FEATURE_ID]?.all + ); useEffect(() => { const interval = setInterval(() => { @@ -161,11 +180,13 @@ export function RulesPage() { render: (_enabled: boolean, item: RuleTableItem) => { return ( reload()} enableRule={async () => await enableRule({ http, id: item.id })} disableRule={async () => await disableRule({ http, id: item.id })} muteRule={async () => await muteRule({ http, id: item.id })} + unMuteRule={async () => await unmuteRule({ http, id: item.id })} /> ); }, @@ -180,6 +201,9 @@ export function RulesPage() { { + if (noData && !rulesState.isLoading) { + return authorizedToCreateAnyRules ? ( + setCreateRuleFlyoutVisibility(true)} + /> + ) : ( + + ); + } + if (initialLoad) { + return ; + } + return ( + <> + + + { + setInputText(e.target.value); + if (e.target.value === '') { + setSearchText(e.target.value); + } + }} + onKeyUp={(e) => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={SEARCH_PLACEHOLDER} + /> + + + setRuleLastResponseFilter(ids)} + /> + + + + + + , + + + + + + + + + + + + + + + + setPage(index)} + sort={sort} + onSortChange={(changedSort) => { + setSort(changedSort); + }} + /> + + + + ); + }; + return ( ), rightSideItems: [ - setCreateRuleFlyoutVisibility(true)} - > - - , + authorizedToCreateAnyRules && ( + setCreateRuleFlyoutVisibility(true)} + > + + + ), - - - { - setInputText(e.target.value); - if (e.target.value === '') { - setSearchText(e.target.value); - } - }} - onKeyUp={(e) => { - if (e.keyCode === ENTER_KEY) { - setSearchText(inputText); - } - }} - placeholder={SEARCH_PLACEHOLDER} - /> - - - setRuleLastResponseFilter(ids)} - /> - - - - - - , - - - - - - - - - - - - - - - - setPage(index)} - sort={sort} - onSortChange={(changedSort) => { - setSort(changedSort); - }} - /> - - + {getRulesTable()} {error && toasts.addDanger({ title: error, diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts index b72d03bf8e566..36f8ff62f1a4c 100644 --- a/x-pack/plugins/observability/public/pages/rules/translations.ts +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -53,6 +53,27 @@ export const RULE_STATUS_WARNING = i18n.translate( } ); +export const RULE_STATUS_ENABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusEnabled', + { + defaultMessage: 'Enabled', + } +); + +export const RULE_STATUS_DISABLED = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusDisabled', + { + defaultMessage: 'Disabled', + } +); + +export const RULE_STATUS_SNOOZED_INDEFINITELY = i18n.translate( + 'xpack.observability.rules.rulesTable.ruleStatusSnoozedIndefinitely', + { + defaultMessage: 'Snoozed indefinitely', + } +); + export const LAST_RESPONSE_COLUMN_TITLE = i18n.translate( 'xpack.observability.rules.rulesTable.columns.lastResponseTitle', { @@ -144,6 +165,13 @@ export const SEARCH_PLACEHOLDER = i18n.translate( { defaultMessage: 'Search' } ); +export const RULES_CHANGE_STATUS = i18n.translate( + 'xpack.observability.rules.rulesTable.changeStatusAriaLabel', + { + defaultMessage: 'Change status', + } +); + export const confirmModalText = ( numIdsToDelete: number, singleTitle: string, diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts index 23443890ad8fa..1a15cf3d9cef2 100644 --- a/x-pack/plugins/observability/public/pages/rules/types.ts +++ b/x-pack/plugins/observability/public/pages/rules/types.ts @@ -4,17 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { Dispatch, SetStateAction } from 'react'; import { EuiTableSortingType, EuiBasicTableColumn } from '@elastic/eui'; import { AlertExecutionStatus } from '../../../../alerting/common'; import { RuleTableItem, Rule } from '../../../../triggers_actions_ui/public'; export interface StatusProps { type: RuleStatus; + disabled: boolean; onClick: () => void; } export enum RuleStatus { enabled = 'enabled', disabled = 'disabled', + snoozed = 'snoozed', } export type Status = Record< @@ -27,10 +30,12 @@ export type Status = Record< export interface StatusContextProps { item: RuleTableItem; + disabled: boolean; onStatusChanged: (status: RuleStatus) => void; enableRule: (rule: Rule) => Promise; disableRule: (rule: Rule) => Promise; muteRule: (rule: Rule) => Promise; + unMuteRule: (rule: Rule) => Promise; } export interface StatusFilterProps { @@ -65,6 +70,7 @@ export interface FetchRulesProps { searchText: string | undefined; ruleLastResponseFilter: string[]; page: Pagination; + setPage: Dispatch>; sort: EuiTableSortingType['sort']; } diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index eb346e43cfbc9..b1ef489bfef70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -55,6 +55,7 @@ export { deleteRules } from './application/lib/rule_api/delete'; export { enableRule } from './application/lib/rule_api/enable'; export { disableRule } from './application/lib/rule_api/disable'; export { muteRule } from './application/lib/rule_api/mute'; +export { unmuteRule } from './application/lib/rule_api/unmute'; export { loadRuleAggregations } from './application/lib/rule_api/aggregate'; export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; From 01d2e2e6b9e4995c4fa8160f6d458daab2a9709a Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Thu, 24 Mar 2022 11:08:37 +0100 Subject: [PATCH 108/132] [Osquery] Fix role permissions for saved query and actions (#124932) * fix packs permission for saved query and actions --- .../add_integration.spec.ts | 5 +- .../{superuser => all}/alerts.spec.ts | 0 .../delete_all_ecs_mappings.spec.ts | 2 +- .../{superuser => all}/live_query.spec.ts | 16 ++- .../{superuser => all}/metrics.spec.ts | 2 +- .../{superuser => all}/packs.spec.ts | 33 ++--- .../integration/all/saved_queries.spec.ts | 23 ++++ .../cypress/integration/roles/reader.spec.ts | 80 +++++++++++++ .../integration/roles/t1_analyst.spec.ts | 99 +++++++++++++++ .../integration/roles/t2_analyst.spec.ts | 113 ++++++++++++++++++ .../integration/t1_analyst/live_query.spec.ts | 30 ----- .../plugins/osquery/cypress/screens/fleet.ts | 1 + .../osquery/cypress/screens/integrations.ts | 3 + .../osquery/cypress/screens/live_query.ts | 4 + .../saved_queries.ts} | 39 +++--- .../osquery/public/actions/actions_table.tsx | 9 +- ...squery_managed_custom_button_extension.tsx | 23 ++-- ...managed_policy_create_import_extension.tsx | 41 ++++--- .../fleet_integration/use_fetch_status.tsx | 36 ++++++ .../public/live_queries/form/index.tsx | 22 +++- .../packs/pack_queries_status_table.tsx | 21 ++-- .../routes/saved_queries/list/index.tsx | 12 +- .../osquery/scripts/roles_users/README.md | 2 +- .../osquery/scripts/roles_users/index.ts | 2 + .../scripts/roles_users/reader/delete_user.sh | 11 ++ .../scripts/roles_users/reader/get_role.sh | 11 ++ .../scripts/roles_users/reader/index.ts | 11 ++ .../scripts/roles_users/reader/post_role.sh | 14 +++ .../scripts/roles_users/reader/post_user.sh | 14 +++ .../scripts/roles_users/reader/role.json | 19 +++ .../scripts/roles_users/reader/user.json | 6 + .../scripts/roles_users/t1_analyst/role.json | 2 +- .../scripts/roles_users/t1_analyst/user.json | 2 +- .../roles_users/t2_analyst/delete_user.sh | 11 ++ .../roles_users/t2_analyst/get_role.sh | 11 ++ .../scripts/roles_users/t2_analyst/index.ts | 11 ++ .../roles_users/t2_analyst/post_role.sh | 14 +++ .../roles_users/t2_analyst/post_user.sh | 14 +++ .../scripts/roles_users/t2_analyst/role.json | 19 +++ .../scripts/roles_users/t2_analyst/user.json | 6 + .../routes/action/create_action_route.ts | 35 ++++-- 41 files changed, 679 insertions(+), 150 deletions(-) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/add_integration.spec.ts (97%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/alerts.spec.ts (100%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/delete_all_ecs_mappings.spec.ts (96%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/live_query.spec.ts (73%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/metrics.spec.ts (97%) rename x-pack/plugins/osquery/cypress/integration/{superuser => all}/packs.spec.ts (90%) create mode 100644 x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts create mode 100644 x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts delete mode 100644 x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts rename x-pack/plugins/osquery/cypress/{integration/superuser/saved_queries.spec.ts => tasks/saved_queries.ts} (76%) create mode 100644 x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/reader/index.ts create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/reader/role.json create mode 100644 x-pack/plugins/osquery/scripts/roles_users/reader/user.json create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh create mode 100755 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh create mode 100644 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json create mode 100644 x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts similarity index 97% rename from x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts index 4f9fb4304fd28..11a904526d314 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/add_integration.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/add_integration.spec.ts @@ -11,8 +11,9 @@ import { addIntegration } from '../../tasks/integrations'; import { login } from '../../tasks/login'; // import { findAndClickButton, findFormFieldByRowsLabelAndType } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { DEFAULT_POLICY } from '../../screens/fleet'; -describe('Super User - Add Integration', () => { +describe('ALL - Add Integration', () => { const integration = 'Osquery Manager'; before(() => { runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); @@ -65,7 +66,7 @@ describe('Super User - Add Integration', () => { it('add integration', () => { cy.visit(FLEET_AGENT_POLICIES); - cy.contains('Default Fleet Server policy').click(); + cy.contains(DEFAULT_POLICY).click(); cy.contains('Add integration').click(); cy.contains(integration).click(); addIntegration(); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts similarity index 100% rename from x-pack/plugins/osquery/cypress/integration/superuser/alerts.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/alerts.spec.ts diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts similarity index 96% rename from x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts index 5c21f29b650e7..46d927329aa98 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts @@ -9,7 +9,7 @@ import { navigateTo } from '../../tasks/navigation'; import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; -describe('SuperUser - Delete ECS Mappings', () => { +describe('ALL - Delete ECS Mappings', () => { const SAVED_QUERY_ID = 'Saved-Query-Id'; before(() => { diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts similarity index 73% rename from x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts index f979f793873f1..d6af17596d89a 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/live_query.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/live_query.spec.ts @@ -15,8 +15,10 @@ import { typeInECSFieldInput, typeInOsqueryFieldInput, } from '../../tasks/live_query'; +import { RESULTS_TABLE_CELL_WRRAPER } from '../../screens/live_query'; +import { getAdvancedButton } from '../../screens/integrations'; -describe('Super User - Live Query', () => { +describe('ALL - Live Query', () => { beforeEach(() => { login(); navigateTo('/app/osquery'); @@ -31,23 +33,25 @@ describe('Super User - Live Query', () => { // checking submit by clicking cmd+enter inputQuery(cmd); checkResults(); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.contains('View in Discover').should('exist'); + cy.contains('View in Lens').should('exist'); + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.days.number', index: 1 }, }); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.hours.number', index: 2 }, }); - cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click(); + getAdvancedButton().click(); typeInECSFieldInput('message{downArrow}{enter}'); typeInOsqueryFieldInput('days{downArrow}{enter}'); submitQuery(); checkResults(); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'message', index: 1 }, }); - cy.react('EuiDataGridHeaderCellWrapper', { + cy.react(RESULTS_TABLE_CELL_WRRAPER, { props: { id: 'osquery.days.number', index: 2 }, }).react('EuiIconIndexMapping'); }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts similarity index 97% rename from x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts index f64e6b31ae7a5..ba71e75d9ea7b 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/metrics.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/metrics.spec.ts @@ -10,7 +10,7 @@ import { login } from '../../tasks/login'; import { checkResults, inputQuery, submitQuery } from '../../tasks/live_query'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; -describe('Super User - Metrics', () => { +describe('ALL - Metrics', () => { beforeEach(() => { login(); navigateTo('/app/osquery'); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts similarity index 90% rename from x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts index fd04d0a62b160..eafe36874244e 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/packs.spec.ts @@ -16,8 +16,10 @@ import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { preparePack } from '../../tasks/packs'; import { addIntegration, closeModalIfVisible } from '../../tasks/integrations'; +import { DEFAULT_POLICY } from '../../screens/fleet'; +import { getSavedQueriesDropdown } from '../../screens/live_query'; -describe('SuperUser - Packs', () => { +describe('ALL - Packs', () => { const integration = 'Osquery Manager'; const SAVED_QUERY_ID = 'Saved-Query-Id'; const PACK_NAME = 'Pack-name'; @@ -47,21 +49,15 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add pack'); findFormFieldByRowsLabelAndType('Name', PACK_NAME); findFormFieldByRowsLabelAndType('Description (optional)', 'Pack description'); - findFormFieldByRowsLabelAndType( - 'Scheduled agent policies (optional)', - 'Default Fleet Server policy' - ); + findFormFieldByRowsLabelAndType('Scheduled agent policies (optional)', DEFAULT_POLICY); cy.react('List').first().click(); findAndClickButton('Add query'); cy.contains('Attach next query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type(SAVED_QUERY_ID); - cy.react('List').first().click(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`); cy.react('EuiFormRow', { props: { label: 'Interval (s)' } }) .click() .clear() - .type('10'); + .type('500'); cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click(); cy.react('EuiTableRow').contains(SAVED_QUERY_ID); findAndClickButton('Save pack'); @@ -94,10 +90,7 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); cy.contains('Attach next query'); cy.contains('ID must be unique').should('not.exist'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type(SAVED_QUERY_ID); - cy.react('List').first().click(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow}{enter}`); cy.contains('ID must be unique').should('exist'); cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click(); }); @@ -175,9 +168,7 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('Multiple {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('Multiple {downArrow} {enter}'); cy.contains('Custom key/value pairs'); cy.contains('Days of uptime'); cy.contains('List of keywords used to tag each'); @@ -185,9 +176,7 @@ describe('SuperUser - Packs', () => { cy.contains('Client network address.'); cy.contains('Total uptime seconds'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('NOMAPPING {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('NOMAPPING {downArrow} {enter}'); cy.contains('Custom key/value pairs').should('not.exist'); cy.contains('Days of uptime').should('not.exist'); cy.contains('List of keywords used to tag each').should('not.exist'); @@ -195,9 +184,7 @@ describe('SuperUser - Packs', () => { cy.contains('Client network address.').should('not.exist'); cy.contains('Total uptime seconds').should('not.exist'); - cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) - .click() - .type('ONE_MAPPING {downArrow} {enter}'); + getSavedQueriesDropdown().click().type('ONE_MAPPING {downArrow} {enter}'); cy.contains('Name of the continent'); cy.contains('Seconds of uptime'); diff --git a/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts new file mode 100644 index 0000000000000..4e48e819ac0ab --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/all/saved_queries.spec.ts @@ -0,0 +1,23 @@ +/* + * 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 { navigateTo } from '../../tasks/navigation'; + +import { login } from '../../tasks/login'; +import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; + +const SAVED_QUERY_ID = 'Saved-Query-Id'; +const SAVED_QUERY_DESCRIPTION = 'Test saved query description'; + +describe('ALL - Saved queries', () => { + beforeEach(() => { + login(); + navigateTo('/app/osquery'); + }); + + getSavedQueriesComplexTest(SAVED_QUERY_ID, SAVED_QUERY_DESCRIPTION); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts new file mode 100644 index 0000000000000..d3a00f970322b --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/reader.spec.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; + +describe('Reader - only READ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + + beforeEach(() => { + login(ROLES.reader); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + it('should not be able to add nor run saved queries', () => { + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(1000); + cy.contains(SAVED_QUERY_ID); + cy.contains('Add saved query').should('be.disabled'); + cy.react('PlayButtonComponent', { + props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.react('EuiFormRow', { props: { label: 'ID' } }) + .getBySel('input') + .should('be.disabled'); + cy.react('EuiFormRow', { props: { label: 'Description (optional)' } }) + .getBySel('input') + .should('be.disabled'); + + cy.contains('Update query').should('not.exist'); + cy.contains(`Delete query`).should('not.exist'); + }); + it('should not be able to play in live queries history', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('be.disabled'); + cy.contains('select * from uptime'); + cy.react('EuiIconPlay', { options: { timeout: 3000 } }).should('not.exist'); + cy.react('ActionTableResultsButton').should('exist'); + }); + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts new file mode 100644 index 0000000000000..64d72c92dda04 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/t1_analyst.spec.ts @@ -0,0 +1,99 @@ +/* + * 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 { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { checkResults, selectAllAgents, submitQuery } from '../../tasks/live_query'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { getSavedQueriesDropdown, LIVE_QUERY_EDITOR } from '../../screens/live_query'; + +describe('T1 Analyst - READ + runSavedQueries ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + + beforeEach(() => { + login(ROLES.t1_analyst); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + it('should be able to run saved queries but not add new ones', () => { + navigateTo('/app/osquery/saved_queries'); + cy.waitForReact(1000); + cy.contains(SAVED_QUERY_ID); + cy.contains('Add saved query').should('be.disabled'); + cy.react('PlayButtonComponent', { + props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + }) + .should('not.be.disabled') + .click(); + selectAllAgents(); + cy.contains('select * from uptime;'); + submitQuery(); + checkResults(); + cy.contains('View in Discover').should('not.exist'); + cy.contains('View in Lens').should('not.exist'); + }); + it('should be able to play in live queries history', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('not.be.disabled'); + cy.contains('select * from uptime'); + cy.wait(1000); + cy.react('EuiTableBody').first().react('DefaultItemAction').first().click(); + selectAllAgents(); + cy.contains(SAVED_QUERY_ID); + submitQuery(); + checkResults(); + }); + it('should be able to use saved query in a new query', () => { + navigateTo('/app/osquery/live_queries'); + cy.waitForReact(1000); + cy.contains('New live query').should('not.be.disabled').click(); + selectAllAgents(); + getSavedQueriesDropdown().click().type(`${SAVED_QUERY_ID}{downArrow} {enter}`); + cy.contains('select * from uptime'); + submitQuery(); + checkResults(); + }); + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); + it('should not be able to create new liveQuery from scratch', () => { + navigateTo('/app/osquery'); + + cy.contains('New live query').click(); + selectAllAgents(); + cy.get(LIVE_QUERY_EDITOR).should('not.exist'); + cy.contains('Submit').should('be.disabled'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts new file mode 100644 index 0000000000000..805eb134a44f5 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/integration/roles/t2_analyst.spec.ts @@ -0,0 +1,113 @@ +/* + * 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 { login } from '../../tasks/login'; +import { navigateTo } from '../../tasks/navigation'; +import { ROLES } from '../../test'; +import { + checkResults, + selectAllAgents, + submitQuery, + inputQuery, + typeInECSFieldInput, + typeInOsqueryFieldInput, +} from '../../tasks/live_query'; +import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; +import { getSavedQueriesComplexTest } from '../../tasks/saved_queries'; + +describe('T2 Analyst - READ + Write Live/Saved + runSavedQueries ', () => { + const SAVED_QUERY_ID = 'Saved-Query-Id'; + const NEW_SAVED_QUERY_ID = 'Saved-Query-Id-T2'; + const NEW_SAVED_QUERY_DESCRIPTION = 'Test saved query description T2'; + beforeEach(() => { + login(ROLES.t2_analyst); + navigateTo('/app/osquery'); + }); + before(() => { + runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + }); + + after(() => { + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + }); + + getSavedQueriesComplexTest(NEW_SAVED_QUERY_ID, NEW_SAVED_QUERY_DESCRIPTION); + + it('should not be able to add nor edit packs', () => { + const PACK_NAME = 'removing-pack'; + + navigateTo('/app/osquery/packs'); + cy.waitForReact(1000); + cy.contains('Add pack').should('be.disabled'); + cy.react('ActiveStateSwitchComponent', { + props: { item: { attributes: { name: PACK_NAME } } }, + }) + .find('button') + .should('be.disabled'); + cy.contains(PACK_NAME).click(); + cy.contains(`${PACK_NAME} details`); + cy.contains('Edit').should('be.disabled'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + cy.react('CustomItemAction', { + props: { index: 1, item: { id: SAVED_QUERY_ID } }, + options: { timeout: 3000 }, + }).should('not.exist'); + }); + + it('should run query and enable ecs mapping', () => { + const cmd = Cypress.platform === 'darwin' ? '{meta}{enter}' : '{ctrl}{enter}'; + cy.contains('New live query').click(); + selectAllAgents(); + inputQuery('select * from uptime; '); + cy.wait(500); + // checking submit by clicking cmd+enter + inputQuery(cmd); + checkResults(); + cy.contains('View in Discover').should('not.exist'); + cy.contains('View in Lens').should('not.exist'); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.days.number', index: 1 }, + }); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.hours.number', index: 2 }, + }); + + cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }).click(); + typeInECSFieldInput('message{downArrow}{enter}'); + typeInOsqueryFieldInput('days{downArrow}{enter}'); + submitQuery(); + + checkResults(); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'message', index: 1 }, + }); + cy.react('EuiDataGridHeaderCellWrapper', { + props: { id: 'osquery.days.number', index: 2 }, + }).react('EuiIconIndexMapping'); + }); + it('to click the edit button and edit pack', () => { + navigateTo('/app/osquery/saved_queries'); + + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.contains('Custom key/value pairs.').should('exist'); + cy.contains('Hours of uptime').should('exist'); + cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click(); + cy.react('EuiButton').contains('Update query').click(); + cy.wait(5000); + + cy.react('CustomItemAction', { + props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + }).click(); + cy.contains('Custom key/value pairs').should('not.exist'); + cy.contains('Hours of uptime').should('not.exist'); + }); +}); diff --git a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts b/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts deleted file mode 100644 index 11c78560d25fe..0000000000000 --- a/x-pack/plugins/osquery/cypress/integration/t1_analyst/live_query.spec.ts +++ /dev/null @@ -1,30 +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 { login } from '../../tasks/login'; -import { navigateTo } from '../../tasks/navigation'; -import { ROLES } from '../../test'; -import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; - -describe('T1 Analyst - Live Query', () => { - beforeEach(() => { - login(ROLES.t1_analyst); - }); - - describe('should run a live query', () => { - before(() => { - runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); - }); - after(() => { - runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); - }); - it('when passed as a saved query ', () => { - navigateTo('/app/osquery/saved_queries'); - cy.waitForReact(1000); - }); - }); -}); diff --git a/x-pack/plugins/osquery/cypress/screens/fleet.ts b/x-pack/plugins/osquery/cypress/screens/fleet.ts index 6be51e5ed24bc..b7cce6484c405 100644 --- a/x-pack/plugins/osquery/cypress/screens/fleet.ts +++ b/x-pack/plugins/osquery/cypress/screens/fleet.ts @@ -9,3 +9,4 @@ export const ADD_AGENT_BUTTON = 'addAgentButton'; export const AGENT_POLICIES_TAB = 'fleet-agent-policies-tab'; export const ENROLLMENT_TOKENS_TAB = 'fleet-enrollment-tokens-tab'; +export const DEFAULT_POLICY = 'Default Fleet Server policy'; diff --git a/x-pack/plugins/osquery/cypress/screens/integrations.ts b/x-pack/plugins/osquery/cypress/screens/integrations.ts index 42c22096cea96..b02efb9cff512 100644 --- a/x-pack/plugins/osquery/cypress/screens/integrations.ts +++ b/x-pack/plugins/osquery/cypress/screens/integrations.ts @@ -24,3 +24,6 @@ export const LATEST_VERSION = 'latestVersion'; export const PACKAGE_VERSION = 'packageVersionText'; export const SAVE_PACKAGE_CONFIRM = '[data-test-subj=confirmModalConfirmButton]'; + +export const getAdvancedButton = () => + cy.react('EuiAccordion', { props: { buttonContent: 'Advanced' } }); diff --git a/x-pack/plugins/osquery/cypress/screens/live_query.ts b/x-pack/plugins/osquery/cypress/screens/live_query.ts index cba4a35c05719..54c19fe508705 100644 --- a/x-pack/plugins/osquery/cypress/screens/live_query.ts +++ b/x-pack/plugins/osquery/cypress/screens/live_query.ts @@ -9,4 +9,8 @@ export const AGENT_FIELD = '[data-test-subj="comboBoxInput"]'; export const ALL_AGENTS_OPTION = '[title="All agents"]'; export const LIVE_QUERY_EDITOR = '#osquery_editor'; export const SUBMIT_BUTTON = '#submit-button'; + export const RESULTS_TABLE_BUTTON = 'dataGridFullScreenButton'; +export const RESULTS_TABLE_CELL_WRRAPER = 'EuiDataGridHeaderCellWrapper'; +export const getSavedQueriesDropdown = () => + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts similarity index 76% rename from x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts rename to x-pack/plugins/osquery/cypress/tasks/saved_queries.ts index bc8417d5facf5..bfa7b51643382 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/saved_queries.spec.ts +++ b/x-pack/plugins/osquery/cypress/tasks/saved_queries.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { navigateTo } from '../../tasks/navigation'; -import { RESULTS_TABLE_BUTTON } from '../../screens/live_query'; +import { RESULTS_TABLE_BUTTON } from '../screens/live_query'; import { checkResults, BIG_QUERY, @@ -15,18 +14,9 @@ import { inputQuery, selectAllAgents, submitQuery, -} from '../../tasks/live_query'; -import { login } from '../../tasks/login'; - -describe('Super User - Saved queries', () => { - const SAVED_QUERY_ID = 'Saved-Query-Id'; - const SAVED_QUERY_DESCRIPTION = 'Saved Query Description'; - - beforeEach(() => { - login(); - navigateTo('/app/osquery'); - }); +} from './live_query'; +export const getSavedQueriesComplexTest = (savedQueryId: string, savedQueryDescription: string) => it( 'should create a new query and verify: \n ' + '- hidden columns, full screen and sorting \n' + @@ -78,8 +68,8 @@ describe('Super User - Saved queries', () => { cy.contains('Exit full screen').should('not.exist'); cy.contains('Save for later').click(); cy.contains('Save query'); - findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID); - findFormFieldByRowsLabelAndType('Description (optional)', SAVED_QUERY_DESCRIPTION); + findFormFieldByRowsLabelAndType('ID', savedQueryId); + findFormFieldByRowsLabelAndType('Description (optional)', savedQueryDescription); cy.react('EuiButtonDisplay').contains('Save').click(); // visit Status results @@ -89,31 +79,30 @@ describe('Super User - Saved queries', () => { // play saved query cy.contains('Saved queries').click(); - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('PlayButtonComponent', { - props: { savedQuery: { attributes: { id: SAVED_QUERY_ID } } }, + props: { savedQuery: { attributes: { id: savedQueryId } } }, }).click(); selectAllAgents(); submitQuery(); // edit saved query cy.contains('Saved queries').click(); - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('CustomItemAction', { - props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + props: { index: 1, item: { attributes: { id: savedQueryId } } }, }).click(); findFormFieldByRowsLabelAndType('Description (optional)', ' Edited'); cy.react('EuiButton').contains('Update query').click(); - cy.contains(`${SAVED_QUERY_DESCRIPTION} Edited`); + cy.contains(`${savedQueryDescription} Edited`); // delete saved query - cy.contains(SAVED_QUERY_ID); + cy.contains(savedQueryId); cy.react('CustomItemAction', { - props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, + props: { index: 1, item: { attributes: { id: savedQueryId } } }, }).click(); deleteAndConfirm('query'); - cy.contains(SAVED_QUERY_ID).should('exist'); - cy.contains(SAVED_QUERY_ID).should('not.exist'); + cy.contains(savedQueryId).should('exist'); + cy.contains(savedQueryId).should('not.exist'); } ); -}); diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx index d92d9ee117fde..2f81394bccde8 100644 --- a/x-pack/plugins/osquery/public/actions/actions_table.tsx +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -13,7 +13,7 @@ import { useHistory } from 'react-router-dom'; import { useAllActions } from './use_all_actions'; import { Direction } from '../../common/search_strategy'; -import { useRouterNavigate } from '../common/lib/kibana'; +import { useRouterNavigate, useKibana } from '../common/lib/kibana'; interface ActionTableResultsButtonProps { actionId: string; @@ -28,6 +28,7 @@ const ActionTableResultsButton: React.FC = ({ act ActionTableResultsButton.displayName = 'ActionTableResultsButton'; const ActionsTableComponent = () => { + const permissions = useKibana().services.application.capabilities.osquery; const { push } = useHistory(); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(20); @@ -84,6 +85,10 @@ const ActionsTableComponent = () => { }), [push] ); + const isPlayButtonAvailable = useCallback( + () => permissions.runSavedQueries || permissions.writeLiveQueries, + [permissions.runSavedQueries, permissions.writeLiveQueries] + ); const columns = useMemo( () => [ @@ -128,6 +133,7 @@ const ActionsTableComponent = () => { type: 'icon', icon: 'play', onClick: handlePlayClick, + available: isPlayButtonAvailable, }, { render: renderActionsColumn, @@ -137,6 +143,7 @@ const ActionsTableComponent = () => { ], [ handlePlayClick, + isPlayButtonAvailable, renderActionsColumn, renderAgentsColumn, renderCreatedByColumn, diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx index c3770f202c087..0f5caca5d19bd 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_custom_button_extension.tsx @@ -6,12 +6,13 @@ */ import { EuiLoadingContent } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React from 'react'; import { PackageCustomExtensionComponentProps } from '../../../fleet/public'; import { NavigationButtons } from './navigation_buttons'; import { DisabledCallout } from './disabled_callout'; -import { useKibana } from '../common/lib/kibana'; +import { MissingPrivileges } from '../routes/components/missing_privileges'; +import { useFetchStatus } from './use_fetch_status'; /** * Exports Osquery-specific package policy instructions @@ -19,22 +20,16 @@ import { useKibana } from '../common/lib/kibana'; */ export const OsqueryManagedCustomButtonExtension = React.memo( () => { - const [disabled, setDisabled] = React.useState(null); - const { http } = useKibana().services; + const { loading, disabled, permissionDenied } = useFetchStatus(); - useEffect(() => { - const fetchStatus = () => { - http.get<{ install_status: string }>('/internal/osquery/status').then((response) => { - setDisabled(response?.install_status !== 'installed'); - }); - }; - fetchStatus(); - }, [http]); - - if (disabled === null) { + if (loading) { return ; } + if (permissionDenied) { + return ; + } + return ( <> {disabled ? : null} diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx index 1b7b87fe180bf..aaedec1e0dbe1 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx @@ -46,6 +46,7 @@ import { fieldValidators, ValidationFunc, } from '../shared_imports'; +import { useFetchStatus } from './use_fetch_status'; // https://github.com/elastic/beats/blob/master/x-pack/osquerybeat/internal/osqd/args.go#L57 const RESTRICTED_CONFIG_OPTIONS = [ @@ -340,6 +341,8 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const { permissionDenied } = useFetchStatus(); + return ( <> {!editMode ? : null} @@ -366,23 +369,27 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< ) : null} - - - - -
- - - -
+ {!permissionDenied && ( + <> + + + + +
+ + + +
+ + )} ); }); diff --git a/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx new file mode 100644 index 0000000000000..3f86675f8be41 --- /dev/null +++ b/x-pack/plugins/osquery/public/fleet_integration/use_fetch_status.tsx @@ -0,0 +1,36 @@ +/* + * 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 { useState, useEffect } from 'react'; +import { useKibana } from '../common/lib/kibana'; + +export const useFetchStatus = () => { + const [loading, setLoading] = useState(true); + const [disabled, setDisabled] = useState(false); + const [permissionDenied, setPermissionDenied] = useState(false); + const { http } = useKibana().services; + + useEffect(() => { + const fetchStatus = () => { + http + .get<{ install_status: string }>('/internal/osquery/status') + .then((response) => { + setLoading(false); + setDisabled(response?.install_status !== 'installed'); + }) + .catch((err) => { + setLoading(false); + if (err.body.statusCode === 403) { + setPermissionDenied(true); + } + }); + }; + fetchStatus(); + }, [http]); + + return { loading, disabled, permissionDenied }; +}; diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index bd8e2bf42129f..9164266d6a8c5 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -273,16 +273,26 @@ const LiveQueryFormComponent: React.FC = ({ [permissions.writeSavedQueries] ); + const isSavedQueryDisabled = useMemo( + () => + queryComponentProps.disabled || !permissions.runSavedQueries || !permissions.readSavedQueries, + [permissions.readSavedQueries, permissions.runSavedQueries, queryComponentProps.disabled] + ); + const queryFieldStepContent = useMemo( () => ( <> {queryField ? ( <> - - + {!isSavedQueryDisabled && ( + <> + + + + )} = ({ [ queryField, queryComponentProps, - permissions.runSavedQueries, permissions.writeSavedQueries, handleSavedQueryChange, ecsMappingField, @@ -372,6 +381,7 @@ const LiveQueryFormComponent: React.FC = ({ enabled, isSubmitting, submit, + isSavedQueryDisabled, ] ); diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index 836350d12d43e..c99662804b1e2 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -207,6 +207,7 @@ const ViewResultsInLensActionComponent: React.FC { const lensService = useKibana().services.lens; + const isLensAvailable = lensService?.canUseEditor(); const handleClick = useCallback( (event) => { @@ -230,14 +231,12 @@ const ViewResultsInLensActionComponent: React.FC + {VIEW_IN_LENS} ); @@ -247,7 +246,7 @@ const ViewResultsInLensActionComponent: React.FC @@ -264,7 +263,10 @@ const ViewResultsInDiscoverActionComponent: React.FC { - const locator = useKibana().services.discover?.locator; + const { discover, application } = useKibana().services; + const locator = discover?.locator; + const discoverPermissions = application.capabilities.discover; + const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { @@ -336,6 +338,9 @@ const ViewResultsInDiscoverActionComponent: React.FC diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx index f16e32a62cb4f..d019b831d96f5 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx @@ -125,12 +125,12 @@ const SavedQueriesPageComponent = () => { ); const renderPlayAction = useCallback( - (item: SavedQuerySO) => ( - - ), + (item: SavedQuerySO) => + permissions.runSavedQueries || permissions.writeLiveQueries ? ( + + ) : ( + <> + ), [permissions.runSavedQueries, permissions.writeLiveQueries] ); diff --git a/x-pack/plugins/osquery/scripts/roles_users/README.md b/x-pack/plugins/osquery/scripts/roles_users/README.md index d0a28049c865b..aadc696a5f504 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/README.md +++ b/x-pack/plugins/osquery/scripts/roles_users/README.md @@ -4,7 +4,7 @@ Initial version of roles support for Osquery |:--------------------------------------------:|:-----------------------------------------------:|:-------------------------------:|:-------------:|:-----:|:-------------------:|:-----:|:-------------:|:----------:| | NO Data Source access user | none | none | none | none | none | none | none | none | | Reader (read-only user) | read | read | read | read | none | none | none | none | -| T1 Analyst | read | read, write (run saved queries) | read | read | none | none | none | none | +| T1 Analyst | read | read, (run saved queries) | read | read | none | none | none | none | | T2 Analyst | read | read, write (tbc) | all | read | none | read | none | none | | Hunter / T3 Analyst | read | all | all | all | none | all | read | all | | SOC Manager | read | all | all | all | none | all | read | all | diff --git a/x-pack/plugins/osquery/scripts/roles_users/index.ts b/x-pack/plugins/osquery/scripts/roles_users/index.ts index 1f51d8691a715..ce29ba92e2590 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/index.ts +++ b/x-pack/plugins/osquery/scripts/roles_users/index.ts @@ -5,4 +5,6 @@ * 2.0. */ +export * from './reader'; export * from './t1_analyst'; +export * from './t2_analyst'; diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh new file mode 100755 index 0000000000000..57704f7abf0d3 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/delete_user.sh @@ -0,0 +1,11 @@ + +# +# 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. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/reader diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh new file mode 100755 index 0000000000000..37db6e10ced55 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/get_role.sh @@ -0,0 +1,11 @@ + +# +# 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. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/reader | jq -S . diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts new file mode 100644 index 0000000000000..6fbd33c69b3a6 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/index.ts @@ -0,0 +1,11 @@ +/* + * 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 * as readerUser from './user.json'; +import * as readerRole from './role.json'; + +export { readerUser, readerRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh new file mode 100755 index 0000000000000..338783465f993 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_role.sh @@ -0,0 +1,14 @@ + +# +# 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. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/reader \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh new file mode 100755 index 0000000000000..8a93326a820b7 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/post_user.sh @@ -0,0 +1,14 @@ + +# +# 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. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/reader \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/role.json b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json new file mode 100644 index 0000000000000..85c2ff52f84d6 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "osquery": ["read"] + }, + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/reader/user.json b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json new file mode 100644 index 0000000000000..a6c3c38cdd16e --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/reader/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["reader"], + "full_name": "Reader", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json index 85c2ff52f84d6..12d5c2607f9ab 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json +++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/role.json @@ -10,7 +10,7 @@ "kibana": [ { "feature": { - "osquery": ["read"] + "osquery": ["read", "run_saved_queries" ] }, "spaces": ["*"] } diff --git a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json index 203abec8ad433..cef1935d57068 100644 --- a/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json +++ b/x-pack/plugins/osquery/scripts/roles_users/t1_analyst/user.json @@ -2,5 +2,5 @@ "password": "changeme", "roles": ["t1_analyst"], "full_name": "T1 Analyst", - "email": "detections-reader@example.com" + "email": "osquery@example.com" } diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh new file mode 100755 index 0000000000000..6dccb0d8c6067 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/delete_user.sh @@ -0,0 +1,11 @@ + +# +# 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. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/t2_analyst diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh new file mode 100755 index 0000000000000..ce9149d8b9fc7 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/get_role.sh @@ -0,0 +1,11 @@ + +# +# 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. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/t2_analyst | jq -S . diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts new file mode 100644 index 0000000000000..a3a8357e67c7f --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/index.ts @@ -0,0 +1,11 @@ +/* + * 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 * as t2AnalystUser from './user.json'; +import * as t2AnalystRole from './role.json'; + +export { t2AnalystUser, t2AnalystRole }; diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh new file mode 100755 index 0000000000000..b94c738c3e3db --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_role.sh @@ -0,0 +1,14 @@ + +# +# 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. +# + +ROLE_CONFIG=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/t2_analyst \ +-d @${ROLE_CONFIG} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh new file mode 100755 index 0000000000000..3a901490515af --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/post_user.sh @@ -0,0 +1,14 @@ + +# +# 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. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/t2_analyst \ +-d @${USER} diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json new file mode 100644 index 0000000000000..43133a62ec56b --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "indices": [ + { + "names": ["logs-osquery_manager*"], + "privileges": ["read"] + } + ] + }, + "kibana": [ + { + "feature": { + "osquery": ["read", "live_queries_all", "saved_queries_all", "packs_read", "run_saved_queries"] + }, + "spaces": ["*"] + } + ] +} + diff --git a/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json new file mode 100644 index 0000000000000..36096b2cc8f06 --- /dev/null +++ b/x-pack/plugins/osquery/scripts/roles_users/t2_analyst/user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["t2_analyst"], + "full_name": "T2 Analyst", + "email": "osquery@example.com" +} diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index 37c08d712e3f6..b37e6032331dd 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -9,7 +9,6 @@ import { pickBy, isEmpty } from 'lodash'; import uuid from 'uuid'; import moment from 'moment-timezone'; -import { PLUGIN_ID } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; @@ -22,6 +21,7 @@ import { import { incrementCount } from '../usage'; import { getInternalSavedObjectsClient } from '../../usage/collector'; +import { savedQuerySavedObjectType } from '../../../common/types'; export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( @@ -33,15 +33,38 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon CreateActionRequestBodySchema >(createActionRequestBodySchema), }, - options: { - tags: [`access:${PLUGIN_ID}-readLiveQueries`, `access:${PLUGIN_ID}-runSavedQueries`], - }, }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; + const soClient = context.core.savedObjects.client; const internalSavedObjectsClient = await getInternalSavedObjectsClient( osqueryContext.getStartServices ); + const [coreStartServices] = await osqueryContext.getStartServices(); + let savedQueryId = request.body.saved_query_id; + + const { + osquery: { writeLiveQueries, runSavedQueries }, + } = await coreStartServices.capabilities.resolveCapabilities(request); + + const isInvalid = !(writeLiveQueries || (runSavedQueries && request.body.saved_query_id)); + + if (isInvalid) { + return response.forbidden(); + } + + if (request.body.saved_query_id && runSavedQueries) { + const savedQueries = await soClient.find({ + type: savedQuerySavedObjectType, + }); + const actualSavedQuery = savedQueries.saved_objects.find( + (savedQuery) => savedQuery.id === request.body.saved_query_id + ); + + if (actualSavedQuery) { + savedQueryId = actualSavedQuery.id; + } + } const { agentSelection } = request.body as { agentSelection: AgentSelection }; const selectedAgents = await parseAgentSelection( @@ -55,8 +78,6 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon return response.badRequest({ body: new Error('No agents found for selection') }); } - // TODO: Add check for `runSavedQueries` only - try { const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username; const action = { @@ -71,7 +92,7 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon { id: uuid.v4(), query: request.body.query, - saved_query_id: request.body.saved_query_id, + saved_query_id: savedQueryId, ecs_mapping: request.body.ecs_mapping, }, (value) => !isEmpty(value) From 96515f559162c54896cc356193a2a531c1e3b6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 24 Mar 2022 11:11:51 +0100 Subject: [PATCH 109/132] [ILM] Removed `stack_trace` usage from ILM extension for Index Management (#128397) * [ILM] Removed `stack_trace` usage from ILM (Index Management extension) * Fixed i18n files --- .../extend_index_management.test.tsx.snap | 76 ------------------- .../__jest__/extend_index_management.test.tsx | 1 - .../common/types/policies.ts | 1 - .../components/index_lifecycle_summary.tsx | 30 +------- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 7 files changed, 1 insertion(+), 110 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 25930c07fcd8b..802d684a8a261 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -130,7 +130,6 @@ exports[`extend index management ilm summary extension should return extension w "step": "ERROR", "step_info": Object { "reason": "setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined", - "stack_trace": "fakestacktrace", "type": "illegal_argument_exception", }, "step_time_millis": 1544187776208, @@ -332,81 +331,6 @@ exports[`extend index management ilm summary extension should return extension w illegal_argument_exception : setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined - -
- - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="stackPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - > -
-
- - - -
-
-
diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx index eaebd6381d984..544aad4c52088 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx @@ -113,7 +113,6 @@ const indexWithLifecycleError = { step_info: { type: 'illegal_argument_exception', reason: 'setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined', - stack_trace: 'fakestacktrace', }, phase_execution: { policy: 'testy', diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 085179f14913d..ad1b1b2b28880 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -229,7 +229,6 @@ export interface IndexLifecyclePolicy { step?: string; step_info?: { reason?: string; - stack_trace?: string; type?: string; message?: string; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx index 4a34a4eb11ea4..fa148a5ba960b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx @@ -10,7 +10,6 @@ import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { - EuiButtonEmpty, EuiCallOut, EuiCodeBlock, EuiFlexGroup, @@ -108,31 +107,6 @@ export class IndexLifecycleSummary extends Component { closePhaseExecutionPopover = () => { this.setState({ showPhaseExecutionPopover: false }); }; - renderStackPopoverButton(ilm: IndexLifecyclePolicy) { - if (!ilm.step_info!.stack_trace) { - return null; - } - const button = ( - - - - ); - return ( - -
-
{ilm.step_info!.stack_trace}
-
-
- ); - } renderPhaseExecutionPopoverButton(ilm: IndexLifecyclePolicy) { const button = ( @@ -257,12 +231,10 @@ export class IndexLifecycleSummary extends Component { iconType="cross" > {ilm.step_info.type}: {ilm.step_info.reason} - - {this.renderStackPopoverButton(ilm)} ) : null} - {ilm.step_info && ilm.step_info!.message && !ilm.step_info!.stack_trace ? ( + {ilm.step_info && ilm.step_info!.message ? ( <> Date: Thu, 24 Mar 2022 12:18:31 +0200 Subject: [PATCH 110/132] Add authentication to apis --- .../cloud_security_posture/server/plugin.ts | 3 +- .../routes/benchmarks/benchmarks.test.ts | 73 ++++++++++++++++++- .../server/routes/benchmarks/benchmarks.ts | 9 ++- .../compliance_dashboard.test.ts | 72 ++++++++++++++++++ .../compliance_dashboard.ts | 5 +- .../update_rules_configuration.test.ts | 56 +++++++++++++- .../update_rules_configuration.ts | 8 +- .../server/routes/findings/findings.test.ts | 49 +++++++++++++ .../server/routes/findings/findings.ts | 9 ++- .../server/routes/index.ts | 4 +- .../cloud_security_posture/server/types.ts | 29 +++++++- 11 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index f2f81ed608ba4..2709518ffbc5f 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -18,6 +18,7 @@ import type { CspServerPluginStart, CspServerPluginSetupDeps, CspServerPluginStartDeps, + CspRequestHandlerContext, } from './types'; import { defineRoutes } from './routes'; import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; @@ -55,7 +56,7 @@ export class CspPlugin core.savedObjects.registerType(cspRuleAssetType); - const router = core.http.createRouter(); + const router = core.http.createRouter(); // Register server side APIs defineRoutes(router, cspAppContext); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts index 8d33d3db189d3..f6363794213ac 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -4,7 +4,18 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { httpServiceMock, loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; +import { + ElasticsearchClientMock, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from 'src/core/server/elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from 'src/core/server/http/router/request'; import { defineGetBenchmarksRoute, benchmarksInputSchema, @@ -14,6 +25,7 @@ import { getAgentPolicies, createBenchmarkEntry, } from './benchmarks'; + import { SavedObjectsClientContract } from 'src/core/server'; import { createMockAgentPolicyService, @@ -25,6 +37,17 @@ import { AgentPolicy } from '../../../../fleet/common'; import { CspAppService } from '../../lib/csp_app_services'; import { CspAppContext } from '../../plugin'; +export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): KibanaRequest => { + return { + core: { + elasticsearch: { + client: { asCurrentUser: mockEsClient }, + }, + }, + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; +}; + function createMockAgentPolicy(props: Partial = {}): AgentPolicy { return { id: 'some-uuid1', @@ -66,6 +89,54 @@ describe('benchmarks API', () => { expect(config.path).toEqual('/api/csp/benchmarks'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + describe('test input schema', () => { it('expect to find default values', async () => { const validatedQuery = benchmarksInputSchema.validate({}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts index 1e6eadb0c77f6..366fcd9e409e9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -5,7 +5,7 @@ * 2.0. */ import { uniq, map } from 'lodash'; -import type { IRouter, SavedObjectsClientContract } from 'src/core/server'; +import type { SavedObjectsClientContract } from 'src/core/server'; import { schema as rt, TypeOf } from '@kbn/config-schema'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { @@ -23,6 +23,7 @@ import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../com import { CspAppContext } from '../../plugin'; import type { Benchmark } from '../../../common/types'; import { isNonNullable } from '../../../common/utils/helpers'; +import { CspRouter } from '../../types'; type BenchmarksQuerySchema = TypeOf; @@ -132,13 +133,17 @@ const createBenchmarks = ( .filter(isNonNullable); }); -export const defineGetBenchmarksRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineGetBenchmarksRoute = (router: CspRouter, cspContext: CspAppContext): void => router.get( { path: BENCHMARKS_ROUTE_PATH, validate: { query: benchmarksInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const soClient = context.core.savedObjects.client; const { query } = request; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts new file mode 100644 index 0000000000000..95addd9c055de --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import // eslint-disable-next-line @kbn/eslint/no-restricted-paths +'src/core/server/elasticsearch/client/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest } from 'src/core/server/http/router/request'; +import { defineGetComplianceDashboardRoute } from './compliance_dashboard'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; + +describe('compliance dashboard permissions API', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetComplianceDashboardRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetComplianceDashboardRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts index f554eb91a4a49..e414dab92606a 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ElasticsearchClient, IRouter } from 'src/core/server'; +import type { ElasticsearchClient } from 'src/core/server'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { AggregationsMultiBucketAggregateBase as Aggregation, @@ -19,6 +19,7 @@ import { CspAppContext } from '../../plugin'; import { getResourcesTypes } from './get_resources_types'; import { getClusters } from './get_clusters'; import { getStats } from './get_stats'; +import { CspRouter } from '../../types'; export interface ClusterBucket { ordered_top_hits: AggregationsTopHitsAggregate; @@ -75,7 +76,7 @@ const getLatestCyclesIds = async (esClient: ElasticsearchClient): Promise router.get( diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts index 4e534d565d7e3..c558caea1e9d9 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts @@ -6,7 +6,12 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; -import { savedObjectsClientMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { + savedObjectsClientMock, + httpServiceMock, + loggingSystemMock, + httpServerMock, +} from 'src/core/server/mocks'; import { convertRulesConfigToYaml, createRulesConfig, @@ -24,6 +29,7 @@ import { createPackagePolicyServiceMock } from '../../../../fleet/server/mocks'; import { cspRuleAssetSavedObjectType, CspRuleSchema } from '../../../common/schemas/csp_rule'; import { ElasticsearchClient, + KibanaRequest, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'kibana/server'; @@ -55,6 +61,54 @@ describe('Update rules configuration API', () => { expect(config.path).toEqual('/api/csp/update_rules_config'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + const [_, handler] = router.post.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineUpdateRulesConfigRoute(router, cspContext); + const [_, handler] = router.post.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + it('validate getCspRules input parameters', async () => { mockSoClient = savedObjectsClientMock.create(); mockSoClient.find.mockResolvedValueOnce({} as SavedObjectsFindResponse); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts index 50a4759c5ec52..a57d3902f266c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts @@ -6,7 +6,6 @@ */ import type { ElasticsearchClient, - IRouter, SavedObjectsClientContract, SavedObjectsFindResponse, } from 'src/core/server'; @@ -24,6 +23,7 @@ import { CspRuleSchema, cspRuleAssetSavedObjectType } from '../../../common/sche import { UPDATE_RULES_CONFIG_ROUTE_PATH } from '../../../common/constants'; import { CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; import { PackagePolicyServiceInterface } from '../../../../fleet/server'; +import { CspRouter } from '../../types'; export const getPackagePolicy = async ( soClient: SavedObjectsClientContract, @@ -99,13 +99,17 @@ export const updatePackagePolicy = ( return packagePolicyService.update(soClient, esClient, packagePolicy.id, updatedPackagePolicy); }; -export const defineUpdateRulesConfigRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineUpdateRulesConfigRoute = (router: CspRouter, cspContext: CspAppContext): void => router.post( { path: UPDATE_RULES_CONFIG_ROUTE_PATH, validate: { query: configurationUpdateInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const esClient = context.core.elasticsearch.client.asCurrentUser; const soClient = context.core.savedObjects.client; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts index cfd180a86169d..c41245db04685 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts @@ -27,6 +27,7 @@ export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): Kibana client: { asCurrentUser: mockEsClient }, }, }, + fleet: { authz: { fleet: { all: true } } }, } as unknown as KibanaRequest; }; @@ -56,6 +57,54 @@ describe('findings API', () => { expect(config.path).toEqual('/api/csp/findings'); }); + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: true } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = { + fleet: { authz: { fleet: { all: false } } }, + } as unknown as KibanaRequest; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + describe('test input schema', () => { it('expect to find default values', async () => { const validatedQuery = findingsInputSchema.validate({}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts index ca95efae3d56a..cdbbfbe5ff69d 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, Logger } from 'src/core/server'; +import type { Logger } from 'src/core/server'; import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { QueryDslBoolQuery } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -16,6 +16,7 @@ import { getLatestCycleIds } from './get_latest_cycle_ids'; import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants'; import { CspAppContext } from '../../plugin'; +import { CspRouter } from '../../types'; type FindingsQuerySchema = TypeOf; @@ -103,13 +104,17 @@ const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions ...getSearchFields(queryParams.fields), }); -export const defineFindingsIndexRoute = (router: IRouter, cspContext: CspAppContext): void => +export const defineFindingsIndexRoute = (router: CspRouter, cspContext: CspAppContext): void => router.get( { path: FINDINGS_ROUTE_PATH, validate: { query: findingsInputSchema }, }, async (context, request, response) => { + if (!context.fleet.authz.fleet.all) { + return response.forbidden(); + } + try { const esClient = context.core.elasticsearch.client.asCurrentUser; const options = buildOptionsRequest(request.query); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/index.ts b/x-pack/plugins/cloud_security_posture/server/routes/index.ts index aa04a610aa486..a0981e2a956cd 100755 --- a/x-pack/plugins/cloud_security_posture/server/routes/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/index.ts @@ -5,14 +5,14 @@ * 2.0. */ -import type { IRouter } from '../../../../../src/core/server'; import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard'; import { defineGetBenchmarksRoute } from './benchmarks/benchmarks'; import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings'; import { defineUpdateRulesConfigRoute } from './configuration/update_rules_configuration'; import { CspAppContext } from '../plugin'; +import { CspRouter } from '../types'; -export function defineRoutes(router: IRouter, cspContext: CspAppContext) { +export function defineRoutes(router: CspRouter, cspContext: CspAppContext) { defineGetComplianceDashboardRoute(router, cspContext); defineGetFindingsIndexRoute(router, cspContext); defineGetBenchmarksRoute(router, cspContext); diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 4e70027013df8..9fe602424321c 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -10,7 +10,14 @@ import type { PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; -import type { FleetStartContract } from '../../fleet/server'; +import type { + RouteMethod, + KibanaResponseFactory, + RequestHandler, + IRouter, +} from '../../../../src/core/server'; + +import type { FleetStartContract, FleetRequestHandlerContext } from '../../fleet/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} @@ -29,3 +36,23 @@ export interface CspServerPluginStartDeps { data: DataPluginStart; fleet: FleetStartContract; } + +export type CspRequestHandlerContext = FleetRequestHandlerContext; + +/** + * Convenience type for request handlers in CSP that includes the CspRequestHandlerContext type + * @internal + */ +export type CspRequestHandler< + P = unknown, + Q = unknown, + B = unknown, + Method extends RouteMethod = any, + ResponseFactory extends KibanaResponseFactory = KibanaResponseFactory +> = RequestHandler; + +/** + * Convenience type for routers in Csp that includes the CspRequestHandlerContext type + * @internal + */ +export type CspRouter = IRouter; From ee3acc5e22d7b144ee7ba0d57cea0a13cefbe310 Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Thu, 24 Mar 2022 11:07:33 +0000 Subject: [PATCH 111/132] Re-organize rule types in alerts (#128249) * Update anomaly rule descriptions * re-order rule types * renaming files and anomaly alert type * fix comment for anomaly --- x-pack/plugins/apm/common/alert_types.ts | 10 +-- .../apm/common/utils/formatters/alert_url.ts | 2 +- .../alerting/register_apm_alerts.ts | 12 ++- .../alerting_popover_flyout.tsx | 83 ++++++------------- ...ts => register_anomaly_alert_type.test.ts} | 12 +-- ...type.ts => register_anomaly_alert_type.ts} | 18 ++-- .../routes/alerts/register_apm_alerts.ts | 4 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 10 files changed, 52 insertions(+), 97 deletions(-) rename x-pack/plugins/apm/server/routes/alerts/{register_transaction_duration_anomaly_alert_type.test.ts => register_anomaly_alert_type.test.ts} (93%) rename x-pack/plugins/apm/server/routes/alerts/{register_transaction_duration_anomaly_alert_type.ts => register_anomaly_alert_type.ts} (94%) diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index 04288fccf0a05..f2f021b81d76d 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -22,7 +22,7 @@ export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. TransactionErrorRate = 'apm.transaction_error_rate', TransactionDuration = 'apm.transaction_duration', - TransactionDurationAnomaly = 'apm.transaction_duration_anomaly', + Anomaly = 'apm.anomaly', } export const THRESHOLD_MET_GROUP_ID = 'threshold_met'; @@ -127,7 +127,7 @@ export function formatTransactionErrorRateReason({ }); } -export function formatTransactionDurationAnomalyReason({ +export function formatAnomalyReason({ serviceName, severityLevel, measured, @@ -188,9 +188,9 @@ export const ALERT_TYPES_CONFIG: Record< producer: APM_SERVER_FEATURE_ID, isExportable: true, }, - [AlertType.TransactionDurationAnomaly]: { - name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { - defaultMessage: 'Latency anomaly', + [AlertType.Anomaly]: { + name: i18n.translate('xpack.apm.anomalyAlert.name', { + defaultMessage: 'Anomaly', }), actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, diff --git a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts index a88f69b4ef5c7..982da4803cb57 100644 --- a/x-pack/plugins/apm/common/utils/formatters/alert_url.ts +++ b/x-pack/plugins/apm/common/utils/formatters/alert_url.ts @@ -27,7 +27,7 @@ export const getAlertUrlErrorCount = ( environment: serviceEnv ?? ENVIRONMENT_ALL.value, }, }); -// This formatter is for TransactionDuration, TransactionErrorRate, and TransactionDurationAnomaly. +// This formatter is for TransactionDuration, TransactionErrorRate, and Anomaly. export const getAlertUrlTransaction = ( serviceName: string, serviceEnv: string | undefined, diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 692165f2b2ff5..69ed7d73c3115 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -147,13 +147,11 @@ export function registerApmAlerts( }); observabilityRuleTypeRegistry.register({ - id: AlertType.TransactionDurationAnomaly, - description: i18n.translate( - 'xpack.apm.alertTypes.transactionDurationAnomaly.description', - { - defaultMessage: 'Alert when the latency of a service is abnormal.', - } - ), + id: AlertType.Anomaly, + description: i18n.translate('xpack.apm.alertTypes.anomaly.description', { + defaultMessage: + 'Alert when either the latency, throughput, or failed transaction rate of a service is anomalous.', + }), format: ({ fields }) => ({ reason: fields[ALERT_REASON]!, link: getAlertUrlTransaction( diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx index f988917515fbb..164a413a548ee 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/alerting_popover_flyout.tsx @@ -30,7 +30,7 @@ const transactionErrorRateLabel = i18n.translate( { defaultMessage: 'Failed transaction rate' } ); const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', { - defaultMessage: 'Error count', + defaultMessage: ' Create error count rule', }); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.home.alertsMenu.createThresholdAlert', @@ -41,11 +41,7 @@ const createAnomalyAlertAlertLabel = i18n.translate( { defaultMessage: 'Create anomaly rule' } ); -const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = - 'create_transaction_duration_panel'; -const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID = - 'create_transaction_error_rate_panel'; -const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel'; +const CREATE_THRESHOLD_PANEL_ID = 'create_threshold_panel'; interface Props { basePath: IBasePath; @@ -86,16 +82,26 @@ export function AlertingPopoverAndFlyout({ ...(canSaveAlerts ? [ { - name: transactionDurationLabel, - panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, - }, - { - name: transactionErrorRateLabel, - panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_PANEL_ID, }, + ...(canReadAnomalies + ? [ + { + name: createAnomalyAlertAlertLabel, + onClick: () => { + setAlertType(AlertType.Anomaly); + setPopoverOpen(false); + }, + }, + ] + : []), { name: errorCountLabel, - panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + onClick: () => { + setAlertType(AlertType.ErrorCount); + setPopoverOpen(false); + }, }, ] : []), @@ -114,16 +120,16 @@ export function AlertingPopoverAndFlyout({ ], }, - // latency panel + // Threshold panel { - id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, - title: transactionDurationLabel, + id: CREATE_THRESHOLD_PANEL_ID, + title: createThresholdAlertLabel, items: [ - // threshold alerts + // Latency ...(includeTransactionDuration ? [ { - name: createThresholdAlertLabel, + name: transactionDurationLabel, onClick: () => { setAlertType(AlertType.TransactionDuration); setPopoverOpen(false); @@ -131,30 +137,10 @@ export function AlertingPopoverAndFlyout({ }, ] : []), - - // anomaly alerts - ...(canReadAnomalies - ? [ - { - name: createAnomalyAlertAlertLabel, - onClick: () => { - setAlertType(AlertType.TransactionDurationAnomaly); - setPopoverOpen(false); - }, - }, - ] - : []), - ], - }, - - // Failed transactions panel - { - id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, - title: transactionErrorRateLabel, - items: [ - // threshold alerts + // Throughput *** TO BE ADDED *** + // Failed transactions rate { - name: createThresholdAlertLabel, + name: transactionErrorRateLabel, onClick: () => { setAlertType(AlertType.TransactionErrorRate); setPopoverOpen(false); @@ -162,21 +148,6 @@ export function AlertingPopoverAndFlyout({ }, ], }, - - // error alerts panel - { - id: CREATE_ERROR_COUNT_ALERT_PANEL_ID, - title: errorCountLabel, - items: [ - { - name: createThresholdAlertLabel, - onClick: () => { - setAlertType(AlertType.ErrorCount); - setPopoverOpen(false); - }, - }, - ], - }, ]; return ( diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts similarity index 93% rename from x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts rename to x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts index 2bb8530ca03f6..2f4245c89694a 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { registerAnomalyAlertType } from './register_anomaly_alert_type'; import { ANOMALY_SEVERITY } from '../../../common/ml_constants'; import { MlPluginSetup } from '../../../../ml/server'; import * as GetServiceAnomalies from '../service_map/get_service_anomalies'; @@ -19,7 +19,7 @@ describe('Transaction duration anomaly alert', () => { it('ml is not defined', async () => { const { services, dependencies, executor } = createRuleTypeMocks(); - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml: undefined, }); @@ -47,7 +47,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -98,7 +98,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -174,7 +174,7 @@ describe('Transaction duration anomaly alert', () => { anomalyDetectorsProvider: jest.fn(), } as unknown as MlPluginSetup; - registerTransactionDurationAnomalyAlertType({ + registerAnomalyAlertType({ ...dependencies, ml, }); @@ -190,7 +190,7 @@ describe('Transaction duration anomaly alert', () => { expect(services.alertFactory.create).toHaveBeenCalledTimes(1); expect(services.alertFactory.create).toHaveBeenCalledWith( - 'apm.transaction_duration_anomaly_foo_development_type-foo' + 'apm.anomaly_foo_development_type-foo' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts similarity index 94% rename from x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts rename to x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts index 64f06c9f638f1..04d1fb775cea0 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_anomaly_alert_type.ts @@ -32,7 +32,7 @@ import { AlertType, ALERT_TYPES_CONFIG, ANOMALY_ALERT_SEVERITY_TYPES, - formatTransactionDurationAnomalyReason, + formatAnomalyReason, } from '../../../common/alert_types'; import { getMLJobs } from '../service_map/get_service_anomalies'; import { apmActionVariables } from './action_variables'; @@ -57,10 +57,9 @@ const paramsSchema = schema.object({ ]), }); -const alertTypeConfig = - ALERT_TYPES_CONFIG[AlertType.TransactionDurationAnomaly]; +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.Anomaly]; -export function registerTransactionDurationAnomalyAlertType({ +export function registerAnomalyAlertType({ logger, ruleDataClient, alerting, @@ -74,7 +73,7 @@ export function registerTransactionDurationAnomalyAlertType({ alerting.registerType( createLifecycleRuleType({ - id: AlertType.TransactionDurationAnomaly, + id: AlertType.Anomaly, name: alertTypeConfig.name, actionGroups: alertTypeConfig.actionGroups, defaultActionGroupId: alertTypeConfig.defaultActionGroupId, @@ -215,7 +214,7 @@ export function registerTransactionDurationAnomalyAlertType({ compact(anomalies).forEach((anomaly) => { const { serviceName, environment, transactionType, score } = anomaly; const severityLevel = getSeverity(score); - const reasonMessage = formatTransactionDurationAnomalyReason({ + const reasonMessage = formatAnomalyReason({ measured: score, serviceName, severityLevel, @@ -237,12 +236,7 @@ export function registerTransactionDurationAnomalyAlertType({ : relativeViewInAppUrl; services .alertWithLifecycle({ - id: [ - AlertType.TransactionDurationAnomaly, - serviceName, - environment, - transactionType, - ] + id: [AlertType.Anomaly, serviceName, environment, transactionType] .filter((name) => name) .join('_'), fields: { diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts index 4556abfea1ee5..dfe0310e919b4 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_alerts.ts @@ -10,7 +10,7 @@ import { IBasePath, Logger } from 'kibana/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../alerting/server'; import { IRuleDataClient } from '../../../../rule_registry/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; -import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; +import { registerAnomalyAlertType } from './register_anomaly_alert_type'; import { registerErrorCountAlertType } from './register_error_count_alert_type'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; @@ -27,7 +27,7 @@ export interface RegisterRuleDependencies { export function registerApmAlerts(dependencies: RegisterRuleDependencies) { registerTransactionDurationAlertType(dependencies); - registerTransactionDurationAnomalyAlertType(dependencies); + registerAnomalyAlertType(dependencies); registerErrorCountAlertType(dependencies); registerTransactionErrorRateAlertType(dependencies); } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 8b74d0cc3e1ea..1595abb458a25 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5861,7 +5861,6 @@ "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de latence : \\{\\{context.threshold\\}\\} ms\n- Latence observée : \\{\\{context.triggerValue\\}\\} sur la dernière période de \\{\\{context.interval\\}\\}", "xpack.apm.alertTypes.transactionDuration.description": "Alerte lorsque la latence d'un type de transaction spécifique dans un service dépasse le seuil défini.", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil de sévérité : \\{\\{context.threshold\\}\\}\n- Valeur de sévérité : \\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "Alerte lorsque la latence d'un service est anormale.", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "L'alerte \\{\\{alertName\\}\\} se déclenche en raison des conditions suivantes :\n\n- Nom de service : \\{\\{context.serviceName\\}\\}\n- Type : \\{\\{context.transactionType\\}\\}\n- Environnement : \\{\\{context.environment\\}\\}\n- Seuil : \\{\\{context.threshold\\}\\} %\n- Valeur de déclenchement : \\{\\{context.triggerValue\\}\\} % des erreurs sur la dernière période de \\{\\{context.interval\\}\\}", "xpack.apm.alertTypes.transactionErrorRate.description": "Alerte lorsque le taux d'erreurs de transaction d'un service dépasse un seuil défini.", "xpack.apm.analyzeDataButton.label": "Analyser les données", @@ -6405,7 +6404,6 @@ "xpack.apm.transactionDurationAlert.name": "Seuil de latence", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "Quand", - "xpack.apm.transactionDurationAnomalyAlert.name": "Anomalie de latence", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "Comporte une anomalie avec sévérité", "xpack.apm.transactionDurationLabel": "Durée", "xpack.apm.transactionErrorRateAlert.name": "Seuil du taux de transactions ayant échoué", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b743ecb844df2..e469741130081 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6823,8 +6823,6 @@ "xpack.apm.alertTypes.transactionDuration.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- レイテンシしきい値:\\{\\{context.threshold\\}\\}ミリ秒\n- 観察されたレイテンシ:直前の\\{\\{context.interval\\}\\}に\\{\\{context.triggerValue\\}\\}", "xpack.apm.alertTypes.transactionDuration.description": "サービスの特定のトランザクションタイプのレイテンシが定義されたしきい値を超えたときにアラートを発行します。", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- 重要度しきい値:\\{\\{context.threshold\\}\\}%\n- 重要度値:\\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "サービスのレイテンシが異常であるときにアラートを表示します。", - "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "過去{interval}における{serviceName}のスコア{measured}の{severityLevel}異常が検知されました。", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "次の条件のため、\\{\\{alertName\\}\\}アラートが実行されています。\n\n- サービス名:\\{\\{context.serviceName\\}\\}\n- タイプ:\\{\\{context.transactionType\\}\\}\n- 環境:\\{\\{context.environment\\}\\}\n- しきい値:\\{\\{context.threshold\\}\\}%\n- トリガーされた値:過去\\{\\{context.interval\\}\\}にエラーの\\{\\{context.triggerValue\\}\\}%", "xpack.apm.alertTypes.transactionErrorRate.description": "サービスのトランザクションエラー率が定義されたしきい値を超過したときにアラートを発行します。", "xpack.apm.analyzeDataButton.label": "データの探索", @@ -7638,7 +7636,6 @@ "xpack.apm.transactionDurationAlert.name": "レイテンシしきい値", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "タイミング", - "xpack.apm.transactionDurationAnomalyAlert.name": "レイテンシ異常値", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "異常と重要度があります", "xpack.apm.transactionDurationLabel": "期間", "xpack.apm.transactionErrorRateAlert.name": "失敗したトランザクション率しきい値", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b3fa235b3dac2..165d3814809b7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6837,8 +6837,6 @@ "xpack.apm.alertTypes.transactionDuration.description": "当服务中特定事务类型的延迟超过定义的阈值时告警。", "xpack.apm.alertTypes.transactionDuration.reason": "对于 {serviceName},过去 {interval}的 {aggregationType} 延迟为 {measured}。超出 {threshold} 时告警。", "xpack.apm.alertTypes.transactionDurationAnomaly.defaultActionMessage": "由于以下条件 \\{\\{alertName\\}\\} 告警触发:\n\n- 服务名称:\\{\\{context.serviceName\\}\\}\n- 类型:\\{\\{context.transactionType\\}\\}\n- 环境:\\{\\{context.environment\\}\\}\n- 严重性阈值:\\{\\{context.threshold\\}\\}\n- 严重性值:\\{\\{context.triggerValue\\}\\}\n", - "xpack.apm.alertTypes.transactionDurationAnomaly.description": "服务的延迟异常时告警。", - "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "对于 {serviceName},过去 {interval}检测到分数为 {measured} 的 {severityLevel} 异常。", "xpack.apm.alertTypes.transactionErrorRate.defaultActionMessage": "由于以下条件 \\{\\{alertName\\}\\} 告警触发:\n\n- 服务名称:\\{\\{context.serviceName\\}\\}\n- 类型:\\{\\{context.transactionType\\}\\}\n- 环境:\\{\\{context.environment\\}\\}\n- 阈值:\\{\\{context.threshold\\}\\}%\n- 已触发的值:在过去 \\{\\{context.interval\\}\\}有 \\{\\{context.triggerValue\\}\\}% 的错误", "xpack.apm.alertTypes.transactionErrorRate.description": "当服务中的事务错误率超过定义的阈值时告警。", "xpack.apm.alertTypes.transactionErrorRate.reason": "对于 {serviceName},过去 {interval}的失败事务数为 {measured}。超出 {threshold} 时告警。", @@ -7656,7 +7654,6 @@ "xpack.apm.transactionDurationAlert.name": "延迟阈值", "xpack.apm.transactionDurationAlertTrigger.ms": "ms", "xpack.apm.transactionDurationAlertTrigger.when": "当", - "xpack.apm.transactionDurationAnomalyAlert.name": "延迟异常", "xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity": "有异常,严重性为", "xpack.apm.transactionDurationLabel": "持续时间", "xpack.apm.transactionErrorRateAlert.name": "失败事务率阈值", From 2789f945f599c363882e61ab7926839b35e85fec Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Thu, 24 Mar 2022 12:16:52 +0100 Subject: [PATCH 112/132] Bump nodejs APM agent version to 3.31 (#128459) * use apm with #2618 * bump to 3.31 --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 24367fa77216e..af0168e125544 100644 --- a/package.json +++ b/package.json @@ -241,7 +241,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.30.0", + "elastic-apm-node": "^3.31.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", diff --git a/yarn.lock b/yarn.lock index 5163a6e68be50..cdcf07b3e7341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12786,10 +12786,10 @@ ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.0.tgz#a38a85eae078e3f7f09edda86db6d6419a8ecfea" - integrity sha512-HB6+O0C4GGj9k5bd6yL3QK5prGKh+Rf8Tc5iW0T7FCdh2HliICfGmB6wmdQ2XkClblLtISh7tKYgVr9YgdXl3Q== +elastic-apm-http-client@11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.1.tgz#15dbe99d56d62b3f732d1bd2b51bef6094b78801" + integrity sha512-5AOWlhs2WlZpI+DfgGqY/8Rk7KF8WeevaO8R961eBylavU6GWhLRNiJncohn5jsvrqhmeT19azBvy/oYRN7bJw== dependencies: agentkeepalive "^4.2.1" breadth-filter "^2.0.0" @@ -12802,10 +12802,10 @@ elastic-apm-http-client@11.0.0: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.30.0: - version "3.30.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.30.0.tgz#4df7110324535089f66f7a3a96bf37d2fe47f38b" - integrity sha512-KumRBDGIE+MGgJfteAi9BDqeGxpAYpbovWjNdB5x8T3/zpnQRJkDMSblliEsMwD6uKf2+Nkxzmyq9UZdh5MbGQ== +elastic-apm-node@^3.31.0: + version "3.31.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.31.0.tgz#6e0bf622d922c95ff0127a263babcdeaeea71457" + integrity sha512-0OulazfhkXYbOaGkHncqjwOfxtcvzsDyzUKr6Y1k95HwKrjf1Vi+xPutZv4p/WfDdO+JadphI0U2Uu5ncGB2iA== dependencies: "@elastic/ecs-pino-format" "^1.2.0" after-all-results "^2.0.0" @@ -12814,7 +12814,7 @@ elastic-apm-node@^3.30.0: basic-auth "^2.0.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "11.0.0" + elastic-apm-http-client "11.0.1" end-of-stream "^1.4.4" error-callsites "^2.0.4" error-stack-parser "^2.0.6" From a6fa068cc147f2bbd661937b46ab8ff10ec71962 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Mar 2022 12:17:47 +0100 Subject: [PATCH 113/132] [Lens] Functional test: Set lucene filter which actually matches data (#128408) * set lucene filter which actually matches data * Update show_underlying_data.ts Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/functional/apps/lens/show_underlying_data.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/test/functional/apps/lens/show_underlying_data.ts b/x-pack/test/functional/apps/lens/show_underlying_data.ts index 2444e8714e014..cbe6820ccef4d 100644 --- a/x-pack/test/functional/apps/lens/show_underlying_data.ts +++ b/x-pack/test/functional/apps/lens/show_underlying_data.ts @@ -16,8 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const browser = getService('browser'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/128396 - describe.skip('show underlying data', () => { + describe('show underlying data', () => { it('should show the open button for a compatible saved visualization', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('lnsXYvis'); @@ -83,7 +82,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('indexPattern-filter-by-input > switchQueryLanguageButton'); // apparently setting a filter requires some time before and after typing to work properly await PageObjects.common.sleep(1000); - await PageObjects.lens.setFilterBy('memory'); + await PageObjects.lens.setFilterBy('memory:*'); await PageObjects.common.sleep(1000); await PageObjects.lens.closeDimensionEditor(); @@ -98,7 +97,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('discoverChart'); // check the query expect(await queryBar.getQueryString()).be.eql( - '( ( ip: "220.120.146.16" ) OR ( ip: "152.56.56.106" ) OR ( ip: "111.55.80.52" ) )' + '( ( ip: "86.252.46.140" ) OR ( ip: "155.34.86.215" ) OR ( ip: "133.198.170.210" ) )' ); const filterPills = await filterBar.getFiltersLabel(); expect(filterPills.length).to.be(1); From 6dbad1d087fdb464e042946bcb49b7d0b101ab33 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Thu, 24 Mar 2022 08:01:04 -0400 Subject: [PATCH 114/132] [Upgrade Assistant] Restrict UI to Kibana admins (#127922) (#128418) --- .../app/privileges.test.tsx | 52 +++++++++++++++++++ .../helpers/app_context.mock.ts | 9 +++- .../client_integration/helpers/index.ts | 1 + .../public/application/app.tsx | 35 +++++++++++-- .../public/shared_imports.ts | 1 + .../upgrade_assistant_security.ts | 6 +-- x-pack/test/functional/config.js | 4 +- 7 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx new file mode 100644 index 0000000000000..3ae0c013d694f --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/app/privileges.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { AppDependencies } from '../../../public/types'; +import { setupEnvironment, kibanaVersion, getAppContextMock } from '../helpers'; +import { AppTestBed, setupAppPage } from './app.helpers'; + +describe('Privileges', () => { + let testBed: AppTestBed; + let httpSetup: ReturnType['httpSetup']; + beforeEach(async () => { + const mockEnvironment = setupEnvironment(); + httpSetup = mockEnvironment.httpSetup; + }); + + describe('when user is not a Kibana global admin', () => { + beforeEach(async () => { + const appContextMock = getAppContextMock(kibanaVersion) as unknown as AppDependencies; + const servicesMock = { + ...appContextMock.services, + core: { + ...appContextMock.services.core, + application: { + capabilities: { + spaces: { + manage: false, + }, + }, + }, + }, + }; + + await act(async () => { + testBed = await setupAppPage(httpSetup, { services: servicesMock }); + }); + + testBed.component.update(); + }); + + test('renders not authorized message', () => { + const { exists } = testBed; + expect(exists('overview')).toBe(false); + expect(exists('missingKibanaPrivilegesMessage')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts index 3ddfeb3b057ea..3ceadecb208df 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/app_context.mock.ts @@ -88,7 +88,14 @@ export const getAppContextMock = (kibanaVersion: SemVer) => ({ notifications: notificationServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), history: scopedHistoryMock.create(), - application: applicationServiceMock.createStartContract(), + application: { + ...applicationServiceMock.createStartContract(), + capabilities: { + spaces: { + manage: true, + }, + }, + }, }, }, plugins: { diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts index f70bfd00e9c07..4ae44f0027069 100644 --- a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/helpers/index.ts @@ -7,3 +7,4 @@ export { setupEnvironment, WithAppDependencies, kibanaVersion } from './setup_environment'; export { advanceTime } from './time_manipulation'; +export { getAppContextMock } from './app_context.mock'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 4b2b85638e8be..00c910fd648f7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -19,6 +19,7 @@ import { AuthorizationProvider, RedirectAppLinks, KibanaThemeProvider, + NotAuthorizedSection, } from '../shared_imports'; import { AppDependencies } from '../types'; import { AppContextProvider, useAppContext } from './app_context'; @@ -35,18 +36,46 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const AppHandlingClusterUpgradeState: React.FunctionComponent = () => { const { isReadOnlyMode, - services: { api }, + services: { api, core }, } = useAppContext(); - const [clusterUpgradeState, setClusterUpradeState] = + const missingManageSpacesPrivilege = core.application.capabilities.spaces.manage !== true; + + const [clusterUpgradeState, setClusterUpgradeState] = useState('isPreparingForUpgrade'); useEffect(() => { api.onClusterUpgradeStateChange((newClusterUpgradeState: ClusterUpgradeState) => { - setClusterUpradeState(newClusterUpgradeState); + setClusterUpgradeState(newClusterUpgradeState); }); }, [api]); + if (missingManageSpacesPrivilege) { + return ( + + + } + message={ + + } + /> + + ); + } + // Read-only mode will be enabled up until the last minor before the next major release if (isReadOnlyMode) { return ; diff --git a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts index 988bb1363398b..7d23f88a95c44 100644 --- a/x-pack/plugins/upgrade_assistant/public/shared_imports.ts +++ b/x-pack/plugins/upgrade_assistant/public/shared_imports.ts @@ -22,6 +22,7 @@ export { WithPrivileges, AuthorizationProvider, AuthorizationContext, + NotAuthorizedSection, } from '../../../../src/plugins/es_ui_shared/public/'; export { Storage } from '../../../../src/plugins/kibana_utils/public'; diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index a7368dfbedf07..a5b28a6bf6c06 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -56,11 +56,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('[SkipCloud] global dashboard read with global_upgrade_assistant_role', function () { this.tags('skipCloud'); - it('should render the "Stack" section with Upgrde Assistant', async function () { + it('should render the "Stack" section with Upgrade Assistant', async function () { await PageObjects.common.navigateToApp('management'); const sections = await managementMenu.getSections(); - expect(sections).to.have.length(3); - expect(sections[2]).to.eql({ + expect(sections).to.have.length(5); + expect(sections[4]).to.eql({ sectionId: 'stack', sectionLinks: ['license_management', 'upgrade_assistant'], }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index b7774b463d058..c32d6f7304aea 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -464,9 +464,7 @@ export default async function ({ readConfigFile }) { }, kibana: [ { - feature: { - discover: ['read'], - }, + base: ['all'], spaces: ['*'], }, ], From 337fadfe291eced5b090f83605c856ed0233d557 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Thu, 24 Mar 2022 12:28:13 +0000 Subject: [PATCH 115/132] [ML] Transforms: Migrate IndexPattern service usage to DataView service (#128247) * [ML] Transforms: Migrate IndexPattern service usage to DataView service * [ML] Fix tests * [ML] Fix API delete_transforms tests * [ML] Edits from review --- .../transform/common/api_schemas/common.ts | 6 +- .../common/api_schemas/delete_transforms.ts | 4 +- .../transform/common/types/data_view.test.ts | 21 ++++++ .../types/{index_pattern.ts => data_view.ts} | 10 +-- .../common/types/index_pattern.test.ts | 21 ------ .../transform/common/types/transform.ts | 2 +- .../transform/public/app/common/data_grid.ts | 4 +- .../public/app/common/request.test.ts | 38 +++++----- .../transform/public/app/common/request.ts | 18 ++--- .../public/app/hooks/__mocks__/use_api.ts | 2 +- .../transform/public/app/hooks/use_api.ts | 4 +- .../public/app/hooks/use_delete_transform.tsx | 42 +++++------ .../public/app/hooks/use_index_data.test.tsx | 18 ++--- .../public/app/hooks/use_index_data.ts | 50 ++++++------- .../public/app/hooks/use_pivot_data.ts | 6 +- .../app/hooks/use_search_items/common.ts | 69 +++++++++--------- .../use_search_items/use_search_items.ts | 26 +++---- .../clone_transform_section.tsx | 6 +- .../source_search_bar/source_search_bar.tsx | 6 +- .../step_create/step_create_form.test.tsx | 4 +- .../step_create/step_create_form.tsx | 32 ++++----- .../apply_transform_config_to_define_state.ts | 10 +-- .../step_define/common/common.test.ts | 16 ++--- .../components/filter_agg_form.test.tsx | 16 ++--- .../filter_agg/components/filter_agg_form.tsx | 16 ++--- .../components/filter_term_form.tsx | 4 +- .../common/get_pivot_dropdown_options.ts | 8 +-- .../hooks/use_latest_function_config.ts | 22 +++--- .../step_define/hooks/use_pivot_config.ts | 6 +- .../step_define/hooks/use_search_bar.ts | 4 +- .../step_define/hooks/use_step_define_form.ts | 12 ++-- .../step_define/step_define_form.test.tsx | 8 +-- .../step_define/step_define_form.tsx | 17 ++--- .../step_define/step_define_summary.test.tsx | 6 +- .../step_define/step_define_summary.tsx | 6 +- .../components/step_details/common.ts | 10 +-- .../step_details/step_details_form.tsx | 72 +++++++++---------- .../step_details/step_details_summary.tsx | 10 +-- .../step_details/step_details_time_field.tsx | 14 ++-- .../components/wizard/wizard.tsx | 26 +++---- .../action_clone/use_clone_action.tsx | 20 +++--- .../action_delete/delete_action_modal.tsx | 20 +++--- .../action_delete/use_delete_action.tsx | 18 ++--- .../discover_action_name.test.tsx | 4 +- .../action_discover/discover_action_name.tsx | 10 +-- .../action_discover/use_action_discover.tsx | 43 ++++++----- .../action_edit/use_edit_action.tsx | 16 ++--- .../edit_transform_flyout.tsx | 9 +-- .../edit_transform_flyout_form.tsx | 18 ++--- .../expanded_row_preview_pane.tsx | 4 +- .../components/transform_list/use_actions.tsx | 2 +- .../transform_management_section.tsx | 2 +- .../public/app/services/es_index_service.ts | 6 +- .../server/routes/api/field_histograms.ts | 17 ++--- .../transform/server/routes/api/transforms.ts | 40 +++++------ .../apis/transform/delete_transforms.ts | 20 +++--- .../test/functional/apps/transform/cloning.ts | 4 +- .../apps/transform/creation_index_pattern.ts | 4 +- .../transform/creation_runtime_mappings.ts | 4 +- .../apps/transform/creation_saved_search.ts | 4 +- .../functional/services/transform/wizard.ts | 8 +-- 61 files changed, 451 insertions(+), 494 deletions(-) create mode 100644 x-pack/plugins/transform/common/types/data_view.test.ts rename x-pack/plugins/transform/common/types/{index_pattern.ts => data_view.ts} (61%) delete mode 100644 x-pack/plugins/transform/common/types/index_pattern.test.ts diff --git a/x-pack/plugins/transform/common/api_schemas/common.ts b/x-pack/plugins/transform/common/api_schemas/common.ts index 285f3879681c7..4cd7d865a69f3 100644 --- a/x-pack/plugins/transform/common/api_schemas/common.ts +++ b/x-pack/plugins/transform/common/api_schemas/common.ts @@ -29,12 +29,12 @@ export const transformStateSchema = schema.oneOf([ schema.literal(TRANSFORM_STATE.WAITING), ]); -export const indexPatternTitleSchema = schema.object({ +export const dataViewTitleSchema = schema.object({ /** Title of the data view for which to return stats. */ - indexPatternTitle: schema.string(), + dataViewTitle: schema.string(), }); -export type IndexPatternTitleSchema = TypeOf; +export type DataViewTitleSchema = TypeOf; export const transformIdParamSchema = schema.object({ transformId: schema.string(), diff --git a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts index 05fefc278e350..e12c144b60af6 100644 --- a/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/delete_transforms.ts @@ -20,7 +20,7 @@ export const deleteTransformsRequestSchema = schema.object({ }) ), deleteDestIndex: schema.maybe(schema.boolean()), - deleteDestIndexPattern: schema.maybe(schema.boolean()), + deleteDestDataView: schema.maybe(schema.boolean()), forceDelete: schema.maybe(schema.boolean()), }); @@ -29,7 +29,7 @@ export type DeleteTransformsRequestSchema = TypeOf { + test('isDataView()', () => { + expect(isDataView(0)).toBe(false); + expect(isDataView('')).toBe(false); + expect(isDataView(null)).toBe(false); + expect(isDataView({})).toBe(false); + expect(isDataView({ attribute: 'value' })).toBe(false); + expect(isDataView({ fields: [], title: 'Data View Title', getComputedFields: () => {} })).toBe( + true + ); + }); +}); diff --git a/x-pack/plugins/transform/common/types/index_pattern.ts b/x-pack/plugins/transform/common/types/data_view.ts similarity index 61% rename from x-pack/plugins/transform/common/types/index_pattern.ts rename to x-pack/plugins/transform/common/types/data_view.ts index 0485de8982e1a..c09b84dea1e4e 100644 --- a/x-pack/plugins/transform/common/types/index_pattern.ts +++ b/x-pack/plugins/transform/common/types/data_view.ts @@ -5,18 +5,18 @@ * 2.0. */ -import type { IndexPattern } from '../../../../../src/plugins/data/common'; +import type { DataView } from '../../../../../src/plugins/data_views/common'; import { isPopulatedObject } from '../shared_imports'; -// Custom minimal type guard for IndexPattern to check against the attributes used in transforms code. -export function isIndexPattern(arg: any): arg is IndexPattern { +// Custom minimal type guard for DataView to check against the attributes used in transforms code. +export function isDataView(arg: any): arg is DataView { return ( isPopulatedObject(arg, ['title', 'fields']) && // `getComputedFields` is inherited, so it's not possible to // check with `hasOwnProperty` which is used by isPopulatedObject() - 'getComputedFields' in (arg as IndexPattern) && - typeof (arg as IndexPattern).getComputedFields === 'function' && + 'getComputedFields' in (arg as DataView) && + typeof (arg as DataView).getComputedFields === 'function' && typeof arg.title === 'string' && Array.isArray(arg.fields) ); diff --git a/x-pack/plugins/transform/common/types/index_pattern.test.ts b/x-pack/plugins/transform/common/types/index_pattern.test.ts deleted file mode 100644 index 57d57473d99de..0000000000000 --- a/x-pack/plugins/transform/common/types/index_pattern.test.ts +++ /dev/null @@ -1,21 +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 { isIndexPattern } from './index_pattern'; - -describe('index_pattern', () => { - test('isIndexPattern()', () => { - expect(isIndexPattern(0)).toBe(false); - expect(isIndexPattern('')).toBe(false); - expect(isIndexPattern(null)).toBe(false); - expect(isIndexPattern({})).toBe(false); - expect(isIndexPattern({ attribute: 'value' })).toBe(false); - expect( - isIndexPattern({ fields: [], title: 'Data View Title', getComputedFields: () => {} }) - ).toBe(true); - }); -}); diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index 92ffc0b99bc3d..a196111bf6678 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -13,7 +13,7 @@ import type { PivotAggDict } from './pivot_aggs'; import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; -export type IndexPattern = string; +export type DataView = string; export type TransformId = string; /** diff --git a/x-pack/plugins/transform/public/app/common/data_grid.ts b/x-pack/plugins/transform/public/app/common/data_grid.ts index 082e73651bb72..43d2b27f13cf9 100644 --- a/x-pack/plugins/transform/public/app/common/data_grid.ts +++ b/x-pack/plugins/transform/public/app/common/data_grid.ts @@ -15,8 +15,8 @@ export const getPivotPreviewDevConsoleStatement = (request: PostTransformsPrevie return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`; }; -export const getIndexDevConsoleStatement = (query: PivotQuery, indexPatternTitle: string) => { - return `GET ${indexPatternTitle}/_search\n${JSON.stringify( +export const getIndexDevConsoleStatement = (query: PivotQuery, dataViewTitle: string) => { + return `GET ${dataViewTitle}/_search\n${JSON.stringify( { query, }, diff --git a/x-pack/plugins/transform/public/app/common/request.test.ts b/x-pack/plugins/transform/public/app/common/request.test.ts index cd34b20cc87a6..f8c5a64099ba2 100644 --- a/x-pack/plugins/transform/public/app/common/request.test.ts +++ b/x-pack/plugins/transform/public/app/common/request.test.ts @@ -80,7 +80,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody()', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody('the-index-pattern-title', query, { + const request = getPreviewTransformRequestBody('the-data-view-title', query, { pivot: { aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, @@ -93,7 +93,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -101,16 +101,12 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with comma-separated index pattern', () => { const query = getPivotQuery('the-query'); - const request = getPreviewTransformRequestBody( - 'the-index-pattern-title,the-other-title', - query, - { - pivot: { - aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, - group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, - }, - } - ); + const request = getPreviewTransformRequestBody('the-data-view-title,the-other-title', query, { + pivot: { + aggregations: { 'the-agg-agg-name': { avg: { field: 'the-agg-field' } } }, + group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, + }, + }); expect(request).toEqual({ pivot: { @@ -118,7 +114,7 @@ describe('Transform: Common', () => { group_by: { 'the-group-by-agg-name': { terms: { field: 'the-group-by-field' } } }, }, source: { - index: ['the-index-pattern-title', 'the-other-title'], + index: ['the-data-view-title', 'the-other-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -178,7 +174,7 @@ describe('Transform: Common', () => { test('getPreviewTransformRequestBody() with missing_buckets config', () => { const query = getPivotQuery('the-query'); const request = getPreviewTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', query, getRequestPayload([aggsAvg], [{ ...groupByTerms, ...{ missing_bucket: true } }]) ); @@ -191,7 +187,7 @@ describe('Transform: Common', () => { }, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-query' } }, }, }); @@ -226,7 +222,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -243,7 +239,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -261,7 +257,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, }, }); @@ -305,7 +301,7 @@ describe('Transform: Common', () => { const transformDetailsState: StepDetailsExposedState = { continuousModeDateField: 'the-continuous-mode-date-field', continuousModeDelay: 'the-continuous-mode-delay', - createIndexPattern: false, + createDataView: false, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -322,7 +318,7 @@ describe('Transform: Common', () => { }; const request = getCreateTransformRequestBody( - 'the-index-pattern-title', + 'the-data-view-title', pivotState, transformDetailsState ); @@ -340,7 +336,7 @@ describe('Transform: Common', () => { docs_per_second: 400, }, source: { - index: ['the-index-pattern-title'], + index: ['the-data-view-title'], query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, runtime_mappings: runtimeMappings, }, diff --git a/x-pack/plugins/transform/public/app/common/request.ts b/x-pack/plugins/transform/public/app/common/request.ts index 36776759eb47a..0f94f82355fd2 100644 --- a/x-pack/plugins/transform/public/app/common/request.ts +++ b/x-pack/plugins/transform/public/app/common/request.ts @@ -8,7 +8,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { HttpFetchError } from '../../../../../../src/core/public'; -import type { IndexPattern } from '../../../../../../src/plugins/data/public'; +import type { DataView } from '../../../../../../src/plugins/data_views/public'; import type { PivotTransformPreviewRequestSchema, @@ -19,7 +19,7 @@ import type { } from '../../../common/api_schemas/transforms'; import { isPopulatedObject } from '../../../common/shared_imports'; import { DateHistogramAgg, HistogramAgg, TermsAgg } from '../../../common/types/pivot_group_by'; -import { isIndexPattern } from '../../../common/types/index_pattern'; +import { isDataView } from '../../../common/types/data_view'; import type { SavedSearchQuery } from '../hooks/use_search_items'; import type { StepDefineExposedState } from '../sections/create_transform/components/step_define'; @@ -78,14 +78,14 @@ export function isDefaultQuery(query: PivotQuery): boolean { } export function getCombinedRuntimeMappings( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): StepDefineExposedState['runtimeMappings'] | undefined { let combinedRuntimeMappings = {}; // And runtime field mappings defined by index pattern - if (isIndexPattern(indexPattern)) { - const computedFields = indexPattern.getComputedFields(); + if (isDataView(dataView)) { + const computedFields = dataView.getComputedFields(); if (computedFields?.runtimeFields !== undefined) { const ipRuntimeMappings = computedFields.runtimeFields; if (isPopulatedObject(ipRuntimeMappings)) { @@ -167,12 +167,12 @@ export const getRequestPayload = ( }; export function getPreviewTransformRequestBody( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], query: PivotQuery, partialRequest?: StepDefineExposedState['previewRequest'] | undefined, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ): PostTransformsPreviewRequestSchema { - const index = indexPatternTitle.split(',').map((name: string) => name.trim()); + const index = dataViewTitle.split(',').map((name: string) => name.trim()); return { source: { @@ -199,12 +199,12 @@ export const getCreateTransformSettingsRequestBody = ( }; export const getCreateTransformRequestBody = ( - indexPatternTitle: IndexPattern['title'], + dataViewTitle: DataView['title'], pivotState: StepDefineExposedState, transformDetailsState: StepDetailsExposedState ): PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema => ({ ...getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, getPivotQuery(pivotState.searchQuery), pivotState.previewRequest, pivotState.runtimeMappings diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 979a98ececabb..cd46caf931e17 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -166,7 +166,7 @@ const apiFactory = () => ({ return Promise.resolve([]); }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 7119ad2719f5e..65c0d2050a5ed 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -226,14 +226,14 @@ export const useApi = () => { } }, async getHistogramsForFields( - indexPatternTitle: string, + dataViewTitle: string, fields: FieldHistogramRequestConfig[], query: string | SavedSearchQuery, runtimeMappings?: FieldHistogramsRequestSchema['runtimeMappings'], samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE ): Promise { try { - return await http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + return await http.post(`${API_BASE_PATH}field_histograms/${dataViewTitle}`, { body: JSON.stringify({ query, fields, diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index ff93f027fc3a4..65a20f2d24ddf 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -30,24 +30,24 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const toastNotifications = useToastNotifications(); const [deleteDestIndex, setDeleteDestIndex] = useState(true); - const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); + const [deleteDataView, setDeleteDataView] = useState(true); const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); - const [indexPatternExists, setIndexPatternExists] = useState(false); + const [dataViewExists, setDataViewExists] = useState(false); const [userCanDeleteDataView, setUserCanDeleteDataView] = useState(false); const toggleDeleteIndex = useCallback( () => setDeleteDestIndex(!deleteDestIndex), [deleteDestIndex] ); - const toggleDeleteIndexPattern = useCallback( - () => setDeleteIndexPattern(!deleteIndexPattern), - [deleteIndexPattern] + const toggleDeleteDataView = useCallback( + () => setDeleteDataView(!deleteDataView), + [deleteDataView] ); - const checkIndexPatternExists = useCallback( + const checkDataViewExists = useCallback( async (indexName: string) => { try { - if (await indexService.indexPatternExists(savedObjects.client, indexName)) { - setIndexPatternExists(true); + if (await indexService.dataViewExists(savedObjects.client, indexName)) { + setDataViewExists(true); } } catch (e) { const error = extractErrorMessage(e); @@ -77,7 +77,7 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { capabilities.indexPatterns.save === true; setUserCanDeleteDataView(canDeleteDataView); if (canDeleteDataView === false) { - setDeleteIndexPattern(false); + setDeleteDataView(false); } } catch (e) { toastNotifications.addDanger( @@ -100,20 +100,20 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const destinationIndex = Array.isArray(config.dest.index) ? config.dest.index[0] : config.dest.index; - checkIndexPatternExists(destinationIndex); + checkDataViewExists(destinationIndex); } else { - setIndexPatternExists(true); + setDataViewExists(true); } - }, [checkIndexPatternExists, checkUserIndexPermission, items]); + }, [checkDataViewExists, checkUserIndexPermission, items]); return { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, }; }; @@ -149,7 +149,7 @@ export const useDeleteTransforms = () => { const successCount: Record = { transformDeleted: 0, destIndexDeleted: 0, - destIndexPatternDeleted: 0, + destDataViewDeleted: 0, }; for (const transformId in results) { // hasOwnProperty check to ensure only properties on object itself, and not its prototypes @@ -179,7 +179,7 @@ export const useDeleteTransforms = () => { ) ); } - if (status.destIndexPatternDeleted?.success) { + if (status.destDataViewDeleted?.success) { toastNotifications.addSuccess( i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewSuccessMessage', @@ -238,8 +238,8 @@ export const useDeleteTransforms = () => { }); } - if (status.destIndexPatternDeleted?.error) { - const error = status.destIndexPatternDeleted.error.reason; + if (status.destDataViewDeleted?.error) { + const error = status.destDataViewDeleted.error.reason; toastNotifications.addDanger({ title: i18n.translate( 'xpack.transform.deleteTransform.deleteAnalyticsWithDataViewErrorMessage', @@ -283,12 +283,12 @@ export const useDeleteTransforms = () => { }) ); } - if (successCount.destIndexPatternDeleted > 0) { + if (successCount.destDataViewDeleted > 0) { toastNotifications.addSuccess( i18n.translate('xpack.transform.transformList.bulkDeleteDestDataViewSuccessMessage', { defaultMessage: 'Successfully deleted {count} destination data {count, plural, one {view} other {views}}.', - values: { count: successCount.destIndexPatternDeleted }, + values: { count: successCount.destDataViewDeleted }, }) ); } diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx index 74d5167c12697..d74c11cbaf607 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.test.tsx @@ -46,7 +46,7 @@ const runtimeMappings = { }; describe('Transform: useIndexData()', () => { - test('indexPattern set triggers loading', async () => { + test('dataView set triggers loading', async () => { const mlShared = await getMlSharedImports(); const wrapper: FC = ({ children }) => ( @@ -61,7 +61,7 @@ describe('Transform: useIndexData()', () => { id: 'the-id', title: 'the-title', fields: [], - } as unknown as SearchItems['indexPattern'], + } as unknown as SearchItems['dataView'], query, runtimeMappings ), @@ -81,10 +81,10 @@ describe('Transform: useIndexData()', () => { describe('Transform: with useIndexData()', () => { test('Minimal initialization, no cross cluster search warning.', async () => { // Arrange - const indexPattern = { - title: 'the-index-pattern-title', + const dataView = { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -93,7 +93,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', @@ -124,10 +124,10 @@ describe('Transform: with useIndexData()', () => { test('Cross-cluster search warning', async () => { // Arrange - const indexPattern = { + const dataView = { title: 'remote:the-index-pattern-title', fields: [] as any[], - } as SearchItems['indexPattern']; + } as SearchItems['dataView']; const mlSharedImports = await getMlSharedImports(); @@ -136,7 +136,7 @@ describe('Transform: with useIndexData()', () => { ml: { DataGrid }, } = useAppDependencies(); const props = { - ...useIndexData(indexPattern, { match_all: {} }, runtimeMappings), + ...useIndexData(dataView, { match_all: {} }, runtimeMappings), copyToClipboard: 'the-copy-to-clipboard-code', copyToClipboardDescription: 'the-copy-to-clipboard-description', dataTestSubj: 'the-data-test-subj', diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index 1d73413b3e386..678ec6d291ceb 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -31,7 +31,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { isRuntimeMappings } from '../../../common/shared_imports'; export const useIndexData = ( - indexPattern: SearchItems['indexPattern'], + dataView: SearchItems['dataView'], query: PivotQuery, combinedRuntimeMappings?: StepDefineExposedState['runtimeMappings'] ): UseIndexDataReturnType => { @@ -51,7 +51,7 @@ export const useIndexData = ( }, } = useAppDependencies(); - const [indexPatternFields, setIndexPatternFields] = useState(); + const [dataViewFields, setDataViewFields] = useState(); // Fetch 500 random documents to determine populated fields. // This is a workaround to avoid passing potentially thousands of unpopulated fields @@ -62,7 +62,7 @@ export const useIndexData = ( setStatus(INDEX_STATUS.LOADING); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -84,21 +84,21 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); // Get all field names for each returned doc and flatten it // to a list of unique field names used across all docs. - const allKibanaIndexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allKibanaIndexPatternFields.includes(d)) + .filter((d) => allDataViewFields.includes(d)) .sort(); setCcsWarning(isCrossClusterSearch && isMissingFields); setStatus(INDEX_STATUS.LOADED); - setIndexPatternFields(populatedFields); + setDataViewFields(populatedFields); }; useEffect(() => { @@ -107,7 +107,7 @@ export const useIndexData = ( }, []); const columns: EuiDataGridColumn[] = useMemo(() => { - if (typeof indexPatternFields === 'undefined') { + if (typeof dataViewFields === 'undefined') { return []; } @@ -124,8 +124,8 @@ export const useIndexData = ( } // Combine the runtime field that are defined from API field - indexPatternFields.forEach((id) => { - const field = indexPattern.fields.getByName(id); + dataViewFields.forEach((id) => { + const field = dataView.fields.getByName(id); if (!field?.runtimeField) { const schema = getDataGridSchemaFromKibanaFieldType(field); result.push({ id, schema }); @@ -134,8 +134,8 @@ export const useIndexData = ( return result.sort((a, b) => a.id.localeCompare(b.id)); }, [ - indexPatternFields, - indexPattern.fields, + dataViewFields, + dataView.fields, combinedRuntimeMappings, getDataGridSchemaFromESFieldType, getDataGridSchemaFromKibanaFieldType, @@ -176,7 +176,7 @@ export const useIndexData = ( }, {} as EsSorting); const esSearchRequest = { - index: indexPattern.title, + index: dataView.title, body: { fields: ['*'], _source: false, @@ -198,7 +198,7 @@ export const useIndexData = ( return; } - const isCrossClusterSearch = indexPattern.title.includes(':'); + const isCrossClusterSearch = dataView.title.includes(':'); const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); @@ -215,16 +215,16 @@ export const useIndexData = ( }; const fetchColumnChartsData = async function () { - const allIndexPatternFieldNames = new Set(indexPattern.fields.map((f) => f.name)); + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); const columnChartsData = await api.getHistogramsForFields( - indexPattern.title, + dataView.title, columns .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) .map((cT) => { // If a column field name has a corresponding keyword field, // fetch the keyword field instead to be able to do aggregations. const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allIndexPatternFieldNames) + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) ? { fieldName: `${fieldName}.keyword`, type: getFieldType(undefined), @@ -247,7 +247,7 @@ export const useIndexData = ( // revert field names with `.keyword` used to do aggregations to their original column name columnChartsData.map((d) => ({ ...d, - ...(isKeywordDuplicate(d.id, allIndexPatternFieldNames) + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) ? { id: removeKeywordPostfix(d.id) } : {}), })) @@ -259,15 +259,9 @@ export const useIndexData = ( // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps - JSON.stringify([ - query, - pagination, - sortingColumns, - indexPatternFields, - combinedRuntimeMappings, - ]), + JSON.stringify([query, pagination, sortingColumns, dataViewFields, combinedRuntimeMappings]), ]); useEffect(() => { @@ -278,12 +272,12 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [ chartsVisible, - indexPattern.title, + dataView.title, // eslint-disable-next-line react-hooks/exhaustive-deps JSON.stringify([query, dataGrid.visibleColumns, combinedRuntimeMappings]), ]); - const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems); + const renderCellValue = useRenderCellValue(dataView, pagination, tableItems); return { ...dataGrid, diff --git a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts index 01cb39ac87fa8..d30237abcdb3f 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_pivot_data.ts @@ -96,7 +96,7 @@ export function getCombinedProperties( } export const usePivotData = ( - indexPatternTitle: SearchItems['indexPattern']['title'], + dataViewTitle: SearchItems['dataView']['title'], query: PivotQuery, validationStatus: StepDefineExposedState['validationStatus'], requestPayload: StepDefineExposedState['previewRequest'], @@ -165,7 +165,7 @@ export const usePivotData = ( setStatus(INDEX_STATUS.LOADING); const previewRequest = getPreviewTransformRequestBody( - indexPatternTitle, + dataViewTitle, query, requestPayload, combinedRuntimeMappings @@ -233,7 +233,7 @@ export const usePivotData = ( getPreviewData(); // custom comparison /* eslint-disable react-hooks/exhaustive-deps */ - }, [indexPatternTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); + }, [dataViewTitle, JSON.stringify([requestPayload, query, combinedRuntimeMappings])]); if (sortingColumns.length > 0) { const sortingColumnsWithTypes = sortingColumns.map((c) => ({ diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 19ff063d11acf..910960cb24eea 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -7,45 +7,45 @@ import { buildEsQuery } from '@kbn/es-query'; import { SavedObjectsClientContract, SimpleSavedObject, IUiSettingsClient } from 'src/core/public'; +import { getEsQueryConfig } from '../../../../../../../src/plugins/data/public'; import { - IndexPattern, - getEsQueryConfig, - IndexPatternsContract, - IndexPatternAttributes, -} from '../../../../../../../src/plugins/data/public'; + DataView, + DataViewAttributes, + DataViewsContract, +} from '../../../../../../../src/plugins/data_views/public'; import { matchAllQuery } from '../../common'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; export type SavedSearchQuery = object; -type IndexPatternId = string; +type DataViewId = string; -let indexPatternCache: Array>> = []; -let fullIndexPatterns; -let currentIndexPattern = null; +let dataViewCache: Array>> = []; +let fullDataViews; +let currentDataView = null; -export let refreshIndexPatterns: () => Promise; +export let refreshDataViews: () => Promise; -export function loadIndexPatterns( +export function loadDataViews( savedObjectsClient: SavedObjectsClientContract, - indexPatterns: IndexPatternsContract + dataViews: DataViewsContract ) { - fullIndexPatterns = indexPatterns; + fullDataViews = dataViews; return savedObjectsClient - .find({ + .find({ type: 'index-pattern', fields: ['id', 'title', 'type', 'fields'], perPage: 10000, }) .then((response) => { - indexPatternCache = response.savedObjects; + dataViewCache = response.savedObjects; - if (refreshIndexPatterns === null) { - refreshIndexPatterns = () => { + if (refreshDataViews === null) { + refreshDataViews = () => { return new Promise((resolve, reject) => { - loadIndexPatterns(savedObjectsClient, indexPatterns) + loadDataViews(savedObjectsClient, dataViews) .then((resp) => { resolve(resp); }) @@ -56,27 +56,24 @@ export function loadIndexPatterns( }; } - return indexPatternCache; + return dataViewCache; }); } -export function getIndexPatternIdByTitle(indexPatternTitle: string): string | undefined { - return indexPatternCache.find((d) => d?.attributes?.title === indexPatternTitle)?.id; +export function getDataViewIdByTitle(dataViewTitle: string): string | undefined { + return dataViewCache.find((d) => d?.attributes?.title === dataViewTitle)?.id; } type CombinedQuery = Record<'bool', any> | object; -export function loadCurrentIndexPattern( - indexPatterns: IndexPatternsContract, - indexPatternId: IndexPatternId -) { - fullIndexPatterns = indexPatterns; - currentIndexPattern = fullIndexPatterns.get(indexPatternId); - return currentIndexPattern; +export function loadCurrentDataView(dataViews: DataViewsContract, dataViewId: DataViewId) { + fullDataViews = dataViews; + currentDataView = fullDataViews.get(dataViewId); + return currentDataView; } export interface SearchItems { - indexPattern: IndexPattern; + dataView: DataView; savedSearch: any; query: any; combinedQuery: CombinedQuery; @@ -84,7 +81,7 @@ export interface SearchItems { // Helper for creating the items used for searching and job creation. export function createSearchItems( - indexPattern: IndexPattern | undefined, + dataView: DataView | undefined, savedSearch: any, config: IUiSettingsClient ): SearchItems { @@ -103,9 +100,9 @@ export function createSearchItems( }, }; - if (!isIndexPattern(indexPattern) && savedSearch !== null && savedSearch.id !== undefined) { + if (!isDataView(dataView) && savedSearch !== null && savedSearch.id !== undefined) { const searchSource = savedSearch.searchSource; - indexPattern = searchSource.getField('index') as IndexPattern; + dataView = searchSource.getField('index') as DataView; query = searchSource.getField('query'); const fs = searchSource.getField('filter'); @@ -113,15 +110,15 @@ export function createSearchItems( const filters = fs.length ? fs : []; const esQueryConfigs = getEsQueryConfig(config); - combinedQuery = buildEsQuery(indexPattern, [query], filters, esQueryConfigs); + combinedQuery = buildEsQuery(dataView, [query], filters, esQueryConfigs); } - if (!isIndexPattern(indexPattern)) { + if (!isDataView(dataView)) { throw new Error('Data view is not defined.'); } return { - indexPattern, + dataView, savedSearch, query, combinedQuery, diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 754cc24b65fec..76fdc77c523e4 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { isIndexPattern } from '../../../../common/types/index_pattern'; +import { isDataView } from '../../../../common/types/data_view'; import { getSavedSearch, getSavedSearchUrlConflictMessage } from '../../../shared_imports'; @@ -17,9 +17,9 @@ import { useAppDependencies } from '../../app_dependencies'; import { createSearchItems, - getIndexPatternIdByTitle, - loadCurrentIndexPattern, - loadIndexPatterns, + getDataViewIdByTitle, + loadCurrentDataView, + loadDataViews, SearchItems, } from './common'; @@ -28,22 +28,22 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const [error, setError] = useState(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const uiSettings = appDeps.uiSettings; const savedObjectsClient = appDeps.savedObjects.client; const [searchItems, setSearchItems] = useState(undefined); async function fetchSavedObject(id: string) { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); - let fetchedIndexPattern; + let fetchedDataView; let fetchedSavedSearch; try { - fetchedIndexPattern = await loadCurrentIndexPattern(indexPatterns, id); + fetchedDataView = await loadCurrentDataView(dataViews, id); } catch (e) { - // Just let fetchedIndexPattern stay undefined in case it doesn't exist. + // Just let fetchedDataView stay undefined in case it doesn't exist. } try { @@ -61,7 +61,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { // Just let fetchedSavedSearch stay undefined in case it doesn't exist. } - if (!isIndexPattern(fetchedIndexPattern) && fetchedSavedSearch === undefined) { + if (!isDataView(fetchedDataView) && fetchedSavedSearch === undefined) { setError( i18n.translate('xpack.transform.searchItems.errorInitializationTitle', { defaultMessage: `An error occurred initializing the Kibana data view or saved search.`, @@ -70,7 +70,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return; } - setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings)); + setSearchItems(createSearchItems(fetchedDataView, fetchedSavedSearch, uiSettings)); setError(undefined); } @@ -84,8 +84,8 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => { return { error, - getIndexPatternIdByTitle, - loadIndexPatterns, + getDataViewIdByTitle, + loadDataViews, searchItems, setSavedObjectId, }; diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index c84f7cb97c959..dceb585c5c190 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -36,7 +36,7 @@ import { overrideTransformForCloning } from '../../common/transform'; type Props = RouteComponentProps<{ transformId: string }>; export const CloneTransformSection: FC = ({ match, location }) => { - const { indexPatternId }: Record = parse(location.search, { + const { dataViewId }: Record = parse(location.search, { sort: false, }); // Set breadcrumb and page title @@ -73,7 +73,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { } try { - if (indexPatternId === undefined) { + if (dataViewId === undefined) { throw new Error( i18n.translate('xpack.transform.clone.fetchErrorPromptText', { defaultMessage: 'Could not fetch the Kibana data view ID.', @@ -81,7 +81,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { ); } - setSavedObjectId(indexPatternId); + setSavedObjectId(dataViewId); setTransformConfig(overrideTransformForCloning(transformConfigs.transforms[0])); setErrorMessage(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx index 5006b898f3bb3..b20909ec9e128 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/source_search_bar/source_search_bar.tsx @@ -18,10 +18,10 @@ import { SearchItems } from '../../../../hooks/use_search_items'; import { StepDefineFormHook, QUERY_LANGUAGE_KUERY } from '../step_define'; interface SourceSearchBarProps { - indexPattern: SearchItems['indexPattern']; + dataView: SearchItems['dataView']; searchBar: StepDefineFormHook['searchBar']; } -export const SourceSearchBar: FC = ({ indexPattern, searchBar }) => { +export const SourceSearchBar: FC = ({ dataView, searchBar }) => { const { actions: { searchChangeHandler, searchSubmitHandler, setErrorMessage }, state: { errorMessage, searchInput }, @@ -35,7 +35,7 @@ export const SourceSearchBar: FC = ({ indexPattern, search ', () => { test('Minimal initialization', () => { // Arrange const props: StepCreateFormProps = { - createIndexPattern: false, + createDataView: false, transformId: 'the-transform-id', transformConfig: { dest: { @@ -31,7 +31,7 @@ describe('Transform: ', () => { index: 'the-source-index', }, }, - overrides: { created: false, started: false, indexPatternId: undefined }, + overrides: { created: false, started: false, dataViewId: undefined }, onChange() {}, }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 42b50e6ef4c1f..bac7754842510 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -56,19 +56,19 @@ import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting export interface StepDetailsExposedState { created: boolean; started: boolean; - indexPatternId: string | undefined; + dataViewId: string | undefined; } export function getDefaultStepCreateState(): StepDetailsExposedState { return { created: false, started: false, - indexPatternId: undefined, + dataViewId: undefined, }; } export interface StepCreateFormProps { - createIndexPattern: boolean; + createDataView: boolean; transformId: string; transformConfig: PutTransformsPivotRequestSchema | PutTransformsLatestRequestSchema; overrides: StepDetailsExposedState; @@ -77,7 +77,7 @@ export interface StepCreateFormProps { } export const StepCreateForm: FC = React.memo( - ({ createIndexPattern, transformConfig, transformId, onChange, overrides, timeFieldName }) => { + ({ createDataView, transformConfig, transformId, onChange, overrides, timeFieldName }) => { const defaults = { ...getDefaultStepCreateState(), ...overrides }; const [redirectToTransformManagement, setRedirectToTransformManagement] = useState(false); @@ -86,7 +86,7 @@ export const StepCreateForm: FC = React.memo( const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); + const [dataViewId, setDataViewId] = useState(defaults.dataViewId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined ); @@ -94,14 +94,14 @@ export const StepCreateForm: FC = React.memo( const deps = useAppDependencies(); const { share } = deps; - const indexPatterns = deps.data.indexPatterns; + const dataViews = deps.data.dataViews; const toastNotifications = useToastNotifications(); const isDiscoverAvailable = deps.application.capabilities.discover?.show ?? false; useEffect(() => { let unmounted = false; - onChange({ created, started, indexPatternId }); + onChange({ created, started, dataViewId }); const getDiscoverUrl = async (): Promise => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); @@ -109,7 +109,7 @@ export const StepCreateForm: FC = React.memo( if (!locator) return; const discoverUrl = await locator.getUrl({ - indexPatternId, + indexPatternId: dataViewId, }); if (!unmounted) { @@ -117,7 +117,7 @@ export const StepCreateForm: FC = React.memo( } }; - if (started === true && indexPatternId !== undefined && isDiscoverAvailable) { + if (started === true && dataViewId !== undefined && isDiscoverAvailable) { getDiscoverUrl(); } @@ -126,7 +126,7 @@ export const StepCreateForm: FC = React.memo( }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps - }, [created, started, indexPatternId]); + }, [created, started, dataViewId]); const { overlays, theme } = useAppDependencies(); const api = useApi(); @@ -174,8 +174,8 @@ export const StepCreateForm: FC = React.memo( setCreated(true); setLoading(false); - if (createIndexPattern) { - createKibanaIndexPattern(); + if (createDataView) { + createKibanaDataView(); } return true; @@ -228,7 +228,7 @@ export const StepCreateForm: FC = React.memo( } } - const createKibanaIndexPattern = async () => { + const createKibanaDataView = async () => { setLoading(true); const dataViewName = transformConfig.dest.index; const runtimeMappings = transformConfig.source.runtime_mappings as Record< @@ -237,7 +237,7 @@ export const StepCreateForm: FC = React.memo( >; try { - const newIndexPattern = await indexPatterns.createAndSave( + const newDataView = await dataViews.createAndSave( { title: dataViewName, timeFieldName, @@ -256,7 +256,7 @@ export const StepCreateForm: FC = React.memo( }) ); - setIndexPatternId(newIndexPattern.id); + setDataViewId(newDataView.id); setLoading(false); return true; } catch (e) { @@ -529,7 +529,7 @@ export const StepCreateForm: FC = React.memo( data-test-subj="transformWizardCardManagement" /> - {started === true && createIndexPattern === true && indexPatternId === undefined && ( + {started === true && createDataView === true && dataViewId === undefined && ( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts index 497f37036725c..e0c8b30a93998 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/apply_transform_config_to_define_state.ts @@ -35,11 +35,11 @@ import { getCombinedRuntimeMappings } from '../../../../../common/request'; export function applyTransformConfigToDefineState( state: StepDefineExposedState, transformConfig?: TransformBaseConfig, - indexPattern?: StepDefineFormProps['searchItems']['indexPattern'] + dataView?: StepDefineFormProps['searchItems']['dataView'] ): StepDefineExposedState { // apply runtime fields from both the index pattern and inline configurations state.runtimeMappings = getCombinedRuntimeMappings( - indexPattern, + dataView, transformConfig?.source?.runtime_mappings ); @@ -88,12 +88,12 @@ export function applyTransformConfigToDefineState( state.latestConfig = { unique_key: transformConfig.latest.unique_key.map((v) => ({ value: v, - label: indexPattern ? indexPattern.fields.find((f) => f.name === v)?.displayName ?? v : v, + label: dataView ? dataView.fields.find((f) => f.name === v)?.displayName ?? v : v, })), sort: { value: transformConfig.latest.sort, - label: indexPattern - ? indexPattern.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? + label: dataView + ? dataView.fields.find((f) => f.name === transformConfig.latest.sort)?.displayName ?? transformConfig.latest.sort : transformConfig.latest.sort, }, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts index 9b8dcc1a623e3..61081e7858b27 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/common.test.ts @@ -6,18 +6,18 @@ */ import { getPivotDropdownOptions } from '../common'; -import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { FilterAggForm } from './filter_agg/components'; import type { RuntimeField } from '../../../../../../../../../../src/plugins/data/common'; describe('Transform: Define Pivot Common', () => { test('getPivotDropdownOptions()', () => { - // The field name includes the characters []> as well as a leading and ending space charcter + // The field name includes the characters []> as well as a leading and ending space character // which cannot be used for aggregation names. The test results verifies that the characters // should still be present in field and dropDownName values, but should be stripped for aggName values. - const indexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', + const dataView = { + id: 'the-data-view-id', + title: 'the-data-view-title', fields: [ { name: ' the-f[i]e>ld ', @@ -27,9 +27,9 @@ describe('Transform: Define Pivot Common', () => { searchable: true, }, ], - } as IndexPattern; + } as DataView; - const options = getPivotDropdownOptions(indexPattern); + const options = getPivotDropdownOptions(dataView); expect(options).toMatchObject({ aggOptions: [ @@ -120,7 +120,7 @@ describe('Transform: Define Pivot Common', () => { }, } as RuntimeField, }; - const optionsWithRuntimeFields = getPivotDropdownOptions(indexPattern, runtimeMappings); + const optionsWithRuntimeFields = getPivotDropdownOptions(dataView, runtimeMappings); expect(optionsWithRuntimeFields).toMatchObject({ aggOptions: [ { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx index 8c3c649749c2f..745cd81908ac8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.test.tsx @@ -14,7 +14,7 @@ import { KBN_FIELD_TYPES, RuntimeField, } from '../../../../../../../../../../../../src/plugins/data/common'; -import { IndexPattern } from '../../../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../../../src/plugins/data_views/public'; import { FilterTermForm } from './filter_term_form'; describe('FilterAggForm', () => { @@ -27,7 +27,7 @@ describe('FilterAggForm', () => { } as RuntimeField, }; - const indexPattern = { + const dataView = { fields: { getByName: jest.fn((fieldName: string) => { if (fieldName === 'test_text_field') { @@ -42,14 +42,14 @@ describe('FilterAggForm', () => { } }), }, - } as unknown as IndexPattern; + } as unknown as DataView; test('should render only select dropdown on empty configuration', async () => { const onChange = jest.fn(); const { getByLabelText, findByTestId, container } = render( - + @@ -74,7 +74,7 @@ describe('FilterAggForm', () => { const { findByTestId } = render( - + @@ -102,7 +102,7 @@ describe('FilterAggForm', () => { const { rerender, findByTestId } = render( - + @@ -111,7 +111,7 @@ describe('FilterAggForm', () => { // re-render the same component with different props rerender( - + @@ -139,7 +139,7 @@ describe('FilterAggForm', () => { const { findByTestId, container } = render( - + { - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const filterAggsOptions = useMemo( - () => getSupportedFilterAggs(selectedField, indexPattern!, runtimeMappings), - [indexPattern, selectedField, runtimeMappings] + () => getSupportedFilterAggs(selectedField, dataView!, runtimeMappings), + [dataView, selectedField, runtimeMappings] ); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 2d24d07fd7019..11f9dadbb359c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -30,7 +30,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm selectedField, }) => { const api = useApi(); - const { indexPattern, runtimeMappings } = useContext(CreateTransformWizardContext); + const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); @@ -40,7 +40,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm const fetchOptions = useCallback( debounce(async (searchValue: string) => { const esSearchRequest = { - index: indexPattern!.title, + index: dataView!.title, body: { ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), query: { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index b17f30d115f4a..5c4ff5a53f724 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -8,9 +8,9 @@ import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ES_FIELD_TYPES, - IndexPattern, KBN_FIELD_TYPES, } from '../../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../../src/plugins/data_views/public'; import { getNestedProperty } from '../../../../../../../common/utils/object_utils'; import { removeKeywordPostfix } from '../../../../../../../common/utils/field_utils'; @@ -58,7 +58,7 @@ export function getKibanaFieldTypeFromEsType(type: string): KBN_FIELD_TYPES { } export function getPivotDropdownOptions( - indexPattern: IndexPattern, + dataView: DataView, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { // The available group by options @@ -70,7 +70,7 @@ export function getPivotDropdownOptions( const aggOptionsData: PivotAggsConfigWithUiSupportDict = {}; const ignoreFieldNames = ['_id', '_index', '_type']; - const indexPatternFields = indexPattern.fields + const dataViewFields = dataView.fields .filter( (field) => field.aggregatable === true && @@ -93,7 +93,7 @@ export function getPivotDropdownOptions( const sortByLabel = (a: Field, b: Field) => a.name.localeCompare(b.name); - const combinedFields = [...indexPatternFields, ...runtimeFields].sort(sortByLabel); + const combinedFields = [...dataViewFields, ...runtimeFields].sort(sortByLabel); combinedFields.forEach((field) => { const rawFieldName = field.name; const displayFieldName = removeKeywordPostfix(rawFieldName); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts index d6473abb04702..46d5d1b562a84 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts @@ -30,18 +30,18 @@ export const latestConfigMapper = { /** * Provides available options for unique_key and sort fields - * @param indexPattern + * @param dataView * @param aggConfigs * @param runtimeMappings */ function getOptions( - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], aggConfigs: AggConfigs, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { const aggConfig = aggConfigs.aggs[0]; const param = aggConfig.type.params.find((p) => p.type === 'field'); - const filteredIndexPatternFields = param + const filteredDataViewFields = param ? (param as unknown as FieldParamType) .getAvailableFields(aggConfig) // runtimeMappings may already include runtime fields defined by the data view @@ -54,7 +54,7 @@ function getOptions( ? Object.keys(runtimeMappings).map((k) => ({ label: k, value: k })) : []; - const uniqueKeyOptions: Array> = filteredIndexPatternFields + const uniqueKeyOptions: Array> = filteredDataViewFields .filter((v) => !ignoreFieldNames.has(v.name)) .map((v) => ({ label: v.displayName, @@ -70,7 +70,7 @@ function getOptions( })) : []; - const indexPatternFieldsSortOptions: Array> = indexPattern.fields + const dataViewFieldsSortOptions: Array> = dataView.fields // The backend API for `latest` allows all field types for sort but the UI will be limited to `date`. .filter((v) => !ignoreFieldNames.has(v.name) && v.sortable && v.type === 'date') .map((v) => ({ @@ -83,9 +83,7 @@ function getOptions( return { uniqueKeyOptions: [...uniqueKeyOptions, ...runtimeFieldsOptions].sort(sortByLabel), - sortFieldOptions: [...indexPatternFieldsSortOptions, ...runtimeFieldsSortOptions].sort( - sortByLabel - ), + sortFieldOptions: [...dataViewFieldsSortOptions, ...runtimeFieldsSortOptions].sort(sortByLabel), }; } @@ -112,7 +110,7 @@ export function validateLatestConfig(config?: LatestFunctionConfig) { export function useLatestFunctionConfig( defaults: StepDefineExposedState['latestConfig'], - indexPattern: StepDefineFormProps['searchItems']['indexPattern'], + dataView: StepDefineFormProps['searchItems']['dataView'], runtimeMappings: StepDefineExposedState['runtimeMappings'] ): { config: LatestFunctionConfigUI; @@ -130,9 +128,9 @@ export function useLatestFunctionConfig( const { data } = useAppDependencies(); const { uniqueKeyOptions, sortFieldOptions } = useMemo(() => { - const aggConfigs = data.search.aggs.createAggConfigs(indexPattern, [{ type: 'terms' }]); - return getOptions(indexPattern, aggConfigs, runtimeMappings); - }, [indexPattern, data.search.aggs, runtimeMappings]); + const aggConfigs = data.search.aggs.createAggConfigs(dataView, [{ type: 'terms' }]); + return getOptions(dataView, aggConfigs, runtimeMappings); + }, [dataView, data.search.aggs, runtimeMappings]); const updateLatestFunctionConfig = useCallback( (update) => diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts index 2415f04c220a6..c16270a6a2dca 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_pivot_config.ts @@ -100,13 +100,13 @@ function getRootAggregation(item: PivotAggsConfig) { export const usePivotConfig = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { const toastNotifications = useToastNotifications(); const { aggOptions, aggOptionsData, groupByOptions, groupByOptionsData, fields } = useMemo( - () => getPivotDropdownOptions(indexPattern, defaults.runtimeMappings), - [defaults.runtimeMappings, indexPattern] + () => getPivotDropdownOptions(dataView, defaults.runtimeMappings), + [defaults.runtimeMappings, dataView] ); // The list of selected aggregations diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts index be6104d393d3f..b8c818720f0a9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_search_bar.ts @@ -24,7 +24,7 @@ import { StepDefineFormProps } from '../step_define_form'; export const useSearchBar = ( defaults: StepDefineExposedState, - indexPattern: StepDefineFormProps['searchItems']['indexPattern'] + dataView: StepDefineFormProps['searchItems']['dataView'] ) => { // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState({ @@ -53,7 +53,7 @@ export const useSearchBar = ( switch (query.language) { case QUERY_LANGUAGE_KUERY: setSearchQuery( - toElasticsearchQuery(fromKueryExpression(query.query as string), indexPattern) + toElasticsearchQuery(fromKueryExpression(query.query as string), dataView) ); return; case QUERY_LANGUAGE_LUCENE: diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts index b56df5e395c88..f4c396808e294 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_step_define_form.ts @@ -25,21 +25,21 @@ export type StepDefineFormHook = ReturnType; export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefineFormProps) => { const defaults = { ...getDefaultStepDefineState(searchItems), ...overrides }; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const [transformFunction, setTransformFunction] = useState(defaults.transformFunction); - const searchBar = useSearchBar(defaults, indexPattern); - const pivotConfig = usePivotConfig(defaults, indexPattern); + const searchBar = useSearchBar(defaults, dataView); + const pivotConfig = usePivotConfig(defaults, dataView); const latestFunctionConfig = useLatestFunctionConfig( defaults.latestConfig, - indexPattern, + dataView, defaults?.runtimeMappings ); const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, defaults?.runtimeMappings @@ -58,7 +58,7 @@ export const useStepDefineForm = ({ overrides, onChange, searchItems }: StepDefi const runtimeMappings = runtimeMappingsEditor.state.runtimeMappings; if (!advancedSourceEditor.state.isAdvancedSourceEditorEnabled) { const previewRequestUpdate = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, searchBar.state.pivotQuery, pivotConfig.state.requestPayload, runtimeMappings diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx index 6e80b6162048e..054deb23eac50 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.test.tsx @@ -57,10 +57,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; // mock services for QueryStringInput @@ -84,7 +84,7 @@ describe('Transform: ', () => { // Act // Assert expect(getByText('Data view')).toBeInTheDocument(); - expect(getByText(searchItems.indexPattern.title)).toBeInTheDocument(); + expect(getByText(searchItems.dataView.title)).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 8d023e2ae430d..32bc4023f06f1 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -67,7 +67,7 @@ export interface StepDefineFormProps { export const StepDefineForm: FC = React.memo((props) => { const { searchItems } = props; - const { indexPattern } = searchItems; + const { dataView } = searchItems; const { ml: { DataGrid }, } = useAppDependencies(); @@ -88,7 +88,7 @@ export const StepDefineForm: FC = React.memo((props) => { const indexPreviewProps = { ...useIndexData( - indexPattern, + dataView, stepDefineForm.searchBar.state.pivotQuery, stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ), @@ -101,7 +101,7 @@ export const StepDefineForm: FC = React.memo((props) => { : stepDefineForm.latestFunctionConfig; const previewRequest = getPreviewTransformRequestBody( - indexPattern.title, + dataView.title, pivotQuery, stepDefineForm.transformFunction === TRANSFORM_FUNCTION.PIVOT ? stepDefineForm.pivotConfig.state.requestPayload @@ -109,7 +109,7 @@ export const StepDefineForm: FC = React.memo((props) => { stepDefineForm.runtimeMappingsEditor.state.runtimeMappings ); - const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, indexPattern.title); + const copyToClipboardSource = getIndexDevConsoleStatement(pivotQuery, dataView.title); const copyToClipboardSourceDescription = i18n.translate( 'xpack.transform.indexPreview.copyClipboardTooltip', { @@ -127,7 +127,7 @@ export const StepDefineForm: FC = React.memo((props) => { const pivotPreviewProps = { ...usePivotData( - indexPattern.title, + dataView.title, pivotQuery, validationStatus, requestPayload, @@ -211,7 +211,7 @@ export const StepDefineForm: FC = React.memo((props) => { defaultMessage: 'Data view', })} > - {indexPattern.title} + {dataView.title} )} @@ -233,10 +233,7 @@ export const StepDefineForm: FC = React.memo((props) => { {searchItems.savedSearch === undefined && ( <> {!isAdvancedSourceEditorEnabled && ( - + )} {isAdvancedSourceEditorEnabled && } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx index 1e3fa2026061b..1b2d5872e53b6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.test.tsx @@ -33,10 +33,10 @@ describe('Transform: ', () => { const mlSharedImports = await getMlSharedImports(); const searchItems = { - indexPattern: { - title: 'the-index-pattern-title', + dataView: { + title: 'the-data-view-title', fields: [] as any[], - } as SearchItems['indexPattern'], + } as SearchItems['dataView'], }; const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx index 2abb3f4c4cda8..2bae20da65067 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_summary.tsx @@ -56,14 +56,14 @@ export const StepDefineSummary: FC = ({ const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, runtimeMappings ); const pivotPreviewProps = usePivotData( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, validationStatus, partialPreviewRequest, @@ -92,7 +92,7 @@ export const StepDefineSummary: FC = ({ defaultMessage: 'Data view', })} > - {searchItems.indexPattern.title} + {searchItems.dataView.title} {typeof searchString === 'string' && ( ; } @@ -40,7 +40,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { return { continuousModeDateField: '', continuousModeDelay: defaultContinuousModeDelay, - createIndexPattern: true, + createDataView: true, isContinuousModeEnabled: false, isRetentionPolicyEnabled: false, retentionPolicyDateField: '', @@ -53,7 +53,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { destinationIngestPipeline: '', touched: false, valid: false, - indexPatternTimeField: undefined, + dataViewTimeField: undefined, }; } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 75ed5c10f0483..aa08049ac9d64 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -49,7 +49,7 @@ import { getPreviewTransformRequestBody, isTransformIdValid, } from '../../../../common'; -import { EsIndexName, IndexPatternTitle } from './common'; +import { EsIndexName, DataViewTitle } from './common'; import { continuousModeDelayValidator, retentionPolicyMaxAgeValidator, @@ -99,14 +99,12 @@ export const StepDetailsForm: FC = React.memo( ); // Index pattern state - const [indexPatternTitles, setIndexPatternTitles] = useState([]); - const [createIndexPattern, setCreateIndexPattern] = useState( - canCreateDataView === false ? false : defaults.createIndexPattern + const [dataViewTitles, setDataViewTitles] = useState([]); + const [createDataView, setCreateDataView] = useState( + canCreateDataView === false ? false : defaults.createDataView ); - const [indexPatternAvailableTimeFields, setIndexPatternAvailableTimeFields] = useState< - string[] - >([]); - const [indexPatternTimeField, setIndexPatternTimeField] = useState(); + const [dataViewAvailableTimeFields, setDataViewAvailableTimeFields] = useState([]); + const [dataViewTimeField, setDataViewTimeField] = useState(); const onTimeFieldChanged = React.useCallback( (e: React.ChangeEvent) => { @@ -117,11 +115,11 @@ export const StepDetailsForm: FC = React.memo( } // Find the time field based on the selected value // this is to account for undefined when user chooses not to use a date field - const timeField = indexPatternAvailableTimeFields.find((col) => col === value); + const timeField = dataViewAvailableTimeFields.find((col) => col === value); - setIndexPatternTimeField(timeField); + setDataViewTimeField(timeField); }, - [setIndexPatternTimeField, indexPatternAvailableTimeFields] + [setDataViewTimeField, dataViewAvailableTimeFields] ); const { overlays, theme } = useAppDependencies(); @@ -134,7 +132,7 @@ export const StepDetailsForm: FC = React.memo( const { searchQuery, previewRequest: partialPreviewRequest } = stepDefineState; const pivotQuery = getPivotQuery(searchQuery); const previewRequest = getPreviewTransformRequestBody( - searchItems.indexPattern.title, + searchItems.dataView.title, pivotQuery, partialPreviewRequest, stepDefineState.runtimeMappings @@ -148,8 +146,8 @@ export const StepDetailsForm: FC = React.memo( (col) => properties[col].type === 'date' ); - setIndexPatternAvailableTimeFields(timeFields); - setIndexPatternTimeField(timeFields[0]); + setDataViewAvailableTimeFields(timeFields); + setDataViewTimeField(timeFields[0]); } else { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingTransformPreview', { @@ -228,7 +226,7 @@ export const StepDetailsForm: FC = React.memo( } try { - setIndexPatternTitles(await deps.data.indexPatterns.getTitles()); + setDataViewTitles(await deps.data.dataViews.getTitles()); } catch (e) { toastNotifications.addDanger({ title: i18n.translate('xpack.transform.stepDetailsForm.errorGettingDataViewTitles', { @@ -245,7 +243,7 @@ export const StepDetailsForm: FC = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const dateFieldNames = searchItems.indexPattern.fields + const dateFieldNames = searchItems.dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -291,7 +289,7 @@ export const StepDetailsForm: FC = React.memo( const indexNameExists = indexNames.some((name) => destinationIndex === name); const indexNameEmpty = destinationIndex === ''; const indexNameValid = isValidIndexName(destinationIndex); - const indexPatternTitleExists = indexPatternTitles.some((name) => destinationIndex === name); + const dataViewTitleExists = dataViewTitles.some((name) => destinationIndex === name); const [transformFrequency, setTransformFrequency] = useState(defaults.transformFrequency); const isTransformFrequencyValid = transformFrequencyValidator(transformFrequency); @@ -313,7 +311,7 @@ export const StepDetailsForm: FC = React.memo( isTransformSettingsMaxPageSearchSizeValid && !indexNameEmpty && indexNameValid && - (!indexPatternTitleExists || !createIndexPattern) && + (!dataViewTitleExists || !createDataView) && (!isContinuousModeAvailable || (isContinuousModeAvailable && isContinuousModeDelayValid)) && (!isRetentionPolicyAvailable || !isRetentionPolicyEnabled || @@ -327,7 +325,7 @@ export const StepDetailsForm: FC = React.memo( onChange({ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -341,7 +339,7 @@ export const StepDetailsForm: FC = React.memo( destinationIngestPipeline, touched: true, valid, - indexPatternTimeField, + dataViewTimeField, _meta: defaults._meta, }); // custom comparison @@ -349,7 +347,7 @@ export const StepDetailsForm: FC = React.memo( }, [ continuousModeDateField, continuousModeDelay, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -361,7 +359,7 @@ export const StepDetailsForm: FC = React.memo( destinationIndex, destinationIngestPipeline, valid, - indexPatternTimeField, + dataViewTimeField, /* eslint-enable react-hooks/exhaustive-deps */ ]); @@ -530,9 +528,7 @@ export const StepDetailsForm: FC = React.memo( ) : null} = React.memo( , ] : []), - ...(createIndexPattern && indexPatternTitleExists + ...(createDataView && dataViewTitleExists ? [ i18n.translate('xpack.transform.stepDetailsForm.dataViewTitleError', { defaultMessage: 'A data view with this title already exists.', @@ -553,25 +549,23 @@ export const StepDetailsForm: FC = React.memo( ]} > setCreateIndexPattern(!createIndexPattern)} - data-test-subj="transformCreateIndexPatternSwitch" + checked={createDataView === true} + onChange={() => setCreateDataView(!createDataView)} + data-test-subj="transformCreateDataViewSwitch" /> - {createIndexPattern && - !indexPatternTitleExists && - indexPatternAvailableTimeFields.length > 0 && ( - - )} + {createDataView && !dataViewTitleExists && dataViewAvailableTimeFields.length > 0 && ( + + )} {/* Continuous mode */} = React.memo((props) => { const { continuousModeDateField, - createIndexPattern, + createDataView, isContinuousModeEnabled, isRetentionPolicyEnabled, retentionPolicyDateField, @@ -28,14 +28,14 @@ export const StepDetailsSummary: FC = React.memo((props destinationIndex, destinationIngestPipeline, touched, - indexPatternTimeField, + dataViewTimeField, } = props; if (touched === false) { return null; } - const destinationIndexHelpText = createIndexPattern + const destinationIndexHelpText = createDataView ? i18n.translate('xpack.transform.stepDetailsSummary.createDataViewMessage', { defaultMessage: 'A Kibana data view will be created for this transform.', }) @@ -69,13 +69,13 @@ export const StepDetailsSummary: FC = React.memo((props > {destinationIndex} - {createIndexPattern && indexPatternTimeField !== undefined && indexPatternTimeField !== '' && ( + {createDataView && dataViewTimeField !== undefined && dataViewTimeField !== '' && ( - {indexPatternTimeField} + {dataViewTimeField} )} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx index 8d7f6b451f985..d750bf6c7e1fd 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_time_field.tsx @@ -11,14 +11,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; interface Props { - indexPatternAvailableTimeFields: string[]; - indexPatternTimeField: string | undefined; + dataViewAvailableTimeFields: string[]; + dataViewTimeField: string | undefined; onTimeFieldChanged: (e: React.ChangeEvent) => void; } export const StepDetailsTimeField: FC = ({ - indexPatternAvailableTimeFields, - indexPatternTimeField, + dataViewAvailableTimeFields, + dataViewTimeField, onTimeFieldChanged, }) => { const noTimeFieldLabel = i18n.translate( @@ -56,13 +56,13 @@ export const StepDetailsTimeField: FC = ({ > ({ text })), + ...dataViewAvailableTimeFields.map((text) => ({ text })), disabledDividerOption, noTimeFieldOption, ]} - value={indexPatternTimeField} + value={dataViewTimeField} onChange={onTimeFieldChanged} - data-test-subj="transformIndexPatternTimeFieldSelect" + data-test-subj="transformDataViewTimeFieldSelect" /> ); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 27c43ed01a934..c16756d0923e9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -31,7 +31,7 @@ import { StepDetailsSummary, } from '../step_details'; import { WizardNav } from '../wizard_nav'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { DataView } from '../../../../../../../../../src/plugins/data_views/public'; import type { RuntimeMappings } from '../step_define/common/types'; enum WIZARD_STEPS { @@ -86,26 +86,22 @@ interface WizardProps { } export const CreateTransformWizardContext = createContext<{ - indexPattern: IndexPattern | null; + dataView: DataView | null; runtimeMappings: RuntimeMappings | undefined; }>({ - indexPattern: null, + dataView: null, runtimeMappings: undefined, }); export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { - const { indexPattern } = searchItems; + const { dataView } = searchItems; // The current WIZARD_STEP const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE); // The DEFINE state const [stepDefineState, setStepDefineState] = useState( - applyTransformConfigToDefineState( - getDefaultStepDefineState(searchItems), - cloneConfig, - indexPattern - ) + applyTransformConfigToDefineState(getDefaultStepDefineState(searchItems), cloneConfig, dataView) ); // The DETAILS state @@ -117,7 +113,7 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) const [stepCreateState, setStepCreateState] = useState(getDefaultStepCreateState); const transformConfig = getCreateTransformRequestBody( - indexPattern.title, + dataView.title, stepDefineState, stepDetailsState ); @@ -180,12 +176,12 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) {currentStep === WIZARD_STEPS.CREATE ? ( ) : ( @@ -200,19 +196,19 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) }, [ currentStep, setCurrentStep, - stepDetailsState.createIndexPattern, + stepDetailsState.createDataView, stepDetailsState.transformId, transformConfig, setStepCreateState, stepCreateState, - stepDetailsState.indexPatternTimeField, + stepDetailsState.dataViewTimeField, ]); const stepsConfig = [stepDefine, stepDetails, stepCreate]; return ( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx index cf2ec765dc06b..f6c700aef67cc 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx @@ -22,23 +22,23 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => const history = useHistory(); const appDeps = useAppDependencies(); const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const toastNotifications = useToastNotifications(); - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); const { canCreateTransform } = useContext(AuthorizationContext).capabilities; const clickHandler = useCallback( async (item: TransformListRow) => { try { - await loadIndexPatterns(savedObjectsClient, indexPatterns); + await loadDataViews(savedObjectsClient, dataViews); const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const indexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const dataViewId = getDataViewIdByTitle(dataViewTitle); - if (indexPatternId === undefined) { + if (dataViewId === undefined) { toastNotifications.addDanger( i18n.translate('xpack.transform.clone.noDataViewErrorPromptText', { defaultMessage: @@ -47,9 +47,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => }) ); } else { - history.push( - `/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?indexPatternId=${indexPatternId}` - ); + history.push(`/${SECTION_SLUG.CLONE_TRANSFORM}/${item.id}?dataViewId=${dataViewId}`); } } catch (e) { toastNotifications.addError(e, { @@ -62,10 +60,10 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => [ history, savedObjectsClient, - indexPatterns, + dataViews, toastNotifications, - loadIndexPatterns, - getIndexPatternIdByTitle, + loadDataViews, + getDataViewIdByTitle, ] ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx index d5436d51c218b..e369d9e992e30 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_action_modal.tsx @@ -23,12 +23,12 @@ export const DeleteActionModal: FC = ({ closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, items, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }) => { @@ -81,15 +81,15 @@ export const DeleteActionModal: FC = ({ { } @@ -130,11 +130,11 @@ export const DeleteActionModal: FC = ({ /> )} - {userCanDeleteIndex && indexPatternExists && ( + {userCanDeleteIndex && dataViewExists && ( = ({ values: { destinationIndex: items[0] && items[0].config.dest.index }, } )} - checked={deleteIndexPattern} - onChange={toggleDeleteIndexPattern} + checked={deleteDataView} + onChange={toggleDeleteDataView} disabled={userCanDeleteDataView === false} /> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx index b41dfe1c06a8a..357809b54746b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/use_delete_action.tsx @@ -40,18 +40,18 @@ export const useDeleteAction = (forceDisable: boolean) => { userCanDeleteIndex, userCanDeleteDataView, deleteDestIndex, - indexPatternExists, - deleteIndexPattern, + dataViewExists, + deleteDataView, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, } = useDeleteIndexAndTargetIndex(items); const deleteAndCloseModal = () => { setModalVisible(false); const shouldDeleteDestIndex = userCanDeleteIndex && deleteDestIndex; - const shouldDeleteDestIndexPattern = - userCanDeleteIndex && userCanDeleteDataView && indexPatternExists && deleteIndexPattern; + const shouldDeleteDestDataView = + userCanDeleteIndex && userCanDeleteDataView && dataViewExists && deleteDataView; // if we are deleting multiple transforms, then force delete all if at least one item has failed // else, force delete only when the item user picks has failed const forceDelete = isBulkAction @@ -64,7 +64,7 @@ export const useDeleteAction = (forceDisable: boolean) => { state: i.stats.state, })), deleteDestIndex: shouldDeleteDestIndex, - deleteDestIndexPattern: shouldDeleteDestIndexPattern, + deleteDestDataView: shouldDeleteDestDataView, forceDelete, }); }; @@ -103,14 +103,14 @@ export const useDeleteAction = (forceDisable: boolean) => { closeModal, deleteAndCloseModal, deleteDestIndex, - deleteIndexPattern, - indexPatternExists, + deleteDataView, + dataViewExists, isModalVisible, items, openModal, shouldForceDelete, toggleDeleteIndex, - toggleDeleteIndexPattern, + toggleDeleteDataView, userCanDeleteIndex, userCanDeleteDataView, }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx index 9c8945264f000..0f73f6aac40d3 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.test.tsx @@ -52,7 +52,7 @@ describe('Transform: Transform List Actions ', () => { // prepare render( - + ); @@ -72,7 +72,7 @@ describe('Transform: Transform List Actions ', () => { itemCopy.stats.checkpointing.last.checkpoint = 0; render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx index 0a5342b3b0c25..f7cc72c2236b0 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/discover_action_name.tsx @@ -23,7 +23,7 @@ export const discoverActionNameText = i18n.translate( export const isDiscoverActionDisabled = ( items: TransformListRow[], forceDisable: boolean, - indexPatternExists: boolean + dataViewExists: boolean ) => { if (items.length !== 1) { return true; @@ -38,14 +38,14 @@ export const isDiscoverActionDisabled = ( const transformNeverStarted = stoppedTransform === true && transformProgress === undefined && isBatchTransform === true; - return forceDisable === true || indexPatternExists === false || transformNeverStarted === true; + return forceDisable === true || dataViewExists === false || transformNeverStarted === true; }; export interface DiscoverActionNameProps { - indexPatternExists: boolean; + dataViewExists: boolean; items: TransformListRow[]; } -export const DiscoverActionName: FC = ({ indexPatternExists, items }) => { +export const DiscoverActionName: FC = ({ dataViewExists, items }) => { const isBulkAction = items.length > 1; const item = items[0]; @@ -65,7 +65,7 @@ export const DiscoverActionName: FC = ({ indexPatternEx defaultMessage: 'Links to Discover are not supported as a bulk action.', } ); - } else if (!indexPatternExists) { + } else if (!dataViewExists) { disabledTransformMessage = i18n.translate( 'xpack.transform.transformList.discoverTransformNoDataViewToolTip', { diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx index 9b1d7ed066404..71a45b572f833 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -20,7 +20,7 @@ import { DiscoverActionName, } from './discover_action_name'; -const getIndexPatternTitleFromTargetIndex = (item: TransformListRow) => +const getDataViewTitleFromTargetIndex = (item: TransformListRow) => Array.isArray(item.config.dest.index) ? item.config.dest.index.join(',') : item.config.dest.index; export type DiscoverAction = ReturnType; @@ -28,60 +28,59 @@ export const useDiscoverAction = (forceDisable: boolean) => { const appDeps = useAppDependencies(); const { share } = appDeps; const savedObjectsClient = appDeps.savedObjects.client; - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; - const { getIndexPatternIdByTitle, loadIndexPatterns } = useSearchItems(undefined); + const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); - const [indexPatternsLoaded, setIndexPatternsLoaded] = useState(false); + const [dataViewsLoaded, setDataViewsLoaded] = useState(false); useEffect(() => { - async function checkIndexPatternAvailability() { - await loadIndexPatterns(savedObjectsClient, indexPatterns); - setIndexPatternsLoaded(true); + async function checkDataViewAvailability() { + await loadDataViews(savedObjectsClient, dataViews); + setDataViewsLoaded(true); } - checkIndexPatternAvailability(); - }, [indexPatterns, loadIndexPatterns, savedObjectsClient]); + checkDataViewAvailability(); + }, [dataViews, loadDataViews, savedObjectsClient]); const clickHandler = useCallback( (item: TransformListRow) => { const locator = share.url.locators.get(DISCOVER_APP_LOCATOR); if (!locator) return; - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); locator.navigateSync({ - indexPatternId, + indexPatternId: dataViewId, }); }, - [getIndexPatternIdByTitle, share] + [getDataViewIdByTitle, share] ); - const indexPatternExists = useCallback( + const dataViewExists = useCallback( (item: TransformListRow) => { - const indexPatternTitle = getIndexPatternTitleFromTargetIndex(item); - const indexPatternId = getIndexPatternIdByTitle(indexPatternTitle); - return indexPatternId !== undefined; + const dataViewTitle = getDataViewTitleFromTargetIndex(item); + const dataViewId = getDataViewIdByTitle(dataViewTitle); + return dataViewId !== undefined; }, - [getIndexPatternIdByTitle] + [getDataViewIdByTitle] ); const action: TransformListAction = useMemo( () => ({ name: (item: TransformListRow) => { - return ; + return ; }, available: () => isDiscoverAvailable, enabled: (item: TransformListRow) => - indexPatternsLoaded && - !isDiscoverActionDisabled([item], forceDisable, indexPatternExists(item)), + dataViewsLoaded && !isDiscoverActionDisabled([item], forceDisable, dataViewExists(item)), description: discoverActionNameText, icon: 'visTable', type: 'icon', onClick: clickHandler, 'data-test-subj': 'transformActionDiscover', }), - [forceDisable, indexPatternExists, indexPatternsLoaded, isDiscoverAvailable, clickHandler] + [forceDisable, dataViewExists, dataViewsLoaded, isDiscoverAvailable, clickHandler] ); return { action }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx index f789327a051e2..e4927fff97070 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/use_edit_action.tsx @@ -22,14 +22,14 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const [config, setConfig] = useState(); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [indexPatternId, setIndexPatternId] = useState(); + const [dataViewId, setDataViewId] = useState(); const closeFlyout = () => setIsFlyoutVisible(false); - const { getIndexPatternIdByTitle } = useSearchItems(undefined); + const { getDataViewIdByTitle } = useSearchItems(undefined); const toastNotifications = useToastNotifications(); const appDeps = useAppDependencies(); - const indexPatterns = appDeps.data.indexPatterns; + const dataViews = appDeps.data.dataViews; const clickHandler = useCallback( async (item: TransformListRow) => { @@ -37,9 +37,9 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; - const currentIndexPatternId = getIndexPatternIdByTitle(dataViewTitle); + const currentDataViewId = getDataViewIdByTitle(dataViewTitle); - if (currentIndexPatternId === undefined) { + if (currentDataViewId === undefined) { toastNotifications.addWarning( i18n.translate('xpack.transform.edit.noDataViewErrorPromptText', { defaultMessage: @@ -48,7 +48,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => }) ); } - setIndexPatternId(currentIndexPatternId); + setDataViewId(currentDataViewId); setConfig(item.config); setIsFlyoutVisible(true); } catch (e) { @@ -60,7 +60,7 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [indexPatterns, toastNotifications, getIndexPatternIdByTitle] + [dataViews, toastNotifications, getDataViewIdByTitle] ); const action: TransformListAction = useMemo( @@ -81,6 +81,6 @@ export const useEditAction = (forceDisable: boolean, transformNodes: number) => config, closeFlyout, isFlyoutVisible, - indexPatternId, + dataViewId, }; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index b988b61c5b0b7..e6648c5214dac 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -44,13 +44,13 @@ import { isManagedTransform } from '../../../../common/managed_transforms_utils' interface EditTransformFlyoutProps { closeFlyout: () => void; config: TransformConfigUnion; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyout: FC = ({ closeFlyout, config, - indexPatternId, + dataViewId, }) => { const api = useApi(); const toastNotifications = useToastNotifications(); @@ -110,10 +110,7 @@ export const EditTransformFlyout: FC = ({ /> ) : null} }> - + {errorMessage !== undefined && ( <> diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx index 22f31fc6139e8..fd0ca655f3056 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout_form.tsx @@ -29,12 +29,12 @@ import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/com interface EditTransformFlyoutFormProps { editTransformFlyout: UseEditTransformFlyoutReturnType; - indexPatternId?: string; + dataViewId?: string; } export const EditTransformFlyoutForm: FC = ({ editTransformFlyout: [state, dispatch], - indexPatternId, + dataViewId, }) => { const { formFields, formSections } = state; const [dateFieldNames, setDateFieldNames] = useState([]); @@ -43,16 +43,16 @@ export const EditTransformFlyoutForm: FC = ({ const isRetentionPolicyAvailable = dateFieldNames.length > 0; const appDeps = useAppDependencies(); - const indexPatternsClient = appDeps.data.indexPatterns; + const dataViewsClient = appDeps.data.dataViews; const api = useApi(); useEffect( function getDateFields() { let unmounted = false; - if (indexPatternId !== undefined) { - indexPatternsClient.get(indexPatternId).then((indexPattern) => { - if (indexPattern) { - const dateTimeFields = indexPattern.fields + if (dataViewId !== undefined) { + dataViewsClient.get(dataViewId).then((dataView) => { + if (dataView) { + const dateTimeFields = dataView.fields .filter((f) => f.type === KBN_FIELD_TYPES.DATE) .map((f) => f.name) .sort(); @@ -66,7 +66,7 @@ export const EditTransformFlyoutForm: FC = ({ }; } }, - [indexPatternId, indexPatternsClient] + [dataViewId, dataViewsClient] ); useEffect(function fetchPipelinesOnMount() { @@ -153,7 +153,7 @@ export const EditTransformFlyoutForm: FC = ({ { // If data view or date fields info not available // gracefully defaults to text input - indexPatternId ? ( + dataViewId ? ( = ({ transf const pivotQuery = useMemo(() => getPivotQuery(searchQuery), [searchQuery]); - const indexPatternTitle = Array.isArray(transformConfig.source.index) + const dataViewTitle = Array.isArray(transformConfig.source.index) ? transformConfig.source.index.join(',') : transformConfig.source.index; const pivotPreviewProps = usePivotData( - indexPatternTitle, + dataViewTitle, pivotQuery, validationStatus, previewRequest, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 5d480003c7600..986adb89bd41e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -52,7 +52,7 @@ export const useActions = ({ )} {deleteAction.isModalVisible && } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 066a72c807956..a5c536990353a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -192,7 +192,7 @@ export const TransformManagement: FC = () => { state: TRANSFORM_STATE.FAILED, })), deleteDestIndex: false, - deleteDestIndexPattern: false, + deleteDestDataView: false, forceDelete: true, } ); diff --git a/x-pack/plugins/transform/public/app/services/es_index_service.ts b/x-pack/plugins/transform/public/app/services/es_index_service.ts index c8d3f625a9281..88b54a7487f92 100644 --- a/x-pack/plugins/transform/public/app/services/es_index_service.ts +++ b/x-pack/plugins/transform/public/app/services/es_index_service.ts @@ -7,7 +7,7 @@ import { HttpSetup, SavedObjectsClientContract } from 'kibana/public'; import { API_BASE_PATH } from '../../../common/constants'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/public'; export class IndexService { async canDeleteIndex(http: HttpSetup) { @@ -18,8 +18,8 @@ export class IndexService { return privilege.hasAllPrivileges; } - async indexPatternExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { - const response = await savedObjectsClient.find({ + async dataViewExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts index bfe2f47078569..5f464949a4fc8 100644 --- a/x-pack/plugins/transform/server/routes/api/field_histograms.ts +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { - indexPatternTitleSchema, - IndexPatternTitleSchema, -} from '../../../common/api_schemas/common'; +import { dataViewTitleSchema, DataViewTitleSchema } from '../../../common/api_schemas/common'; import { fieldHistogramsRequestSchema, FieldHistogramsRequestSchema, @@ -21,23 +18,23 @@ import { addBasePath } from '../index'; import { wrapError, wrapEsError } from './error_utils'; export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { - router.post( + router.post( { - path: addBasePath('field_histograms/{indexPatternTitle}'), + path: addBasePath('field_histograms/{dataViewTitle}'), validate: { - params: indexPatternTitleSchema, + params: dataViewTitleSchema, body: fieldHistogramsRequestSchema, }, }, - license.guardApiRoute( + license.guardApiRoute( async (ctx, req, res) => { - const { indexPatternTitle } = req.params; + const { dataViewTitle } = req.params; const { query, fields, runtimeMappings, samplerShardSize } = req.body; try { const resp = await getHistogramsForFields( ctx.core.elasticsearch.client, - indexPatternTitle, + dataViewTitle, query, fields, samplerShardSize, diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 2f82b9a70389b..78b51fca58547 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -61,7 +61,7 @@ import { addBasePath } from '../index'; import { isRequestTimeout, fillResultsWithTimeouts, wrapError, wrapEsError } from './error_utils'; import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; import { registerTransformNodesRoutes } from './transforms_nodes'; -import { IIndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data_views/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; @@ -449,11 +449,8 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { registerTransformNodesRoutes(routeDependencies); } -async function getIndexPatternId( - indexName: string, - savedObjectsClient: SavedObjectsClientContract -) { - const response = await savedObjectsClient.find({ +async function getDataViewId(indexName: string, savedObjectsClient: SavedObjectsClientContract) { + const response = await savedObjectsClient.find({ type: 'index-pattern', perPage: 1, search: `"${indexName}"`, @@ -464,11 +461,11 @@ async function getIndexPatternId( return ip?.id; } -async function deleteDestIndexPatternById( - indexPatternId: string, +async function deleteDestDataViewById( + dataViewId: string, savedObjectsClient: SavedObjectsClientContract ) { - return await savedObjectsClient.delete('index-pattern', indexPatternId); + return await savedObjectsClient.delete('index-pattern', dataViewId); } async function deleteTransforms( @@ -480,7 +477,7 @@ async function deleteTransforms( // Cast possible undefineds as booleans const deleteDestIndex = !!reqBody.deleteDestIndex; - const deleteDestIndexPattern = !!reqBody.deleteDestIndexPattern; + const deleteDestDataView = !!reqBody.deleteDestDataView; const shouldForceDelete = !!reqBody.forceDelete; const results: DeleteTransformsResponseSchema = {}; @@ -490,7 +487,7 @@ async function deleteTransforms( const transformDeleted: ResponseStatus = { success: false }; const destIndexDeleted: ResponseStatus = { success: false }; - const destIndexPatternDeleted: ResponseStatus = { + const destDataViewDeleted: ResponseStatus = { success: false, }; const transformId = transformInfo.id; @@ -516,7 +513,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; // No need to perform further delete attempts @@ -538,18 +535,15 @@ async function deleteTransforms( } // Delete the data view if there's a data view that matches the name of dest index - if (destinationIndex && deleteDestIndexPattern) { + if (destinationIndex && deleteDestDataView) { try { - const indexPatternId = await getIndexPatternId( - destinationIndex, - ctx.core.savedObjects.client - ); - if (indexPatternId) { - await deleteDestIndexPatternById(indexPatternId, ctx.core.savedObjects.client); - destIndexPatternDeleted.success = true; + const dataViewId = await getDataViewId(destinationIndex, ctx.core.savedObjects.client); + if (dataViewId) { + await deleteDestDataViewById(dataViewId, ctx.core.savedObjects.client); + destDataViewDeleted.success = true; } - } catch (deleteDestIndexPatternError) { - destIndexPatternDeleted.error = deleteDestIndexPatternError.meta.body.error; + } catch (deleteDestDataViewError) { + destDataViewDeleted.error = deleteDestDataViewError.meta.body.error; } } @@ -569,7 +563,7 @@ async function deleteTransforms( results[transformId] = { transformDeleted, destIndexDeleted, - destIndexPatternDeleted, + destDataViewDeleted, destinationIndex, }; } catch (e) { diff --git a/x-pack/test/api_integration/apis/transform/delete_transforms.ts b/x-pack/test/api_integration/apis/transform/delete_transforms.ts index b823c46509a63..7f14081e5c574 100644 --- a/x-pack/test/api_integration/apis/transform/delete_transforms.ts +++ b/x-pack/test/api_integration/apis/transform/delete_transforms.ts @@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); }); @@ -148,7 +148,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -178,7 +178,7 @@ export default ({ getService }: FtrProviderContext) => { async ({ id: transformId }: { id: string }, idx: number) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndices[idx]); } @@ -219,13 +219,13 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(false); + expect(body[transformId].destDataViewDeleted.success).to.eql(false); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); }); }); - describe('with deleteDestIndexPattern setting', function () { + describe('with deleteDestDataView setting', function () { const transformId = 'test3'; const destinationIndex = generateDestIndex(transformId); @@ -244,7 +244,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: false, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -258,14 +258,14 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(false); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); }); }); - describe('with deleteDestIndex & deleteDestIndexPattern setting', function () { + describe('with deleteDestIndex & deleteDestDataView setting', function () { const transformId = 'test4'; const destinationIndex = generateDestIndex(transformId); @@ -284,7 +284,7 @@ export default ({ getService }: FtrProviderContext) => { const reqBody: DeleteTransformsRequestSchema = { transformsInfo: [{ id: transformId, state: TRANSFORM_STATE.STOPPED }], deleteDestIndex: true, - deleteDestIndexPattern: true, + deleteDestDataView: true, }; const { body, status } = await supertest .post(`/api/transform/delete_transforms`) @@ -298,7 +298,7 @@ export default ({ getService }: FtrProviderContext) => { expect(body[transformId].transformDeleted.success).to.eql(true); expect(body[transformId].destIndexDeleted.success).to.eql(true); - expect(body[transformId].destIndexPatternDeleted.success).to.eql(true); + expect(body[transformId].destDataViewDeleted.success).to.eql(true); await transform.api.waitForTransformNotToExist(transformId); await transform.api.waitForIndicesNotToExist(destinationIndex); await transform.testResources.assertIndexPatternNotExist(destinationIndex); diff --git a/x-pack/test/functional/apps/transform/cloning.ts b/x-pack/test/functional/apps/transform/cloning.ts index 382f1b5ba75ab..3cbb0892bd4ec 100644 --- a/x-pack/test/functional/apps/transform/cloning.ts +++ b/x-pack/test/functional/apps/transform/cloning.ts @@ -333,8 +333,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('should display the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('should display the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 37647b48d3180..dc8190c877d61 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -589,8 +589,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts index 72467b3060ab1..2c7889572ce74 100644 --- a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts +++ b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts @@ -401,8 +401,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/apps/transform/creation_saved_search.ts b/x-pack/test/functional/apps/transform/creation_saved_search.ts index acdc0c64ddda2..b33027da24341 100644 --- a/x-pack/test/functional/apps/transform/creation_saved_search.ts +++ b/x-pack/test/functional/apps/transform/creation_saved_search.ts @@ -232,8 +232,8 @@ export default function ({ getService }: FtrProviderContext) { await transform.wizard.setDestinationIndex(testData.destinationIndex); await transform.testExecution.logTestStep('displays the create data view switch'); - await transform.wizard.assertCreateIndexPatternSwitchExists(); - await transform.wizard.assertCreateIndexPatternSwitchCheckState(true); + await transform.wizard.assertCreateDataViewSwitchExists(); + await transform.wizard.assertCreateDataViewSwitchCheckState(true); await transform.testExecution.logTestStep('displays the continuous mode switch'); await transform.wizard.assertContinuousModeSwitchExists(); diff --git a/x-pack/test/functional/services/transform/wizard.ts b/x-pack/test/functional/services/transform/wizard.ts index 714ae52a6641b..2b95570a9fb1a 100644 --- a/x-pack/test/functional/services/transform/wizard.ts +++ b/x-pack/test/functional/services/transform/wizard.ts @@ -670,13 +670,13 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi await this.assertDestinationIndexValue(destinationIndex); }, - async assertCreateIndexPatternSwitchExists() { - await testSubjects.existOrFail(`transformCreateIndexPatternSwitch`, { allowHidden: true }); + async assertCreateDataViewSwitchExists() { + await testSubjects.existOrFail(`transformCreateDataViewSwitch`, { allowHidden: true }); }, - async assertCreateIndexPatternSwitchCheckState(expectedCheckState: boolean) { + async assertCreateDataViewSwitchCheckState(expectedCheckState: boolean) { const actualCheckState = - (await testSubjects.getAttribute('transformCreateIndexPatternSwitch', 'aria-checked')) === + (await testSubjects.getAttribute('transformCreateDataViewSwitch', 'aria-checked')) === 'true'; expect(actualCheckState).to.eql( expectedCheckState, From f04433e1a3c9cb8508a8911e4f082846da8de36a Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 24 Mar 2022 13:39:41 +0100 Subject: [PATCH 116/132] [Uptime] Enable uptime inspect on dev (#128469) --- x-pack/plugins/uptime/public/apps/plugin.ts | 9 ++++++++- x-pack/plugins/uptime/public/apps/render_app.tsx | 4 +++- x-pack/plugins/uptime/public/apps/uptime_app.tsx | 1 + .../components/common/header/inspector_header_link.tsx | 5 ++++- .../uptime/public/contexts/uptime_settings_context.tsx | 5 +++++ .../server/lib/adapters/framework/adapter_types.ts | 1 + x-pack/plugins/uptime/server/lib/lib.ts | 6 ++++-- x-pack/plugins/uptime/server/plugin.ts | 1 + .../uptime/server/rest_api/uptime_route_wrapper.ts | 5 +++-- 9 files changed, 30 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index bf7c5336a8b0f..278ce45cdf593 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -215,7 +215,14 @@ export class UptimePlugin const [coreStart, corePlugins] = await core.getStartServices(); const { renderApp } = await import('./render_app'); - return renderApp(coreStart, plugins, corePlugins, params, config); + return renderApp( + coreStart, + plugins, + corePlugins, + params, + config, + this.initContext.env.mode.dev + ); }, }); } diff --git a/x-pack/plugins/uptime/public/apps/render_app.tsx b/x-pack/plugins/uptime/public/apps/render_app.tsx index 23f8fc9a8e58c..44e9651c25dd1 100644 --- a/x-pack/plugins/uptime/public/apps/render_app.tsx +++ b/x-pack/plugins/uptime/public/apps/render_app.tsx @@ -25,7 +25,8 @@ export function renderApp( plugins: ClientPluginsSetup, startPlugins: ClientPluginsStart, appMountParameters: AppMountParameters, - config: UptimeUiConfig + config: UptimeUiConfig, + isDev: boolean ) { const { application: { capabilities }, @@ -45,6 +46,7 @@ export function renderApp( plugins.share.url.locators.create(uptimeOverviewNavigatorParams); const props: UptimeAppProps = { + isDev, plugins, canSave, core, diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 5df0d1a00f905..12519143d347a 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -65,6 +65,7 @@ export interface UptimeAppProps { setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; appMountParameters: AppMountParameters; config: UptimeUiConfig; + isDev: boolean; } const Application = (props: UptimeAppProps) => { diff --git a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx index 8f787512aaf9d..c55ef9bdf781e 100644 --- a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx @@ -11,12 +11,15 @@ import React from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { enableInspectEsQueries, useInspectorContext } from '../../../../../observability/public'; import { ClientPluginsStart } from '../../../apps/plugin'; +import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; export function InspectorHeaderLink() { const { services: { inspector, uiSettings }, } = useKibana(); + const { isDev } = useUptimeSettingsContext(); + const { inspectorAdapters } = useInspectorContext(); const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); @@ -25,7 +28,7 @@ export function InspectorHeaderLink() { inspector.open(inspectorAdapters); }; - if (!isInspectorEnabled) { + if (!isInspectorEnabled && !isDev) { return null; } diff --git a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx index 63f21a23e30d3..67058be9a9d65 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_settings_context.tsx @@ -21,6 +21,7 @@ export interface UptimeSettingsContextValues { isLogsAvailable: boolean; config: UptimeUiConfig; commonlyUsedRanges?: CommonlyUsedRange[]; + isDev?: boolean; } const { BASE_PATH } = CONTEXT_DEFAULTS; @@ -39,6 +40,7 @@ const defaultContext: UptimeSettingsContextValues = { isInfraAvailable: true, isLogsAvailable: true, config: {}, + isDev: false, }; export const UptimeSettingsContext = createContext(defaultContext); @@ -50,12 +52,14 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr isLogsAvailable, commonlyUsedRanges, config, + isDev, } = props; const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); const value = useMemo(() => { return { + isDev, basePath, isApmAvailable, isInfraAvailable, @@ -66,6 +70,7 @@ export const UptimeSettingsContextProvider: React.FC = ({ childr dateRangeEnd: dateRangeEnd ?? DATE_RANGE_END, }; }, [ + isDev, basePath, isApmAvailable, isInfraAvailable, diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index d9dadc81397ce..fb5c0cd1e69a1 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -62,6 +62,7 @@ export interface UptimeServerSetup { telemetry: TelemetryEventsSender; uptimeEsClient: UptimeESClient; basePath: IBasePath; + isDev?: boolean; } export interface UptimeCorePluginsSetup { diff --git a/x-pack/plugins/uptime/server/lib/lib.ts b/x-pack/plugins/uptime/server/lib/lib.ts index f61497816e2d9..220ac5a3797a4 100644 --- a/x-pack/plugins/uptime/server/lib/lib.ts +++ b/x-pack/plugins/uptime/server/lib/lib.ts @@ -49,7 +49,9 @@ export function createUptimeESClient({ esClient, request, savedObjectsClient, + isInspectorEnabled, }: { + isInspectorEnabled?: boolean; esClient: ElasticsearchClient; request?: KibanaRequest; savedObjectsClient: SavedObjectsClientContract; @@ -94,7 +96,7 @@ export function createUptimeESClient({ startTime: startTimeNow, }) ); - if (request) { + if (request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'search', params: esParams }); } } @@ -123,7 +125,7 @@ export function createUptimeESClient({ } const inspectableEsQueries = inspectableEsQueriesMap.get(request!); - if (inspectableEsQueries && request) { + if (inspectableEsQueries && request && isInspectorEnabled) { debugESCall({ startTime, request, esError, operationName: 'count', params: esParams }); } diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index 2f329aa83a5c4..61272651e1ce2 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -81,6 +81,7 @@ export class Plugin implements PluginType { basePath: core.http.basePath, logger: this.logger, telemetry: this.telemetryEventsSender, + isDev: this.initContext.env.mode.dev, } as UptimeServerSetup; if (this.isServiceEnabled && this.server.config.service) { diff --git a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts index cf03e7d58fd14..60ba60087382a 100644 --- a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts +++ b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts @@ -41,12 +41,13 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => const uptimeEsClient = createUptimeESClient({ request, savedObjectsClient, + isInspectorEnabled, esClient: esClient.asCurrentUser, }); server.uptimeEsClient = uptimeEsClient; - if (isInspectorEnabled) { + if (isInspectorEnabled || server.isDev) { inspectableEsQueriesMap.set(request, []); } @@ -66,7 +67,7 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute, server) => return response.ok({ body: { ...res, - ...(isInspectorEnabled && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS + ...((isInspectorEnabled || server.isDev) && uptimeRoute.path !== API_URLS.DYNAMIC_SETTINGS ? { _inspect: inspectableEsQueriesMap.get(request) } : {}), }, From 4e4a26a4cf550fee9c1820d4b90301390bb6d1ee Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 24 Mar 2022 13:40:33 +0100 Subject: [PATCH 117/132] [Lens] Make sure x axis values are always strings (#128160) * make sure x axis values are always strings * Update expression.tsx Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/lens/public/xy_visualization/expression.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 8b62b8d0c120c..105b9d24bb09b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -294,8 +294,8 @@ export function XYChart({ // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => xAxisColumn && layersAlreadyFormatted[xAxisColumn.id] - ? (value as string) - : xAxisFormatter.convert(value); + ? String(value) + : String(xAxisFormatter.convert(value)); const chartHasMoreThanOneSeries = filteredLayers.length > 1 || From 99a044aba89b5a3bebc97380c724d297ffb239d3 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 24 Mar 2022 08:51:22 -0400 Subject: [PATCH 118/132] [Security Solution][Endpoint] Add generic Console component in support of Response Actions (#127885) * Generic Console component * "hidden" dev console instance on endpoint list (behind URL param) --- .../console/components/bad_argument.tsx | 39 +++ .../components/command_execution_failure.tsx | 17 ++ .../components/command_execution_output.tsx | 107 +++++++ .../command_input/command_input.test.tsx | 43 +++ .../command_input/command_input.tsx | 143 ++++++++++ .../console/components/command_input/index.ts | 9 + .../components/command_input/key_capture.tsx | 156 ++++++++++ .../console/components/command_list.tsx | 54 ++++ .../console/components/command_usage.tsx | 114 ++++++++ .../console_magenement_provider/index.ts | 8 + .../console_state/console_state.tsx | 47 +++ .../console/components/console_state/index.ts | 8 + .../components/console_state/state_reducer.ts | 42 +++ .../handle_execute_command.test.tsx | 192 +++++++++++++ .../handle_execute_command.tsx | 269 ++++++++++++++++++ .../console/components/console_state/types.ts | 39 +++ .../console/components/help_output.tsx | 59 ++++ .../console/components/history_item.tsx | 31 ++ .../console/components/history_output.tsx | 42 +++ .../console/components/unknow_comand.tsx | 46 +++ .../console/components/user_command_input.tsx | 22 ++ .../components/console/console.test.tsx | 41 +++ .../management/components/console/console.tsx | 100 +++++++ .../use_builtin_command_service.ts | 13 + .../state_selectors/use_command_history.ts | 12 + .../state_selectors/use_command_service.ts | 13 + .../use_console_state_dispatch.ts | 13 + .../state_selectors/use_data_test_subj.ts | 12 + .../management/components/console/index.ts | 10 + .../management/components/console/mocks.tsx | 176 ++++++++++++ .../service/builtin_command_service.tsx | 102 +++++++ .../console/service/parsed_command_input.ts | 95 +++++++ .../service/types.builtin_command_service.ts | 27 ++ .../service/usage_from_command_definition.ts | 33 +++ .../management/components/console/types.ts | 64 +++++ .../endpoint_console/endpoint_console.tsx | 25 ++ .../endpoint_console_command_service.tsx | 22 ++ .../components/endpoint_console/index.ts | 8 + .../pages/endpoint_hosts/view/dev_console.tsx | 95 +++++++ .../pages/endpoint_hosts/view/index.tsx | 4 + 40 files changed, 2352 insertions(+) create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/console.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/console.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/mocks.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx new file mode 100644 index 0000000000000..8ff4b71668fd4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/bad_argument.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, PropsWithChildren } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { ParsedCommandInput } from '../service/parsed_command_input'; +import { CommandDefinition } from '../types'; +import { CommandInputUsage } from './command_usage'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export type BadArgumentProps = PropsWithChildren<{ + parsedInput: ParsedCommandInput; + commandDefinition: CommandDefinition; +}>; + +export const BadArgument = memo( + ({ parsedInput, commandDefinition, children = null }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> + + + + + {children} + + + + ); + } +); +BadArgument.displayName = 'BadArgument'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx new file mode 100644 index 0000000000000..2205bb38d0aea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_failure.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +export interface CommandExecutionFailureProps { + error: Error; +} +export const CommandExecutionFailure = memo(({ error }) => { + return {error}; +}); +CommandExecutionFailure.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx new file mode 100644 index 0000000000000..8bb9769980914 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useCallback, useEffect, useState } from 'react'; +import { EuiButton, EuiLoadingChart } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { UserCommandInput } from './user_command_input'; +import { Command } from '../types'; +import { useCommandService } from '../hooks/state_selectors/use_command_service'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; + +const CommandOutputContainer = styled.div` + position: relative; + + .run-in-background { + position: absolute; + right: 0; + top: 1em; + } +`; + +export interface CommandExecutionOutputProps { + command: Command; +} +export const CommandExecutionOutput = memo(({ command }) => { + const commandService = useCommandService(); + const [isRunning, setIsRunning] = useState(true); + const [output, setOutput] = useState(null); + const dispatch = useConsoleStateDispatch(); + + // FIXME:PT implement the `run in the background` functionality + const [showRunInBackground, setShowRunInTheBackground] = useState(false); + const handleRunInBackgroundClick = useCallback(() => { + setShowRunInTheBackground(false); + }, []); + + useEffect(() => { + (async () => { + const timeoutId = setTimeout(() => { + setShowRunInTheBackground(true); + }, 15000); + + try { + const commandOutput = await commandService.executeCommand(command); + setOutput(commandOutput.result); + + // FIXME: PT the console should scroll the bottom as well + } catch (error) { + setOutput(); + } + + clearTimeout(timeoutId); + setIsRunning(false); + setShowRunInTheBackground(false); + })(); + }, [command, commandService]); + + useEffect(() => { + if (!isRunning) { + dispatch({ type: 'scrollDown' }); + } + }, [isRunning, dispatch]); + + return ( + + {showRunInBackground && ( +
+ + + +
+ )} +
+ + {isRunning && ( + <> + + + )} +
+
{output}
+
+ ); +}); +CommandExecutionOutput.displayName = 'CommandExecutionOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx new file mode 100644 index 0000000000000..e61318227cb1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { ConsoleProps } from '../../console'; +import { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { ConsoleTestSetup, getConsoleTestSetup } from '../../mocks'; + +describe('When entering data into the Console input', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display what the user is typing', () => { + render(); + + enterCommand('c', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + + enterCommand('m', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + }); + + it('should delete last character when BACKSPACE is pressed', () => { + render(); + + enterCommand('cm', { inputOnly: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('cm'); + + enterCommand('{backspace}', { inputOnly: true, useKeyboard: true }); + expect(renderResult.getByTestId('test-cmdInput-userTextInput').textContent).toEqual('c'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx new file mode 100644 index 0000000000000..f9b12391e6f6b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, MouseEventHandler, useCallback, useRef, useState } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import classNames from 'classnames'; +import { KeyCapture, KeyCaptureProps } from './key_capture'; +import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const CommandInputContainer = styled.div` + .prompt { + padding-right: 1ch; + } + + .textEntered { + white-space: break-spaces; + } + + .cursor { + display: inline-block; + width: 0.5em; + height: 1em; + background-color: ${({ theme }) => theme.eui.euiTextColors.default}; + + animation: cursor-blink-animation 1s steps(5, start) infinite; + -webkit-animation: cursor-blink-animation 1s steps(5, start) infinite; + @keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + @-webkit-keyframes cursor-blink-animation { + to { + visibility: hidden; + } + } + + &.inactive { + background-color: transparent !important; + } + } +`; + +export interface CommandInputProps extends CommonProps { + prompt?: string; + isWaiting?: boolean; + focusRef?: KeyCaptureProps['focusRef']; +} + +export const CommandInput = memo( + ({ prompt = '>', focusRef, ...commonProps }) => { + const dispatch = useConsoleStateDispatch(); + const [textEntered, setTextEntered] = useState(''); + const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false); + const _focusRef: KeyCaptureProps['focusRef'] = useRef(null); + const textDisplayRef = useRef(null); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const keyCaptureFocusRef = focusRef || _focusRef; + + const handleKeyCaptureOnStateChange = useCallback< + NonNullable + >((isCapturing) => { + setIsKeyInputBeingCaptured(isCapturing); + }, []); + + const handleTypingAreaClick = useCallback( + (ev) => { + if (keyCaptureFocusRef.current) { + keyCaptureFocusRef.current(); + } + }, + [keyCaptureFocusRef] + ); + + const handleKeyCapture = useCallback( + ({ value, eventDetails }) => { + setTextEntered((prevState) => { + let updatedState = prevState + value; + + switch (eventDetails.keyCode) { + // BACKSPACE + // remove the last character from the text entered + case 8: + if (updatedState.length) { + updatedState = updatedState.replace(/.$/, ''); + } + break; + + // ENTER + // Execute command and blank out the input area + case 13: + dispatch({ type: 'executeCommand', payload: { input: updatedState } }); + return ''; + } + + return updatedState; + }); + }, + [dispatch] + ); + + return ( + + + + {prompt} + + + {textEntered} + + + + + + + + ); + } +); +CommandInput.displayName = 'CommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts new file mode 100644 index 0000000000000..4db81ade86011 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CommandInput } from './command_input'; +export type { CommandInputProps } from './command_input'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx new file mode 100644 index 0000000000000..03bb133f88d79 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/key_capture.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { + FormEventHandler, + KeyboardEventHandler, + memo, + MutableRefObject, + useCallback, + useRef, + useState, +} from 'react'; +import { pick } from 'lodash'; +import styled from 'styled-components'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; + +const NOOP = () => undefined; + +const KeyCaptureContainer = styled.span` + display: inline-block; + position: relative; + width: 1px; + height: 1em; + overflow: hidden; + + .invisible-input { + &, + &:focus { + border: none; + background-image: none; + background-color: transparent; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + animation: none !important; + width: 1ch !important; + position: absolute; + left: -100px; + top: -100px; + } + } +`; + +export interface KeyCaptureProps { + onCapture: (params: { + value: string | undefined; + eventDetails: Pick< + KeyboardEvent, + 'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey' + >; + }) => void; + onStateChange?: (isCapturing: boolean) => void; + focusRef?: MutableRefObject<((force?: boolean) => void) | null>; +} + +/** + * Key Capture is an invisible INPUT field that we set focus to when the user clicks inside of + * the console. It's sole purpose is to capture what the user types, which is then pass along to be + * displayed in a more UX friendly way + */ +export const KeyCapture = memo(({ onCapture, focusRef, onStateChange }) => { + // We don't need the actual value that was last input in this component, because + // `setLastInput()` is used with a function that returns the typed character. + // This state is used like this: + // 1. user presses a keyboard key + // 2. `input` event is triggered - we store the letter typed + // 3. the next event to be triggered (after `input`) that we listen for is `keyup`, + // and when that is triggered, we take the input letter (already stored) and + // call `onCapture()` with it and then set the lastInput state back to an empty string + const [, setLastInput] = useState(''); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const handleBlurAndFocus = useCallback( + (ev) => { + if (!onStateChange) { + return; + } + + onStateChange(ev.type === 'focus'); + }, + [onStateChange] + ); + + const handleOnKeyUp = useCallback>( + (ev) => { + ev.stopPropagation(); + + const eventDetails = pick(ev, [ + 'key', + 'altKey', + 'ctrlKey', + 'keyCode', + 'metaKey', + 'repeat', + 'shiftKey', + ]); + + setLastInput((value) => { + onCapture({ + value, + eventDetails, + }); + + return ''; + }); + }, + [onCapture] + ); + + const handleOnInput = useCallback>((ev) => { + const newValue = ev.currentTarget.value; + + setLastInput((prevState) => { + return `${prevState || ''}${newValue}`; + }); + }, []); + + const inputRef = useRef(null); + + const setFocus = useCallback((force: boolean = false) => { + // If user selected text and `force` is not true, then don't focus (else they lose selection) + if (!force && (window.getSelection()?.toString() ?? '').length > 0) { + return; + } + + inputRef.current?.focus(); + }, []); + + if (focusRef) { + focusRef.current = setFocus; + } + + // FIXME:PT probably need to add `aria-` type properties to the input? + return ( + + + + ); +}); +KeyCapture.displayName = 'KeyCapture'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx new file mode 100644 index 0000000000000..d7464e2f97391 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface CommandListProps { + commands: CommandDefinition[]; +} + +export const CommandList = memo(({ commands }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + const footerMessage = useMemo(() => { + return ( + {'some-command --help'}, + }} + /> + ); + }, []); + + return ( + <> + + {commands.map(({ name, about }) => { + return ( + + + + ); + })} + + {footerMessage} + + ); +}); +CommandList.displayName = 'CommandList'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx new file mode 100644 index 0000000000000..9d17d83f0266f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_usage.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { usageFromCommandDefinition } from '../service/usage_from_command_definition'; +import { CommandDefinition } from '../types'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export const CommandInputUsage = memo>(({ commandDef }) => { + const usageHelp = useMemo(() => { + return usageFromCommandDefinition(commandDef); + }, [commandDef]); + + return ( + + + + + + + + + + {usageHelp} + + + + + ); +}); +CommandInputUsage.displayName = 'CommandInputUsage'; + +export interface CommandUsageProps { + commandDef: CommandDefinition; +} + +export const CommandUsage = memo(({ commandDef }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + const hasArgs = useMemo(() => Object.keys(commandDef.args ?? []).length > 0, [commandDef.args]); + const commandOptions = useMemo(() => { + // `command.args` only here to silence TS check + if (!hasArgs || !commandDef.args) { + return []; + } + + return Object.entries(commandDef.args).map(([option, { about: description }]) => ({ + title: `--${option}`, + description, + })); + }, [commandDef.args, hasArgs]); + const additionalProps = useMemo( + () => ({ + className: 'euiTruncateText', + }), + [] + ); + + return ( + + {commandDef.about} + + {hasArgs && ( + <> + +

+ + + {commandDef.mustHaveArgs && commandDef.args && hasArgs && ( + + + + )} + +

+ {commandDef.args && ( + + )} + + )} +
+ ); +}); +CommandUsage.displayName = 'CommandUsage'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts new file mode 100644 index 0000000000000..8d7de159bbc5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_magenement_provider/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +// FIXME:PT implement a React context to manage consoles diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx new file mode 100644 index 0000000000000..852b2b1ab58fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/console_state.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useReducer, memo, createContext, PropsWithChildren, useContext } from 'react'; +import { InitialStateInterface, initiateState, stateDataReducer } from './state_reducer'; +import { ConsoleStore } from './types'; + +const ConsoleStateContext = createContext(null); + +type ConsoleStateProviderProps = PropsWithChildren<{}> & InitialStateInterface; + +/** + * A Console wide data store for internal state management between inner components + */ +export const ConsoleStateProvider = memo( + ({ commandService, scrollToBottom, dataTestSubj, children }) => { + const [state, dispatch] = useReducer( + stateDataReducer, + { commandService, scrollToBottom, dataTestSubj }, + initiateState + ); + + // FIXME:PT should handle cases where props that are in the store change + // Probably need to have a `useAffect()` that just does a `dispatch()` to update those. + + return ( + + {children} + + ); + } +); +ConsoleStateProvider.displayName = 'ConsoleStateProvider'; + +export const useConsoleStore = (): ConsoleStore => { + const store = useContext(ConsoleStateContext); + + if (!store) { + throw new Error(`ConsoleStateContext not defined`); + } + + return store; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts new file mode 100644 index 0000000000000..dc59ac1c2acef --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ConsoleStateProvider } from './console_state'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts new file mode 100644 index 0000000000000..94175d9821ae7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts @@ -0,0 +1,42 @@ +/* + * 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 { ConsoleDataState, ConsoleStoreReducer } from './types'; +import { handleExecuteCommand } from './state_update_handlers/handle_execute_command'; +import { ConsoleBuiltinCommandsService } from '../../service/builtin_command_service'; + +export type InitialStateInterface = Pick< + ConsoleDataState, + 'commandService' | 'scrollToBottom' | 'dataTestSubj' +>; + +export const initiateState = ({ + commandService, + scrollToBottom, + dataTestSubj, +}: InitialStateInterface): ConsoleDataState => { + return { + commandService, + scrollToBottom, + dataTestSubj, + commandHistory: [], + builtinCommandService: new ConsoleBuiltinCommandsService(), + }; +}; + +export const stateDataReducer: ConsoleStoreReducer = (state, action) => { + switch (action.type) { + case 'scrollDown': + state.scrollToBottom(); + return state; + + case 'executeCommand': + return handleExecuteCommand(state, action); + } + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx new file mode 100644 index 0000000000000..b6a8e4db52340 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.test.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ConsoleProps } from '../../../console'; +import { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { getConsoleTestSetup } from '../../../mocks'; +import type { ConsoleTestSetup } from '../../../mocks'; +import { waitFor } from '@testing-library/react'; + +describe('When a Console command is entered by the user', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + let commandServiceMock: ConsoleTestSetup['commandServiceMock']; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + ({ commandServiceMock, enterCommand } = testSetup); + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display all available commands when `help` command is entered', async () => { + render(); + enterCommand('help'); + + expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy(); + + await waitFor(() => { + expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength( + // `+2` to account for builtin commands + commandServiceMock.getCommandList().length + 2 + ); + }); + }); + + it('should display custom help output when Command service has `getHelp()` defined', async () => { + commandServiceMock.getHelp = async () => { + return { + result:
{'help output'}
, + }; + }; + render(); + enterCommand('help'); + + await waitFor(() => { + expect(renderResult.getByTestId('custom-help')).toBeTruthy(); + }); + }); + + it('should clear the command output history when `clear` is entered', async () => { + render(); + enterCommand('help'); + enterCommand('help'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(2); + + enterCommand('clear'); + + expect(renderResult.getByTestId('test-historyOutput').childElementCount).toBe(0); + }); + + it('should show individual command help when `--help` option is used', async () => { + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('test-commandUsage')).toBeTruthy()); + }); + + it('should should custom command `--help` output when Command service defines `getCommandUsage()`', async () => { + commandServiceMock.getCommandUsage = async () => { + return { + result:
{'command help here'}
, + }; + }; + render(); + enterCommand('cmd2 --help'); + + await waitFor(() => expect(renderResult.getByTestId('cmd-help')).toBeTruthy()); + }); + + it('should execute a command entered', async () => { + render(); + enterCommand('cmd1'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should allow multiple of the same options if `allowMultiples` is `true`', async () => { + render(); + enterCommand('cmd3 --foo one --foo two'); + + await waitFor(() => { + expect(renderResult.getByTestId('exec-output')).toBeTruthy(); + }); + }); + + it('should show error if unknown command', async () => { + render(); + enterCommand('foo-foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-unknownCommandError').textContent).toEqual( + 'Unknown commandFor a list of available command, enter: help' + ); + }); + }); + + it('should show error if options are used but command supports none', async () => { + render(); + enterCommand('cmd1 --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'command does not support any argumentsUsage:cmd1' + ); + }); + }); + + it('should show error if unknown option is used', async () => { + render(); + enterCommand('cmd2 --file test --foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'unsupported argument: --fooUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if any required option is not set', async () => { + render(); + enterCommand('cmd2 --ext one'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required argument: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if argument is used more than one', async () => { + render(); + enterCommand('cmd2 --file one --file two'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'argument can only be used once: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it("should show error returned by the option's `validate()` callback", async () => { + render(); + enterCommand('cmd2 --file one --bad foo'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'invalid argument value: --bad. This is a bad valueUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error no options were provided, bug command requires some', async () => { + render(); + enterCommand('cmd2'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'missing required arguments: --fileUsage:cmd2 --file [--ext --bad]' + ); + }); + }); + + it('should show error if all arguments are optional, but at least 1 must be defined', async () => { + render(); + enterCommand('cmd4'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument').textContent).toEqual( + 'at least one argument must be usedUsage:cmd4 [--foo --bar]' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx new file mode 100644 index 0000000000000..2815ec4605917 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx @@ -0,0 +1,269 @@ +/* + * 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. + */ + +/* eslint complexity: ["error", 40]*/ +// FIXME:PT remove the complexity + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ConsoleDataAction, ConsoleDataState, ConsoleStoreReducer } from '../types'; +import { parseCommandInput } from '../../../service/parsed_command_input'; +import { HistoryItem } from '../../history_item'; +import { UnknownCommand } from '../../unknow_comand'; +import { HelpOutput } from '../../help_output'; +import { BadArgument } from '../../bad_argument'; +import { CommandExecutionOutput } from '../../command_execution_output'; +import { CommandDefinition } from '../../../types'; + +const toCliArgumentOption = (argName: string) => `--${argName}`; + +const getRequiredArguments = (argDefinitions: CommandDefinition['args']): string[] => { + if (!argDefinitions) { + return []; + } + + return Object.entries(argDefinitions) + .filter(([_, argDef]) => argDef.required) + .map(([argName]) => argName); +}; + +const updateStateWithNewCommandHistoryItem = ( + state: ConsoleDataState, + newHistoryItem: ConsoleDataState['commandHistory'][number] +): ConsoleDataState => { + return { + ...state, + commandHistory: [...state.commandHistory, newHistoryItem], + }; +}; + +export const handleExecuteCommand: ConsoleStoreReducer< + ConsoleDataAction & { type: 'executeCommand' } +> = (state, action) => { + const parsedInput = parseCommandInput(action.payload.input); + + if (parsedInput.name === '') { + return state; + } + + const { commandService, builtinCommandService } = state; + + // Is it an internal command? + if (builtinCommandService.isBuiltin(parsedInput.name)) { + const commandOutput = builtinCommandService.executeBuiltinCommand(parsedInput, commandService); + + if (commandOutput.clearBuffer) { + return { + ...state, + commandHistory: [], + }; + } + + return updateStateWithNewCommandHistoryItem(state, commandOutput.result); + } + + // ---------------------------------------------------- + // Validate and execute the user defined command + // ---------------------------------------------------- + const commandDefinition = commandService + .getCommandList() + .find((definition) => definition.name === parsedInput.name); + + // Unknown command + if (!commandDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + + ); + } + + const requiredArgs = getRequiredArguments(commandDefinition.args); + + // If args were entered, then validate them + if (parsedInput.hasArgs()) { + // Show command help + if (parsedInput.hasArg('help')) { + return updateStateWithNewCommandHistoryItem( + state, + + + {(commandService.getCommandUsage || builtinCommandService.getCommandUsage)( + commandDefinition + )} + + + ); + } + + // Command supports no arguments + if (!commandDefinition.args || Object.keys(commandDefinition.args).length === 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.noArgumentsSupported', + { + defaultMessage: 'command does not support any arguments', + } + )} + + + ); + } + + // no unknown arguments allowed? + if (parsedInput.unknownArgs && parsedInput.unknownArgs.length) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unknownArgument', { + defaultMessage: 'unknown argument(s): {unknownArgs}', + values: { + unknownArgs: parsedInput.unknownArgs.join(', '), + }, + })} + + + ); + } + + // Missing required Arguments + for (const requiredArg of requiredArgs) { + if (!parsedInput.args[requiredArg]) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.missingRequiredArg', + { + defaultMessage: 'missing required argument: {argName}', + values: { + argName: toCliArgumentOption(requiredArg), + }, + } + )} + + + ); + } + } + + // Validate each argument given to the command + for (const argName of Object.keys(parsedInput.args)) { + const argDefinition = commandDefinition.args[argName]; + const argInput = parsedInput.args[argName]; + + // Unknown argument + if (!argDefinition) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', { + defaultMessage: 'unsupported argument: {argName}', + values: { argName: toCliArgumentOption(argName) }, + })} + + + ); + } + + // does not allow multiple values + if ( + !argDefinition.allowMultiples && + Array.isArray(argInput.values) && + argInput.values.length > 0 + ) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', + { + defaultMessage: 'argument can only be used once: {argName}', + values: { argName: toCliArgumentOption(argName) }, + } + )} + + + ); + } + + if (argDefinition.validate) { + const validationResult = argDefinition.validate(argInput); + + if (validationResult !== true) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate( + 'xpack.securitySolution.console.commandValidation.invalidArgValue', + { + defaultMessage: 'invalid argument value: {argName}. {error}', + values: { argName: toCliArgumentOption(argName), error: validationResult }, + } + )} + + + ); + } + } + } + } else if (requiredArgs.length > 0) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', { + defaultMessage: 'missing required arguments: {requiredArgs}', + values: { + requiredArgs: requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', '), + }, + })} + + + ); + } else if (commandDefinition.mustHaveArgs) { + return updateStateWithNewCommandHistoryItem( + state, + + + {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', { + defaultMessage: 'at least one argument must be used', + })} + + + ); + } + + // All is good. Execute the command + return updateStateWithNewCommandHistoryItem( + state, + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts new file mode 100644 index 0000000000000..72810d31e3248 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts @@ -0,0 +1,39 @@ +/* + * 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 { Dispatch, Reducer } from 'react'; +import { CommandServiceInterface } from '../../types'; +import { HistoryItemComponent } from '../history_item'; +import { BuiltinCommandServiceInterface } from '../../service/types.builtin_command_service'; + +export interface ConsoleDataState { + /** Command service defined on input to the `Console` component by consumers of the component */ + commandService: CommandServiceInterface; + /** Command service for builtin console commands */ + builtinCommandService: BuiltinCommandServiceInterface; + /** UI function that scrolls the console down to the bottom */ + scrollToBottom: () => void; + /** + * List of commands entered by the user and being shown in the UI + */ + commandHistory: Array>; + dataTestSubj?: string; +} + +export type ConsoleDataAction = + | { type: 'scrollDown' } + | { type: 'executeCommand'; payload: { input: string } }; + +export interface ConsoleStore { + state: ConsoleDataState; + dispatch: Dispatch; +} + +export type ConsoleStoreReducer
= Reducer< + ConsoleDataState, + A +>; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx new file mode 100644 index 0000000000000..b0a2217e169c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/help_output.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, ReactNode, useEffect, useState } from 'react'; +import { EuiCallOut, EuiCallOutProps, EuiLoadingChart } from '@elastic/eui'; +import { UserCommandInput } from './user_command_input'; +import { CommandExecutionFailure } from './command_execution_failure'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export interface HelpOutputProps extends Pick { + input: string; + children: ReactNode | Promise<{ result: ReactNode }>; +} +export const HelpOutput = memo(({ input, children, ...euiCalloutProps }) => { + const [content, setContent] = useState(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + useEffect(() => { + if (children instanceof Promise) { + (async () => { + try { + const response = await (children as Promise<{ + result: ReactNode; + }>); + setContent(response.result); + } catch (error) { + setContent(); + } + })(); + + return; + } + + setContent(children); + }, [children]); + + return ( +
+
+ +
+ + {content} + +
+ ); +}); +HelpOutput.displayName = 'HelpOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx new file mode 100644 index 0000000000000..0143d36f0e766 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_item.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, PropsWithChildren } from 'react'; +import { EuiFlexItem } from '@elastic/eui'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type HistoryItemProps = PropsWithChildren<{}>; + +export const HistoryItem = memo(({ children }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + + {children} + + ); +}); + +HistoryItem.displayName = 'HistoryItem'; + +export type HistoryItemComponent = typeof HistoryItem; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx new file mode 100644 index 0000000000000..088a6fac57ae4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/history_output.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useEffect } from 'react'; +import { CommonProps, EuiFlexGroup } from '@elastic/eui'; +import { useCommandHistory } from '../hooks/state_selectors/use_command_history'; +import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; + +export type OutputHistoryProps = CommonProps; + +export const HistoryOutput = memo((commonProps) => { + const historyItems = useCommandHistory(); + const dispatch = useConsoleStateDispatch(); + const getTestId = useTestIdGenerator(useDataTestSubj()); + + // Anytime we add a new item to the history + // scroll down so that command input remains visible + useEffect(() => { + dispatch({ type: 'scrollDown' }); + }, [dispatch, historyItems.length]); + + return ( + + {historyItems} + + ); +}); + +HistoryOutput.displayName = 'HistoryOutput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx new file mode 100644 index 0000000000000..5529457cbb05a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/unknow_comand.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { UserCommandInput } from './user_command_input'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; + +export interface UnknownCommand { + input: string; +} +export const UnknownCommand = memo(({ input }) => { + const getTestId = useTestIdGenerator(useDataTestSubj()); + + return ( + <> +
+ +
+ + + + + + {'help'}, + }} + /> + + + + ); +}); +UnknownCommand.displayName = 'UnknownCommand'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx new file mode 100644 index 0000000000000..84afff3f28209 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/user_command_input.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +export interface UserCommandInputProps { + input: string; +} + +export const UserCommandInput = memo(({ input }) => { + return ( + <> + {'$ '} + {input} + + ); +}); +UserCommandInput.displayName = 'UserCommandInput'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx new file mode 100644 index 0000000000000..9adeaa72d683e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppContextTestRender } from '../../../common/mock/endpoint'; +import { ConsoleProps } from './console'; +import { getConsoleTestSetup } from './mocks'; +import userEvent from '@testing-library/user-event'; + +describe('When using Console component', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should render console', () => { + render(); + + expect(renderResult.getByTestId('test')).toBeTruthy(); + }); + + it('should display prompt given on input', () => { + render({ prompt: 'MY PROMPT>>' }); + + expect(renderResult.getByTestId('test-cmdInput-prompt').textContent).toEqual('MY PROMPT>>'); + }); + + it('should focus on input area when it gains focus', () => { + render(); + userEvent.click(renderResult.getByTestId('test-mainPanel')); + + expect(document.activeElement!.classList.contains('invisible-input')).toBe(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/console.tsx b/x-pack/plugins/security_solution/public/management/components/console/console.tsx new file mode 100644 index 0000000000000..6c64a045c86fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/console.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useRef } from 'react'; +import { CommonProps, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; +import { HistoryOutput } from './components/history_output'; +import { CommandInput, CommandInputProps } from './components/command_input'; +import { CommandServiceInterface } from './types'; +import { ConsoleStateProvider } from './components/console_state'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; + +// FIXME:PT implement dark mode for the console or light mode switch + +const ConsoleWindow = styled.div` + height: 100%; + + // FIXME: IMPORTANT - this should NOT be used in production + // dark mode on light theme / light mode on dark theme + filter: invert(100%); + + .ui-panel { + min-width: ${({ theme }) => theme.eui.euiBreakpoints.s}; + height: 100%; + min-height: 300px; + overflow-y: auto; + } + + .descriptionList-20_80 { + &.euiDescriptionList { + > .euiDescriptionList__title { + width: 20%; + } + + > .euiDescriptionList__description { + width: 80%; + } + } + } +`; + +export interface ConsoleProps extends CommonProps, Pick { + commandService: CommandServiceInterface; +} + +export const Console = memo(({ prompt, commandService, ...commonProps }) => { + const consoleWindowRef = useRef(null); + const inputFocusRef: CommandInputProps['focusRef'] = useRef(null); + const getTestId = useTestIdGenerator(commonProps['data-test-subj']); + + const scrollToBottom = useCallback(() => { + // We need the `setTimeout` here because in some cases, the command output + // will take a bit of time to populate its content due to the use of Promises + setTimeout(() => { + if (consoleWindowRef.current) { + consoleWindowRef.current.scrollTop = consoleWindowRef.current.scrollHeight; + } + }, 1); + + // NOTE: its IMPORTANT that this callback does NOT have any dependencies, because + // it is stored in State and currently not updated if it changes + }, []); + + const handleConsoleClick = useCallback(() => { + if (inputFocusRef.current) { + inputFocusRef.current(); + } + }, []); + + return ( + + + + + + + + + + + + + + + ); +}); + +Console.displayName = 'Console'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts new file mode 100644 index 0000000000000..22167d5066743 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_builtin_command_service.ts @@ -0,0 +1,13 @@ +/* + * 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 { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.builtinCommandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts new file mode 100644 index 0000000000000..ded51471a1c3e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_history.ts @@ -0,0 +1,12 @@ +/* + * 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 { useConsoleStore } from '../../components/console_state/console_state'; + +export const useCommandHistory = () => { + return useConsoleStore().state.commandHistory; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts new file mode 100644 index 0000000000000..66ce0c2b5eb43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_command_service.ts @@ -0,0 +1,13 @@ +/* + * 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 { useConsoleStore } from '../../components/console_state/console_state'; +import { CommandServiceInterface } from '../../types'; + +export const useCommandService = (): CommandServiceInterface => { + return useConsoleStore().state.commandService; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts new file mode 100644 index 0000000000000..90e5fe094f9c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_console_state_dispatch.ts @@ -0,0 +1,13 @@ +/* + * 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 { useConsoleStore } from '../../components/console_state/console_state'; +import { ConsoleStore } from '../../components/console_state/types'; + +export const useConsoleStateDispatch = (): ConsoleStore['dispatch'] => { + return useConsoleStore().dispatch; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts new file mode 100644 index 0000000000000..144a5a63cd71b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts @@ -0,0 +1,12 @@ +/* + * 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 { useConsoleStore } from '../../components/console_state/console_state'; + +export const useDataTestSubj = (): string | undefined => { + return useConsoleStore().state.dataTestSubj; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/index.ts b/x-pack/plugins/security_solution/public/management/components/console/index.ts new file mode 100644 index 0000000000000..81244b3013b36 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/index.ts @@ -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. + */ + +export { Console } from './console'; +export type { ConsoleProps } from './console'; +export type { CommandServiceInterface, CommandDefinition, Command } from './types'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx new file mode 100644 index 0000000000000..693daf83ed6ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -0,0 +1,176 @@ +/* + * 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. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import React from 'react'; +import { EuiCode } from '@elastic/eui'; +import userEvent from '@testing-library/user-event'; +import { act } from '@testing-library/react'; +import { Console } from './console'; +import type { ConsoleProps } from './console'; +import type { Command, CommandServiceInterface } from './types'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { CommandDefinition } from './types'; + +export interface ConsoleTestSetup { + renderConsole(props?: Partial): ReturnType; + + commandServiceMock: jest.Mocked; + + enterCommand( + cmd: string, + options?: Partial<{ + /** If true, the ENTER key will not be pressed */ + inputOnly: boolean; + /** + * if true, then the keyboard keys will be used to send the command. + * Use this if wanting ot press keyboard keys other than letter/punctuation + */ + useKeyboard: boolean; + }> + ): void; +} + +export const getConsoleTestSetup = (): ConsoleTestSetup => { + const mockedContext = createAppRootMockRenderer(); + + let renderResult: ReturnType; + + const commandServiceMock = getCommandServiceMock(); + + const renderConsole: ConsoleTestSetup['renderConsole'] = ({ + prompt = '$$>', + commandService = commandServiceMock, + 'data-test-subj': dataTestSubj = 'test', + ...others + } = {}) => { + if (commandService !== commandServiceMock) { + throw new Error('Must use CommandService provided by test setup'); + } + + return (renderResult = mockedContext.render( + + )); + }; + + const enterCommand: ConsoleTestSetup['enterCommand'] = ( + cmd, + { inputOnly = false, useKeyboard = false } = {} + ) => { + const keyCaptureInput = renderResult.getByTestId('test-keyCapture-input'); + + act(() => { + if (useKeyboard) { + userEvent.click(keyCaptureInput); + userEvent.keyboard(cmd); + } else { + userEvent.type(keyCaptureInput, cmd); + } + + if (!inputOnly) { + userEvent.keyboard('{enter}'); + } + }); + }; + + return { + renderConsole, + commandServiceMock, + enterCommand, + }; +}; + +export const getCommandServiceMock = (): jest.Mocked => { + return { + getCommandList: jest.fn(() => { + const commands: CommandDefinition[] = [ + { + name: 'cmd1', + about: 'a command with no options', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + about: 'Includes file in the run', + required: true, + allowMultiples: false, + validate: () => { + return true; + }, + }, + ext: { + about: 'optional argument', + required: false, + allowMultiples: false, + }, + bad: { + about: 'will fail validation', + required: false, + allowMultiples: false, + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd3', + about: 'allows argument to be used multiple times', + args: { + foo: { + about: 'foo stuff', + required: true, + allowMultiples: true, + }, + }, + }, + { + name: 'cmd4', + about: 'all options optinal, but at least one is required', + mustHaveArgs: true, + args: { + foo: { + about: 'foo stuff', + required: false, + allowMultiples: true, + }, + bar: { + about: 'bar stuff', + required: false, + allowMultiples: true, + }, + }, + }, + ]; + + return commands; + }), + + executeCommand: jest.fn(async (command: Command) => { + await new Promise((r) => setTimeout(r, 1)); + + return { + result: ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ + {JSON.stringify(command.args, null, 2)} + +
+ ), + }; + }), + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx new file mode 100644 index 0000000000000..6cd8af0dc6eff --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/builtin_command_service.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { i18n } from '@kbn/i18n'; +import { HistoryItem, HistoryItemComponent } from '../components/history_item'; +import { HelpOutput } from '../components/help_output'; +import { ParsedCommandInput } from './parsed_command_input'; +import { CommandList } from '../components/command_list'; +import { CommandUsage } from '../components/command_usage'; +import { Command, CommandDefinition, CommandServiceInterface } from '../types'; +import { BuiltinCommandServiceInterface } from './types.builtin_command_service'; + +const builtInCommands = (): CommandDefinition[] => { + return [ + { + name: 'help', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.helpAbout', { + defaultMessage: 'View list of available commands', + }), + }, + { + name: 'clear', + about: i18n.translate('xpack.securitySolution.console.builtInCommands.clearAbout', { + defaultMessage: 'Clear the console buffer', + }), + }, + ]; +}; + +export class ConsoleBuiltinCommandsService implements BuiltinCommandServiceInterface { + constructor(private commandList = builtInCommands()) {} + + getCommandList(): CommandDefinition[] { + return this.commandList; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { + result: null, + }; + } + + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean } { + switch (parsedInput.name) { + case 'help': + return { + result: ( + + + {this.getHelpContent(parsedInput, contextConsoleService)} + + + ), + }; + + case 'clear': + return { + result: null, + clearBuffer: true, + }; + } + + return { result: null }; + } + + async getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }> { + let helpOutput: ReactNode; + + if (commandService.getHelp) { + helpOutput = (await commandService.getHelp()).result; + } else { + helpOutput = ( + + ); + } + + return { + result: helpOutput, + }; + } + + isBuiltin(name: string): boolean { + return !!this.commandList.find((command) => command.name === name); + } + + async getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }> { + return { + result: , + }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts new file mode 100644 index 0000000000000..55e0b3dc6267b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-ignore +// eslint-disable-next-line import/no-extraneous-dependencies +import argsplit from 'argsplit'; + +// FIXME:PT use a 3rd party lib for arguments parsing +// For now, just using what I found in kibana package.json devDependencies, so this will NOT work for production + +// FIXME:PT Type `ParsedCommandInput` should be a generic that allows for the args's keys to be defined + +export interface ParsedArgData { + /** For arguments that were used only once. Will be `undefined` if multiples were used */ + value: undefined | string; + /** For arguments that were used multiple times */ + values: undefined | string[]; +} + +export interface ParsedCommandInput { + input: string; + name: string; + args: { + [argName: string]: ParsedArgData; + }; + unknownArgs: undefined | string[]; + hasArgs(): boolean; + hasArg(argName: string): boolean; +} + +const PARSED_COMMAND_INPUT_PROTOTYPE: Pick = Object.freeze({ + hasArgs(this: ParsedCommandInput) { + return Object.keys(this.args).length > 0 || Array.isArray(this.unknownArgs); + }, + + hasArg(argName: string): boolean { + // @ts-ignore + return Object.prototype.hasOwnProperty.call(this.args, argName); + }, +}); + +export const parseCommandInput = (input: string): ParsedCommandInput => { + const inputTokens: string[] = argsplit(input) || []; + const name: string = inputTokens.shift() || ''; + const args: ParsedCommandInput['args'] = {}; + let unknownArgs: ParsedCommandInput['unknownArgs']; + + // All options start with `--` + let argName = ''; + + for (const inputToken of inputTokens) { + if (inputToken.startsWith('--')) { + argName = inputToken.substr(2); + + if (!args[argName]) { + args[argName] = { + value: undefined, + values: undefined, + }; + } + + // eslint-disable-next-line no-continue + continue; + } else if (!argName) { + (unknownArgs = unknownArgs || []).push(inputToken); + + // eslint-disable-next-line no-continue + continue; + } + + if (Array.isArray(args[argName].values)) { + // @ts-ignore + args[argName].values.push(inputToken); + } else { + // Do we have multiple values for this argumentName, then create array for values + if (args[argName].value !== undefined) { + args[argName].values = [args[argName].value ?? '', inputToken]; + args[argName].value = undefined; + } else { + args[argName].value = inputToken; + } + } + } + + return Object.assign(Object.create(PARSED_COMMAND_INPUT_PROTOTYPE), { + input, + name, + args, + unknownArgs, + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts new file mode 100644 index 0000000000000..dbd5347ea99c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/types.builtin_command_service.ts @@ -0,0 +1,27 @@ +/* + * 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 { ReactNode } from 'react'; +import { CommandDefinition, CommandServiceInterface } from '../types'; +import { ParsedCommandInput } from './parsed_command_input'; +import { HistoryItemComponent } from '../components/history_item'; + +export interface BuiltinCommandServiceInterface extends CommandServiceInterface { + executeBuiltinCommand( + parsedInput: ParsedCommandInput, + contextConsoleService: CommandServiceInterface + ): { result: ReturnType | null; clearBuffer?: boolean }; + + getHelpContent( + parsedInput: ParsedCommandInput, + commandService: CommandServiceInterface + ): Promise<{ result: ReactNode }>; + + isBuiltin(name: string): boolean; + + getCommandUsage(command: CommandDefinition): Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts new file mode 100644 index 0000000000000..edc7d404fd8dd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/usage_from_command_definition.ts @@ -0,0 +1,33 @@ +/* + * 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 { CommandDefinition } from '../types'; + +export const usageFromCommandDefinition = (command: CommandDefinition): string => { + let requiredArgs = ''; + let optionalArgs = ''; + + if (command.args) { + for (const [argName, argDefinition] of Object.entries(command.args)) { + if (argDefinition.required) { + if (requiredArgs.length) { + requiredArgs += ' '; + } + requiredArgs += `--${argName}`; + } else { + if (optionalArgs.length) { + optionalArgs += ' '; + } + optionalArgs += `--${argName}`; + } + } + } + + return `${command.name} ${requiredArgs} ${ + optionalArgs.length > 0 ? `[${optionalArgs}]` : '' + }`.trim(); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts new file mode 100644 index 0000000000000..e2b6d5c2a84aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/types.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReactNode } from 'react'; +import { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input'; + +export interface CommandDefinition { + name: string; + about: string; + validator?: () => Promise; + /** If all args are optional, but at least one must be defined, set to true */ + mustHaveArgs?: boolean; + args?: { + [longName: string]: { + required: boolean; + allowMultiples: boolean; + about: string; + /** + * Validate the individual values given to this argument. + * Should return `true` if valid or a string with the error message + */ + validate?: (argData: ParsedArgData) => true | string; + // Selector: Idea is that the schema can plugin in a rich component for the + // user to select something (ex. a file) + // FIXME: implement selector + selector?: () => unknown; + }; + }; +} + +/** + * A command to be executed (as entered by the user) + */ +export interface Command { + /** The raw input entered by the user */ + input: string; + // FIXME:PT this should be a generic that allows for the arguments type to be used + /** An object with the arguments entered by the user and their value */ + args: ParsedCommandInput; + /** The command defined associated with this user command */ + commandDefinition: CommandDefinition; +} + +export interface CommandServiceInterface { + getCommandList(): CommandDefinition[]; + + executeCommand(command: Command): Promise<{ result: ReactNode }>; + + /** + * If defined, then the `help` builtin command will display this output instead of the default one + * which is generated out of the Command list + */ + getHelp?: () => Promise<{ result: ReactNode }>; + + /** + * If defined, then the output of this function will be used to display individual + * command help (`--help`) + */ + getCommandUsage?: (command: CommandDefinition) => Promise<{ result: ReactNode }>; +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx new file mode 100644 index 0000000000000..28472e123380a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { Console } from '../console'; +import { EndpointConsoleCommandService } from './endpoint_console_command_service'; +import type { HostMetadata } from '../../../../common/endpoint/types'; + +export interface EndpointConsoleProps { + endpoint: HostMetadata; +} + +export const EndpointConsole = memo((props) => { + const consoleService = useMemo(() => { + return new EndpointConsoleCommandService(); + }, []); + + return `} commandService={consoleService} />; +}); + +EndpointConsole.displayName = 'EndpointConsole'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx new file mode 100644 index 0000000000000..5028879bc1a49 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/endpoint_console_command_service.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { ReactNode } from 'react'; +import { CommandServiceInterface, CommandDefinition, Command } from '../console'; + +/** + * Endpoint specific Response Actions (commands) for use with Console. + */ +export class EndpointConsoleCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return []; + } + + async executeCommand(command: Command): Promise<{ result: ReactNode }> { + return { result: <> }; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts new file mode 100644 index 0000000000000..97f7fb61ae607 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EndpointConsole } from './endpoint_console'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx new file mode 100644 index 0000000000000..7fb057809919e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/dev_console.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useMemo } from 'react'; +import { EuiCode } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useUrlParams } from '../../../components/hooks/use_url_params'; +import { + Command, + CommandDefinition, + CommandServiceInterface, + Console, +} from '../../../components/console'; + +const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms)); + +class DevCommandService implements CommandServiceInterface { + getCommandList(): CommandDefinition[] { + return [ + { + name: 'cmd1', + about: 'Runs cmd1', + }, + { + name: 'cmd2', + about: 'runs cmd 2', + args: { + file: { + required: true, + allowMultiples: false, + about: 'Includes file in the run', + validate: () => { + return true; + }, + }, + bad: { + required: false, + allowMultiples: false, + about: 'will fail validation', + validate: () => 'This is a bad value', + }, + }, + }, + { + name: 'cmd-long-delay', + about: 'runs cmd 2', + }, + ]; + } + + async executeCommand(command: Command): Promise<{ result: React.ReactNode }> { + await delay(); + + if (command.commandDefinition.name === 'cmd-long-delay') { + await delay(20000); + } + + return { + result: ( +
+
{`${command.commandDefinition.name}`}
+
{`command input: ${command.input}`}
+ {JSON.stringify(command.args, null, 2)} +
+ ), + }; + } +} + +// ------------------------------------------------------------ +// FOR DEV PURPOSES ONLY +// FIXME:PT Delete once we have support via row actions menu +// ------------------------------------------------------------ +export const DevConsole = memo(() => { + const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled'); + + const consoleService = useMemo(() => { + return new DevCommandService(); + }, []); + + const { + urlParams: { showConsole = false }, + } = useUrlParams(); + + return isConsoleEnabled && showConsole ? ( +
+ +
+ ) : null; +}); +DevConsole.displayName = 'DevConsole'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index da6f3b54323c5..3946edb9a0981 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -68,6 +68,7 @@ import { BackToExternalAppButton, BackToExternalAppButtonProps, } from '../../../components/back_to_external_app_button/back_to_external_app_button'; +import { DevConsole } from './dev_console'; const MAX_PAGINATED_ITEM = 9999; const TRANSFORM_URL = '/data/transform'; @@ -664,6 +665,9 @@ export const EndpointList = () => { } headerBackComponent={routeState.backLink && backToPolicyList} > + {/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */} + + {hasSelectedEndpoint && } <> {areEndpointsEnrolling && !hasErrorFindingTotals && ( From df9c1f4f837eeaac39a1fdb6a3507747a24cbd09 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Thu, 24 Mar 2022 13:56:02 +0100 Subject: [PATCH 119/132] [Workplace Search] Add documentation for external connector (#128414) * [Workplace Search] Add documentation for external connector --- .../external_connector_config.test.tsx | 10 +-- .../external_connector_config.tsx | 25 ++++--- .../external_connector_documentation.test.tsx | 24 +++++++ .../external_connector_documentation.tsx | 68 +++++++++++++++++++ .../external_connector_form_fields.test.tsx | 4 +- .../external_connector_form_fields.tsx | 0 .../external_connector_logic.test.ts | 11 +-- .../external_connector_logic.ts | 12 ++-- .../add_external_connector/index.ts | 11 +++ .../add_source/add_source_logic.test.ts | 2 +- .../components/add_source/add_source_logic.ts | 5 +- .../add_source/configuration_choice.tsx | 24 ++++--- .../add_source/save_config.test.tsx | 2 +- .../components/add_source/save_config.tsx | 9 ++- .../views/content_sources/source_data.tsx | 63 ++++++++--------- .../views/content_sources/sources_router.tsx | 2 +- 16 files changed, 200 insertions(+), 72 deletions(-) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_config.test.tsx (88%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_config.tsx (81%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_form_fields.test.tsx (95%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_form_fields.tsx (100%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_logic.test.ts (97%) rename x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/{ => add_external_connector}/external_connector_logic.ts (94%) create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx similarity index 88% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 6a93291a28cb3..4917877c0ec30 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +18,8 @@ import { EuiSteps } from '@elastic/eui'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, -} from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; +} from '../../../../../components/layout'; +import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx similarity index 81% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 637be68929ac0..002cafa2e3229 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -21,17 +21,20 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AppLogic } from '../../../../app_logic'; +import { AppLogic } from '../../../../../app_logic'; import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, -} from '../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../constants'; -import { SourceDataItem } from '../../../../types'; +} from '../../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../../constants'; +import { SourceDataItem } from '../../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { ConfigDocsLinks } from './config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { staticExternalSourceData } from '../../../source_data'; + +import { AddSourceHeader } from './../add_source_header'; +import { ConfigDocsLinks } from './../config_docs_links'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './../constants'; +import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; @@ -69,10 +72,14 @@ export const ExternalConnectorConfig: React.FC = ({ const { name, categories } = sourceConfigData; const { - configuration: { documentationUrl, applicationLinkTitle, applicationPortalUrl }, + configuration: { applicationLinkTitle, applicationPortalUrl }, } = sourceData; const { isOrganization } = useValues(AppLogic); + const { + configuration: { documentationUrl }, + } = staticExternalSourceData; + const saveButton = ( {OAUTH_SAVE_CONFIG_BUTTON} @@ -135,6 +142,8 @@ export const ExternalConnectorConfig: React.FC = ({ {header} + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx new file mode 100644 index 0000000000000..13b8967637ee1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText } from '@elastic/eui'; + +import { ExternalConnectorDocumentation } from './external_connector_documentation'; + +describe('ExternalDocumentation', () => { + it('renders', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(EuiText)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx new file mode 100644 index 0000000000000..437bf6f683198 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_documentation.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiText, EuiLink } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +interface ExternalConnectorDocumentationProps { + name: string; + documentationUrl: string; +} + +export const ExternalConnectorDocumentation: React.FC = ({ + name, + documentationUrl, +}) => { + return ( + +

+ +

+

+ + + + ), + }} + /> +

+

+ + + +

+

+ + + +

+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx similarity index 95% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx index 931a2f3517fbb..45a7dd122eabf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.test.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import '../../../../../__mocks__/shallow_useeffect.mock'; -import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_form_fields.tsx rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_form_fields.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index 38bf74052541c..0e9ad386a353d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -10,18 +10,19 @@ import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues, -} from '../../../../../__mocks__/kea_logic'; -import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; +} from '../../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; -jest.mock('../../../../app_logic', () => ({ +jest.mock('../../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; + import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; describe('ExternalConnectorLogic', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts similarity index 94% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index 1f7edf0d8e2a9..3bf96a31dd8c5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -13,14 +13,14 @@ import { flashAPIErrors, flashSuccessToast, clearFlashMessages, -} from '../../../../../shared/flash_messages'; -import { HttpLogic } from '../../../../../shared/http'; -import { KibanaLogic } from '../../../../../shared/kibana'; -import { AppLogic } from '../../../../app_logic'; +} from '../../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../../shared/http'; +import { KibanaLogic } from '../../../../../../shared/kibana'; +import { AppLogic } from '../../../../../app_logic'; -import { getAddPath, getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../../routes'; -import { AddSourceLogic, SourceConfigData } from './add_source_logic'; +import { AddSourceLogic, SourceConfigData } from '../add_source_logic'; export interface ExternalConnectorActions { fetchExternalSource: () => true; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts new file mode 100644 index 0000000000000..7f2871a9f5c75 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExternalConnectorConfig } from './external_connector_config'; +export { ExternalConnectorFormFields } from './external_connector_form_fields'; +export { ExternalConnectorLogic } from './external_connector_logic'; +export { ExternalConnectorDocumentation } from './external_connector_documentation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 21246defbb863..6b335b1f7ffe4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -29,6 +29,7 @@ import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; +import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; import { AddSourceLogic, AddSourceSteps, @@ -38,7 +39,6 @@ import { AddSourceValues, AddSourceProps, } from './add_source_logic'; -import { ExternalConnectorLogic } from './external_connector_logic'; describe('AddSourceLogic', () => { const { mount } = new LogicMounter(AddSourceLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 8693cffc17e21..c621e0ee16bd5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -25,7 +25,10 @@ import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; -import { ExternalConnectorLogic, isValidExternalUrl } from './external_connector_logic'; +import { + ExternalConnectorLogic, + isValidExternalUrl, +} from './add_external_connector/external_connector_logic'; export interface AddSourceProps { sourceData: SourceDataItem; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 9a5673451cd1a..8d8311d2a0a6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -30,7 +30,7 @@ interface CardProps { description: string; buttonText: string; onClick: () => void; - betaBadgeLabel?: string; + badgeLabel?: string; } export const ConfigurationChoice: React.FC = ({ @@ -75,14 +75,14 @@ export const ConfigurationChoice: React.FC = ({ description, buttonText, onClick, - betaBadgeLabel, + badgeLabel, }: CardProps) => ( {buttonText} @@ -96,13 +96,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', { - defaultMessage: 'Default connector', + defaultMessage: 'Connector', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', { - defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + defaultMessage: + 'Use this connector to get started quickly without deploying additional infrastructure.', } ), buttonText: i18n.translate( @@ -111,6 +112,12 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Connect', } ), + badgeLabel: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.recommendedLabel', + { + defaultMessage: 'Recommended', + } + ), onClick: goToInternal, }; @@ -118,13 +125,14 @@ export const ConfigurationChoice: React.FC = ({ title: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', { - defaultMessage: 'Custom connector', + defaultMessage: 'Connector Package', } ), description: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', { - defaultMessage: 'Set up a custom connector for more configurability and control.', + defaultMessage: + 'Deploy this connector package on self-managed infrastructure for advanced use cases.', } ), buttonText: i18n.translate( @@ -134,7 +142,7 @@ export const ConfigurationChoice: React.FC = ({ } ), onClick: goToExternal, - betaBadgeLabel: i18n.translate( + badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { defaultMessage: 'Beta', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx index 5c234be583b9d..3e35c608fcee8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.test.tsx @@ -18,8 +18,8 @@ import { EuiSteps, EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { ApiKey } from '../../../../components/shared/api_key'; import { staticSourceData } from '../../source_data'; +import { ExternalConnectorFormFields } from './add_external_connector'; import { ConfigDocsLinks } from './config_docs_links'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { SaveConfig } from './save_config'; describe('SaveConfig', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx index d56efcdab95d6..eb887a9f8cc42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx @@ -35,10 +35,11 @@ import { } from '../../../../constants'; import { Configuration } from '../../../../types'; +import { ExternalConnectorFormFields } from './add_external_connector'; +import { ExternalConnectorDocumentation } from './add_external_connector'; import { AddSourceLogic } from './add_source_logic'; import { ConfigDocsLinks } from './config_docs_links'; import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants'; -import { ExternalConnectorFormFields } from './external_connector_form_fields'; interface SaveConfigProps { header: React.ReactNode; @@ -224,6 +225,12 @@ export const SaveConfig: React.FC = ({ <> {header} + {serviceType === 'external' && ( + <> + + + + )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 361eccbe8da38..5b1e4d97ef4cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -12,6 +12,35 @@ import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; +export const staticExternalSourceData: SourceDataItem = { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + }, + accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + isBeta: true, +}; + export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, @@ -502,39 +531,7 @@ export const staticSourceData: SourceDataItem[] = [ internalConnectorAvailable: true, externalConnectorAvailable: true, }, - // TODO: temporary hack until backend sends us stuff - { - name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, - serviceType: 'external', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, - applicationPortalUrl: 'https://portal.azure.com/', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, - isBeta: true, - }, + staticExternalSourceData, { name: SOURCE_NAMES.SHAREPOINT_SERVER, iconName: SOURCE_NAMES.SHAREPOINT_SERVER, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index e735119f687cc..19af955f8780c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -30,8 +30,8 @@ import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; +import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; import { ConfigurationChoice } from './components/add_source/configuration_choice'; -import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; import { staticCustomSourceData, staticSourceData as sources } from './source_data'; From f69fe77413bd065dd5e5a537878d2f0e00d981f5 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 24 Mar 2022 09:32:07 -0400 Subject: [PATCH 120/132] skip failing test suite (#128468) --- x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index 66d1e83700ded..40fd69246710b 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -16,7 +16,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const retry = getService('retry'); const browser = getService('browser'); - describe('cases list', () => { + // Failing: See https://github.com/elastic/kibana/issues/128468 + describe.skip('cases list', () => { before(async () => { await common.navigateToApp('cases'); await cases.api.deleteAllCases(); From 78bae9b24140294b4dbfae379ea588987f08350d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 24 Mar 2022 14:32:53 +0100 Subject: [PATCH 121/132] [Security solution] [Endpoint] Add api validations for single blocklist actions (#128252) * Adds blocklists extension point validation for Lists Api * Fixes generator and adds ftr test for blocklist validator under Lists API * Adds js comments on hash validation method * Removes trusted entry for signed field. Change process. by file. Fixed typo and updated generator and ftr test * Reenable disabled ftr tests * Fix wrong hash types in generator. Improve blocklists validator and updated/added tests in ftr test * Fixes Blocklist validator using file.path field, also fixed generator and unit test for the same * Returns original updated item to avoid unnecessary casting --- .../exceptions_list_item_generator.ts | 50 ++- .../services/feature_usage/service.ts | 1 + .../handlers/exceptions_pre_create_handler.ts | 9 + .../exceptions_pre_delete_item_handler.ts | 7 + .../handlers/exceptions_pre_export_handler.ts | 8 + .../exceptions_pre_get_one_handler.ts | 7 + .../exceptions_pre_multi_list_find_handler.ts | 8 + ...exceptions_pre_single_list_find_handler.ts | 9 +- .../exceptions_pre_summary_handler.ts | 8 + .../handlers/exceptions_pre_update_handler.ts | 12 + .../validators/blocklist_validator.ts | 279 ++++++++++++++ .../endpoint/validators/index.ts | 1 + .../validators/trusted_app_validator.ts | 2 +- .../apis/endpoint_artifacts/blocklists.ts | 343 ++++++++++++++++++ .../apis/index.ts | 1 + 15 files changed, 715 insertions(+), 30 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts index e6f2669c95c34..737d81cc9d1ed 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator.ts @@ -256,19 +256,7 @@ export class ExceptionsListItemGenerator extends BaseDataGenerator TrustedAppValidator.isTrustedApp({ listId: id }))) { await new TrustedAppValidator(endpointAppContextService, request).validatePreMultiListFind(); @@ -46,6 +48,12 @@ export const getExceptionsPreMultiListFindHandler = ( return data; } + // validate Blocklist + if (data.listId.some((id) => BlocklistValidator.isBlocklist({ listId: id }))) { + await new BlocklistValidator(endpointAppContextService, request).validatePreMultiListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts index c33ae013b2099..917e6c97b1bfd 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSingleListFindServerExtension['callback']; @@ -24,7 +25,7 @@ export const getExceptionsPreSingleListFindHandler = ( const { listId } = data; - // Validate Host Isolation Exceptions + // Validate Trusted applications if (TrustedAppValidator.isTrustedApp({ listId })) { await new TrustedAppValidator(endpointAppContextService, request).validatePreSingleListFind(); return data; @@ -48,6 +49,12 @@ export const getExceptionsPreSingleListFindHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreSingleListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts index c250979058962..93c1abdcb7d7a 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts @@ -11,6 +11,7 @@ import { TrustedAppValidator, HostIsolationExceptionsValidator, EventFilterValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSummaryServerExtension['callback']; @@ -38,6 +39,7 @@ export const getExceptionsPreSummaryHandler = ( await new TrustedAppValidator(endpointAppContextService, request).validatePreGetListSummary(); return data; } + // Host Isolation Exceptions if (HostIsolationExceptionsValidator.isHostIsolationException({ listId })) { await new HostIsolationExceptionsValidator( @@ -53,6 +55,12 @@ export const getExceptionsPreSummaryHandler = ( return data; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + await new BlocklistValidator(endpointAppContextService, request).validatePreGetListSummary(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 67b2e5cc03efe..acedbf7d1ed25 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -15,6 +15,7 @@ import { EventFilterValidator, TrustedAppValidator, HostIsolationExceptionsValidator, + BlocklistValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreUpdateItemServerExtension['callback']; @@ -86,6 +87,17 @@ export const getExceptionsPreUpdateItemHandler = ( return validatedItem; } + // Validate Blocklists + if (BlocklistValidator.isBlocklist({ listId })) { + const blocklistValidator = new BlocklistValidator(endpointAppContextService, request); + const validatedItem = await blocklistValidator.validatePreUpdateItem(data, currentSavedItem); + blocklistValidator.notifyFeatureUsage( + data as ExceptionItemLikeOptions, + 'BLOCKLIST_BY_POLICY' + ); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts new file mode 100644 index 0000000000000..e51190467aee4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/blocklist_validator.ts @@ -0,0 +1,279 @@ +/* + * 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 { ENDPOINT_BLOCKLISTS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { schema, Type, TypeOf } from '@kbn/config-schema'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { BaseValidator } from './base_validator'; +import { ExceptionItemLikeOptions } from '../types'; +import { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '../../../../../lists/server'; +import { isValidHash } from '../../../../common/endpoint/service/trusted_apps/validations'; +import { EndpointArtifactExceptionValidationError } from './errors'; + +const allowedHashes: Readonly = ['file.hash.md5', 'file.hash.sha1', 'file.hash.sha256']; + +const FileHashField = schema.oneOf( + allowedHashes.map((hash) => schema.literal(hash)) as [Type] +); + +const FilePath = schema.literal('file.path'); +const FileCodeSigner = schema.literal('file.Ext.code_signature'); + +const ConditionEntryTypeSchema = schema.literal('match_any'); +const ConditionEntryOperatorSchema = schema.literal('included'); + +type ConditionEntryFieldAllowedType = + | TypeOf + | TypeOf + | TypeOf; + +type BlocklistConditionEntry = + | { + field: ConditionEntryFieldAllowedType; + type: 'match_any'; + operator: 'included'; + value: string[]; + } + | TypeOf; + +/* + * A generic Entry schema to be used for a specific entry schema depending on the OS + */ +const CommonEntrySchema = { + field: schema.oneOf([FileHashField, FilePath]), + type: ConditionEntryTypeSchema, + operator: ConditionEntryOperatorSchema, + // If field === HASH then validate hash with custom method, else validate string with minLength = 1 + value: schema.conditional( + schema.siblingRef('field'), + FileHashField, + schema.arrayOf( + schema.string({ + validate: (hash: string) => + isValidHash(hash) ? undefined : `invalid hash value [${hash}]`, + }), + { minSize: 1 } + ), + schema.conditional( + schema.siblingRef('field'), + FilePath, + schema.arrayOf( + schema.string({ + validate: (pathValue: string) => + pathValue.length > 0 ? undefined : `invalid path value [${pathValue}]`, + }), + { minSize: 1 } + ), + schema.arrayOf( + schema.string({ + validate: (signerValue: string) => + signerValue.length > 0 ? undefined : `invalid signer value [${signerValue}]`, + }), + { minSize: 1 } + ) + ) + ), +}; + +// Windows Signer entries use a Nested field that checks to ensure +// that the certificate is trusted +const WindowsSignerEntrySchema = schema.object({ + type: schema.literal('nested'), + field: FileCodeSigner, + entries: schema.arrayOf( + schema.object({ + field: schema.literal('subject_name'), + value: schema.arrayOf(schema.string({ minLength: 1 })), + type: schema.literal('match_any'), + operator: schema.literal('included'), + }), + { minSize: 1 } + ), +}); + +const WindowsEntrySchema = schema.oneOf([ + WindowsSignerEntrySchema, + schema.object({ + ...CommonEntrySchema, + field: schema.oneOf([FileHashField, FilePath]), + }), +]); + +const LinuxEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +const MacEntrySchema = schema.object({ + ...CommonEntrySchema, +}); + +// Hash entries validator method. +const hashEntriesValidation = (entries: BlocklistConditionEntry[]) => { + const currentHashes = entries.map((entry) => entry.field); + // If there are more hashes than allowed (three) then return an error + if (currentHashes.length > allowedHashes.length) { + const allowedHashesMessage = allowedHashes + .map((hash) => hash.replace('file.hash.', '')) + .join(','); + return `There are more hash types than allowed [${allowedHashesMessage}]`; + } + + const hashesCount: { [key: string]: boolean } = {}; + const duplicatedHashes: string[] = []; + const invalidHash: string[] = []; + + // Check hash entries individually + currentHashes.forEach((hash) => { + if (!allowedHashes.includes(hash)) invalidHash.push(hash); + if (hashesCount[hash]) { + duplicatedHashes.push(hash); + } else { + hashesCount[hash] = true; + } + }); + + // There is more than one entry with the same hash type + if (duplicatedHashes.length) { + return `There are some duplicated hashes: ${duplicatedHashes.join(',')}`; + } + + // There is an entry with an invalid hash type + if (invalidHash.length) { + return `There are some invalid fields for hash type: ${invalidHash.join(',')}`; + } +}; + +// Validate there is only one entry when signer or path and the allowed entries for hashes +const entriesSchemaOptions = { + minSize: 1, + validate(entries: BlocklistConditionEntry[]) { + if (allowedHashes.includes(entries[0].field)) { + return hashEntriesValidation(entries); + } else { + if (entries.length > 1) { + return 'Only one entry is allowed when no using hash field type'; + } + } + }, +}; + +/* + * Entities array schema depending on Os type using schema.conditional. + * If OS === WINDOWS then use Windows schema, + * else if OS === LINUX then use Linux schema, + * else use Mac schema + * + * The validate function checks there is only one item for entries excepts for hash + */ +const EntriesSchema = schema.conditional( + schema.contextRef('os'), + OperatingSystem.WINDOWS, + schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions), + schema.conditional( + schema.contextRef('os'), + OperatingSystem.LINUX, + schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions), + schema.arrayOf(MacEntrySchema, entriesSchemaOptions) + ) +); + +/** + * Schema to validate Blocklist data for create and update. + * When called, it must be given an `context` with a `os` property set + * + * @example + * + * BlocklistDataSchema.validate(item, { os: 'windows' }); + */ +const BlocklistDataSchema = schema.object( + { + entries: EntriesSchema, + }, + + // Because we are only validating some fields from the Exception Item, we set `unknowns` to `ignore` here + { unknowns: 'ignore' } +); + +export class BlocklistValidator extends BaseValidator { + static isBlocklist(item: { listId: string }): boolean { + return item.listId === ENDPOINT_BLOCKLISTS_LIST_ID; + } + + async validatePreCreateItem( + item: CreateExceptionListItemOptions + ): Promise { + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(item); + await this.validateCanCreateByPolicyArtifacts(item); + await this.validateByPolicyItem(item); + + return item; + } + + async validatePreDeleteItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetOneItem(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreMultiListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreExport(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreSingleListFind(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreGetListSummary(): Promise { + await this.validateCanManageEndpointArtifacts(); + } + + async validatePreUpdateItem( + _updatedItem: UpdateExceptionListItemOptions, + currentItem: ExceptionListItemSchema + ): Promise { + const updatedItem = _updatedItem as ExceptionItemLikeOptions; + + await this.validateCanManageEndpointArtifacts(); + await this.validateBlocklistData(updatedItem); + + try { + await this.validateCanCreateByPolicyArtifacts(updatedItem); + } catch (noByPolicyAuthzError) { + // Not allowed to create/update by policy data. Validate that the effective scope of the item + // remained unchanged with this update or was set to `global` (only allowed update). If not, + // then throw the validation error that was catch'ed + if (this.wasByPolicyEffectScopeChanged(updatedItem, currentItem)) { + throw noByPolicyAuthzError; + } + } + + await this.validateByPolicyItem(updatedItem); + + return _updatedItem; + } + + private async validateBlocklistData(item: ExceptionItemLikeOptions): Promise { + await this.validateBasicData(item); + + try { + BlocklistDataSchema.validate(item, { os: item.osTypes[0] }); + } catch (error) { + throw new EndpointArtifactExceptionValidationError(error.message); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts index 05b3847001869..ccd6ebd8e08d6 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts @@ -8,3 +8,4 @@ export { TrustedAppValidator } from './trusted_app_validator'; export { EventFilterValidator } from './event_filter_validator'; export { HostIsolationExceptionsValidator } from './host_isolation_exceptions_validator'; +export { BlocklistValidator } from './blocklist_validator'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index dc539e76e7946..b2171ebd018bd 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -230,7 +230,7 @@ export class TrustedAppValidator extends BaseValidator { await this.validateByPolicyItem(updatedItem); - return updatedItem as UpdateExceptionListItemOptions; + return _updatedItem; } private async validateTrustedAppData(item: ExceptionItemLikeOptions): Promise { diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts new file mode 100644 index 0000000000000..7e67c38347603 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/endpoint_artifacts/blocklists.ts @@ -0,0 +1,343 @@ +/* + * 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 { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { PolicyTestResourceInfo } from '../../../security_solution_endpoint/services/endpoint_policy'; +import { ArtifactTestData } from '../../../security_solution_endpoint/services//endpoint_artifacts'; +import { + BY_POLICY_ARTIFACT_TAG_PREFIX, + GLOBAL_ARTIFACT_TAG, +} from '../../../../plugins/security_solution/common/endpoint/service/artifacts'; +import { ExceptionsListItemGenerator } from '../../../../plugins/security_solution/common/endpoint/data_generators/exceptions_list_item_generator'; +import { + createUserAndRole, + deleteUserAndRole, + ROLES, +} from '../../../common/services/security_solution'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const endpointPolicyTestResources = getService('endpointPolicyTestResources'); + const endpointArtifactTestResources = getService('endpointArtifactTestResources'); + + describe('Endpoint artifacts (via lists plugin): Blocklists', () => { + let fleetEndpointPolicy: PolicyTestResourceInfo; + + before(async () => { + // Create an endpoint policy in fleet we can work with + fleetEndpointPolicy = await endpointPolicyTestResources.createPolicy(); + + // create role/user + await createUserAndRole(getService, ROLES.detections_admin); + }); + + after(async () => { + if (fleetEndpointPolicy) { + await fleetEndpointPolicy.cleanup(); + } + + // delete role/user + await deleteUserAndRole(getService, ROLES.detections_admin); + }); + + const anEndpointArtifactError = (res: { body: { message: string } }) => { + expect(res.body.message).to.match(/EndpointArtifactError/); + }; + const anErrorMessageWith = ( + value: string | RegExp + ): ((res: { body: { message: string } }) => void) => { + return (res) => { + if (value instanceof RegExp) { + expect(res.body.message).to.match(value); + } else { + expect(res.body.message).to.be(value); + } + }; + }; + + describe('and accessing blocklists', () => { + const exceptionsGenerator = new ExceptionsListItemGenerator(); + let blocklistData: ArtifactTestData; + + type BlocklistApiCallsInterface = Array<{ + method: keyof Pick; + info?: string; + path: string; + // The body just needs to have the properties we care about in the tests. This should cover most + // mocks used for testing that support different interfaces + getBody: () => BodyReturnType; + }>; + + beforeEach(async () => { + blocklistData = await endpointArtifactTestResources.createBlocklist({ + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${fleetEndpointPolicy.packagePolicy.id}`], + }); + }); + + afterEach(async () => { + if (blocklistData) { + await blocklistData.cleanup(); + } + }); + + const blocklistApiCalls: BlocklistApiCallsInterface< + Pick + > = [ + { + method: 'post', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => { + return exceptionsGenerator.generateBlocklistForCreate({ tags: [GLOBAL_ARTIFACT_TAG] }); + }, + }, + { + method: 'put', + path: EXCEPTION_LIST_ITEM_URL, + getBody: () => + exceptionsGenerator.generateBlocklistForUpdate({ + id: blocklistData.artifact.id, + item_id: blocklistData.artifact.item_id, + tags: [GLOBAL_ARTIFACT_TAG], + }), + }, + ]; + + describe('and has authorization to manage endpoint security', () => { + for (const blocklistApiCall of blocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}] if invalid condition entry fields are used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries[0].field = 'some.invalid.field'; + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/types that failed validation:/)); + }); + + it(`should error on [${blocklistApiCall.method}] if the same hash type is present twice`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.sha256', + value: ['a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.hash.sha256', + value: [ + '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + ], + type: 'match_any', + operator: 'included', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/duplicated/)); + }); + + it(`should error on [${blocklistApiCall.method}] if an invalid hash is used`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: ['1'], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid hash/)); + }); + + it(`should error on [${blocklistApiCall.method}] if no values`, async () => { + const body = blocklistApiCall.getBody(); + + body.entries = [ + { + field: 'file.hash.md5', + operator: 'included', + type: 'match_any', + value: [], + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anErrorMessageWith(/Invalid value \"\[\]\"/)); + }); + + it(`should error on [${blocklistApiCall.method}] if signer is set for a non windows os entry item`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux']; + body.entries = [ + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: 'foo', + type: 'match', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/^.*(?!file\.Ext\.code_signature)/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one entry and not a hash`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['windows']; + body.entries = [ + { + field: 'file.path', + value: ['C:\\some\\path', 'C:\\some\\other\\path', 'C:\\yet\\another\\path'], + type: 'match_any', + operator: 'included', + }, + { + field: 'file.Ext.code_signature', + entries: [ + { + field: 'subject_name', + value: ['notsus.exe', 'verynotsus.exe', 'superlegit.exe'], + type: 'match_any', + operator: 'included', + }, + ], + type: 'nested', + }, + ]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/one entry is allowed/)); + }); + + it(`should error on [${blocklistApiCall.method}] if more than one OS is set`, async () => { + const body = blocklistApiCall.getBody(); + + body.os_types = ['linux', 'windows']; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/\[osTypes\]: array size is \[2\]/)); + }); + + it(`should error on [${blocklistApiCall.method}] if policy id is invalid`, async () => { + const body = blocklistApiCall.getBody(); + + body.tags = [`${BY_POLICY_ARTIFACT_TAG_PREFIX}123`]; + + await supertest[blocklistApiCall.method](blocklistApiCall.path) + .set('kbn-xsrf', 'true') + .send(body) + .expect(400) + .expect(anEndpointArtifactError) + .expect(anErrorMessageWith(/invalid policy ids/)); + }); + } + }); + + describe('and user DOES NOT have authorization to manage endpoint security', () => { + const allblocklistApiCalls: BlocklistApiCallsInterface = [ + ...blocklistApiCalls, + { + method: 'get', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'list summary', + get path() { + return `${EXCEPTION_LIST_URL}/summary?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'delete', + info: 'single item', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}?item_id=${blocklistData.artifact.item_id}&namespace_type=${blocklistData.artifact.namespace_type}`; + }, + getBody: () => undefined, + }, + { + method: 'post', + info: 'list export', + get path() { + return `${EXCEPTION_LIST_URL}/_export?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&id=1`; + }, + getBody: () => undefined, + }, + { + method: 'get', + info: 'single items', + get path() { + return `${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${blocklistData.artifact.list_id}&namespace_type=${blocklistData.artifact.namespace_type}&page=1&per_page=1&sort_field=name&sort_order=asc`; + }, + getBody: () => undefined, + }, + ]; + + for (const blocklistApiCall of allblocklistApiCalls) { + it(`should error on [${blocklistApiCall.method}]`, async () => { + await supertestWithoutAuth[blocklistApiCall.method](blocklistApiCall.path) + .auth(ROLES.detections_admin, 'changeme') + .set('kbn-xsrf', 'true') + .send(blocklistApiCall.getBody()) + .expect(403, { + status_code: 403, + message: 'EndpointArtifactError: Endpoint authorization failure', + }); + }); + } + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index 5acb9d2e4261d..94a5a9122f187 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -35,5 +35,6 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider loadTestFile(require.resolve('./endpoint_artifacts/trusted_apps')); loadTestFile(require.resolve('./endpoint_artifacts/event_filters')); loadTestFile(require.resolve('./endpoint_artifacts/host_isolation_exceptions')); + loadTestFile(require.resolve('./endpoint_artifacts/blocklists')); }); } From 0421f868eaf92c65c78cf5b79db8502a223078dc Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 24 Mar 2022 14:37:11 +0100 Subject: [PATCH 122/132] [Search] SQL search strategy (#127859) --- .../search_examples/public/application.tsx | 9 + .../search_examples/public/sql_search/app.tsx | 164 +++++++++++ src/plugins/data/common/search/index.ts | 1 + .../search/strategies/sql_search/index.ts | 9 + .../search/strategies/sql_search/types.ts | 23 ++ src/plugins/data/server/search/README.md | 1 + .../data/server/search/search_service.ts | 3 + .../search/strategies/sql_search/index.ts | 9 + .../sql_search/request_utils.test.ts | 110 ++++++++ .../strategies/sql_search/request_utils.ts | 56 ++++ .../strategies/sql_search/response_utils.ts | 26 ++ .../sql_search/sql_search_strategy.test.ts | 264 ++++++++++++++++++ .../sql_search/sql_search_strategy.ts | 138 +++++++++ test/api_integration/apis/search/index.ts | 1 + .../api_integration/apis/search/sql_search.ts | 90 ++++++ .../search/session/get_search_status.ts | 1 + x-pack/test/examples/search_examples/index.ts | 1 + .../search_examples/sql_search_example.ts | 42 +++ 18 files changed, 948 insertions(+) create mode 100644 examples/search_examples/public/sql_search/app.tsx create mode 100644 src/plugins/data/common/search/strategies/sql_search/index.ts create mode 100644 src/plugins/data/common/search/strategies/sql_search/types.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/index.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/request_utils.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/response_utils.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts create mode 100644 src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts create mode 100644 test/api_integration/apis/search/sql_search.ts create mode 100644 x-pack/test/examples/search_examples/sql_search_example.ts diff --git a/examples/search_examples/public/application.tsx b/examples/search_examples/public/application.tsx index c77c3c24be147..9bd5bb0f3f8a2 100644 --- a/examples/search_examples/public/application.tsx +++ b/examples/search_examples/public/application.tsx @@ -16,12 +16,17 @@ import { SearchExamplePage, ExampleLink } from './common/example_page'; import { SearchExamplesApp } from './search/app'; import { SearchSessionsExampleApp } from './search_sessions/app'; import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public'; +import { SqlSearchExampleApp } from './sql_search/app'; const LINKS: ExampleLink[] = [ { path: '/search', title: 'Search', }, + { + path: '/sql-search', + title: 'SQL Search', + }, { path: '/search-sessions', title: 'Search Sessions', @@ -51,12 +56,16 @@ export const renderApp = ( /> + + + + diff --git a/examples/search_examples/public/sql_search/app.tsx b/examples/search_examples/public/sql_search/app.tsx new file mode 100644 index 0000000000000..acb640c4d82db --- /dev/null +++ b/examples/search_examples/public/sql_search/app.tsx @@ -0,0 +1,164 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPanel, + EuiSuperUpdateButton, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; + +import { CoreStart } from '../../../../src/core/public'; + +import { + DataPublicPluginStart, + IKibanaSearchResponse, + isCompleteResponse, + isErrorResponse, +} from '../../../../src/plugins/data/public'; +import { + SQL_SEARCH_STRATEGY, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../src/plugins/data/common'; + +interface SearchExamplesAppDeps { + notifications: CoreStart['notifications']; + data: DataPublicPluginStart; +} + +export const SqlSearchExampleApp = ({ notifications, data }: SearchExamplesAppDeps) => { + const [sqlQuery, setSqlQuery] = useState(''); + const [request, setRequest] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [rawResponse, setRawResponse] = useState>({}); + + function setResponse(response: IKibanaSearchResponse) { + setRawResponse(response.rawResponse); + } + + const doSearch = async () => { + const req: SqlSearchStrategyRequest = { + params: { + query: sqlQuery, + }, + }; + + // Submit the search request using the `data.search` service. + setRequest(req.params!); + setIsLoading(true); + + data.search + .search(req, { + strategy: SQL_SEARCH_STRATEGY, + }) + .subscribe({ + next: (res) => { + if (isCompleteResponse(res)) { + setIsLoading(false); + setResponse(res); + } else if (isErrorResponse(res)) { + setIsLoading(false); + setResponse(res); + notifications.toasts.addDanger('An error has occurred'); + } + }, + error: (e) => { + setIsLoading(false); + data.search.showError(e); + }, + }); + }; + + return ( + + + +

SQL search example

+
+
+ + + + + + setSqlQuery(e.target.value)} + fullWidth + data-test-subj="sqlQueryInput" + /> + + + + + + + + + + + +

Request

+
+ + {JSON.stringify(request, null, 2)} + +
+
+ + + +

Response

+
+ + {JSON.stringify(rawResponse, null, 2)} + +
+
+
+
+
+
+ ); +}; diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index badbb94e9752f..d0d103abe1ea2 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -17,3 +17,4 @@ export * from './poll_search'; export * from './strategies/es_search'; export * from './strategies/eql_search'; export * from './strategies/ese_search'; +export * from './strategies/sql_search'; diff --git a/src/plugins/data/common/search/strategies/sql_search/index.ts b/src/plugins/data/common/search/strategies/sql_search/index.ts new file mode 100644 index 0000000000000..12594660136d8 --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './types'; diff --git a/src/plugins/data/common/search/strategies/sql_search/types.ts b/src/plugins/data/common/search/strategies/sql_search/types.ts new file mode 100644 index 0000000000000..e51d0bf4a6b6c --- /dev/null +++ b/src/plugins/data/common/search/strategies/sql_search/types.ts @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + SqlGetAsyncRequest, + SqlQueryRequest, + SqlQueryResponse, +} from '@elastic/elasticsearch/lib/api/types'; +import { IKibanaSearchRequest, IKibanaSearchResponse } from '../../types'; + +export const SQL_SEARCH_STRATEGY = 'sql'; + +export type SqlRequestParams = + | Omit + | Omit; +export type SqlSearchStrategyRequest = IKibanaSearchRequest; + +export type SqlSearchStrategyResponse = IKibanaSearchResponse; diff --git a/src/plugins/data/server/search/README.md b/src/plugins/data/server/search/README.md index b564c34a7f8b3..d663cdc38da1b 100644 --- a/src/plugins/data/server/search/README.md +++ b/src/plugins/data/server/search/README.md @@ -10,3 +10,4 @@ The `search` plugin includes: - ES_SEARCH_STRATEGY - hitting regular es `_search` endpoint using query DSL - (default) ESE_SEARCH_STRATEGY (Enhanced ES) - hitting `_async_search` endpoint and works with search sessions - EQL_SEARCH_STRATEGY +- SQL_SEARCH_STRATEGY diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 8fb92136bc259..7c01fefc92d65 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -77,6 +77,7 @@ import { eqlRawResponse, ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY, + SQL_SEARCH_STRATEGY, } from '../../common/search'; import { getEsaggs, getEsdsl, getEql } from './expressions'; import { @@ -93,6 +94,7 @@ import { enhancedEsSearchStrategyProvider } from './strategies/ese_search'; import { eqlSearchStrategyProvider } from './strategies/eql_search'; import { NoSearchIdInSessionError } from './errors/no_search_id_in_session'; import { CachedUiSettingsClient } from './services'; +import { sqlSearchStrategyProvider } from './strategies/sql_search'; type StrategyMap = Record>; @@ -176,6 +178,7 @@ export class SearchService implements Plugin { ); this.registerSearchStrategy(EQL_SEARCH_STRATEGY, eqlSearchStrategyProvider(this.logger)); + this.registerSearchStrategy(SQL_SEARCH_STRATEGY, sqlSearchStrategyProvider(this.logger)); registerBsearchRoute( bfetch, diff --git a/src/plugins/data/server/search/strategies/sql_search/index.ts b/src/plugins/data/server/search/strategies/sql_search/index.ts new file mode 100644 index 0000000000000..9af70ddcb618d --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/index.ts @@ -0,0 +1,9 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { sqlSearchStrategyProvider } from './sql_search_strategy'; diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts new file mode 100644 index 0000000000000..9944de7be17be --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.test.ts @@ -0,0 +1,110 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getDefaultAsyncSubmitParams, getDefaultAsyncGetParams } from './request_utils'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({}); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts new file mode 100644 index 0000000000000..d05b2710b07ea --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts @@ -0,0 +1,56 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import { ISearchOptions } from '../../../../common'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +/** + @internal + */ +export function getDefaultAsyncSubmitParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, + }; +} + +/** + @internal + */ +export function getDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined + : { + // We still need to do polling for searches not within the context of a search session or when search session disabled + keep_alive: '1m', + }), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/response_utils.ts b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts new file mode 100644 index 0000000000000..9d6e3f4fd3ebc --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/response_utils.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { SqlSearchStrategyResponse } from '../../../../common'; + +/** + * Get the Kibana representation of an async search response + */ +export function toAsyncKibanaSearchResponse( + response: SqlQueryResponse, + warning?: string +): SqlSearchStrategyResponse { + return { + id: response.id, + rawResponse: response, + isPartial: response.is_partial, + isRunning: response.is_running, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts new file mode 100644 index 0000000000000..2734a512e046b --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.test.ts @@ -0,0 +1,264 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { KbnServerError } from '../../../../../kibana_utils/server'; +import { errors } from '@elastic/elasticsearch'; +import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json'; +import { SearchStrategyDependencies } from '../../types'; +import { sqlSearchStrategyProvider } from './sql_search_strategy'; +import { createSearchSessionsClientMock } from '../../mocks'; +import { SqlSearchStrategyRequest } from '../../../../common'; + +const mockSqlResponse = { + body: { + id: 'foo', + is_partial: false, + is_running: false, + rows: [], + }, +}; + +describe('SQL search strategy', () => { + const mockSqlGetAsync = jest.fn(); + const mockSqlQuery = jest.fn(); + const mockSqlDelete = jest.fn(); + const mockLogger: any = { + debug: () => {}, + }; + const mockDeps = { + esClient: { + asCurrentUser: { + sql: { + getAsync: mockSqlGetAsync, + query: mockSqlQuery, + deleteAsync: mockSqlDelete, + }, + }, + }, + searchSessionsClient: createSearchSessionsClientMock(), + } as unknown as SearchStrategyDependencies; + + beforeEach(() => { + mockSqlGetAsync.mockClear(); + mockSqlQuery.mockClear(); + mockSqlDelete.mockClear(); + }); + + it('returns a strategy with `search and `cancel`, `extend`', async () => { + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + expect(typeof esSearch.search).toBe('function'); + expect(typeof esSearch.cancel).toBe('function'); + expect(typeof esSearch.extend).toBe('function'); + }); + + describe('search', () => { + describe('no sessionId', () => { + it('makes a POST request with params when no ID provided', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, {}, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + expect(request).toHaveProperty('format', 'json'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + }); + + it('makes a GET request to async search with ID', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, {}, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + expect(request).toHaveProperty('format', 'json'); + }); + }); + + // skip until full search session support https://github.com/elastic/kibana/issues/127880 + describe.skip('with sessionId', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('keep_alive', '604800000ms'); + }); + + it('makes a GET request to async search without keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).not.toHaveProperty('keep_alive'); + }); + }); + + describe('with sessionId (until SQL ignores session Id)', () => { + it('makes a POST request with params (long keepalive)', async () => { + mockSqlQuery.mockResolvedValueOnce(mockSqlResponse); + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlQuery).toBeCalled(); + const request = mockSqlQuery.mock.calls[0][0]; + expect(request.query).toEqual(params.query); + + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + + it('makes a GET request to async search with keepalive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise(); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request.id).toEqual('foo'); + expect(request).toHaveProperty('wait_for_completion_timeout'); + expect(request).toHaveProperty('keep_alive', '1m'); + }); + }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new errors.ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSqlQuery.mockRejectedValue(errResponse); + + const params: SqlSearchStrategyRequest['params'] = { + query: + 'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC', + }; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSqlQuery).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); + }); + + describe('cancel', () => { + it('makes a DELETE request to async search with the provided ID', async () => { + mockSqlDelete.mockResolvedValueOnce(200); + + const id = 'some_id'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + + await esSearch.cancel!(id, {}, mockDeps); + + expect(mockSqlDelete).toBeCalled(); + const request = mockSqlDelete.mock.calls[0][0]; + expect(request).toEqual({ id }); + }); + }); + + describe('extend', () => { + it('makes a GET request to async search with the provided ID and keepAlive', async () => { + mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await sqlSearchStrategyProvider(mockLogger); + await esSearch.extend!(id, keepAlive, {}, mockDeps); + + expect(mockSqlGetAsync).toBeCalled(); + const request = mockSqlGetAsync.mock.calls[0][0]; + expect(request).toEqual({ id, keep_alive: keepAlive }); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts new file mode 100644 index 0000000000000..51ab35af3db0f --- /dev/null +++ b/src/plugins/data/server/search/strategies/sql_search/sql_search_strategy.ts @@ -0,0 +1,138 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IScopedClusterClient, Logger } from 'kibana/server'; +import { catchError, tap } from 'rxjs/operators'; +import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { ISearchStrategy, SearchStrategyDependencies } from '../../types'; +import type { + IAsyncSearchOptions, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '../../../../common'; +import { pollSearch } from '../../../../common'; +import { getDefaultAsyncGetParams, getDefaultAsyncSubmitParams } from './request_utils'; +import { toAsyncKibanaSearchResponse } from './response_utils'; +import { getKbnServerError } from '../../../../../kibana_utils/server'; + +export const sqlSearchStrategyProvider = ( + logger: Logger, + useInternalUser: boolean = false +): ISearchStrategy => { + async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.deleteAsync({ id }); + } catch (e) { + throw getKbnServerError(e); + } + } + + function asyncSearch( + { id, ...request }: SqlSearchStrategyRequest, + options: IAsyncSearchOptions, + { esClient }: SearchStrategyDependencies + ) { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + + // disable search sessions until session task manager supports SQL + // https://github.com/elastic/kibana/issues/127880 + // const sessionConfig = searchSessionsClient.getConfig(); + const sessionConfig = null; + + const search = async () => { + if (id) { + const params: SqlGetAsyncRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncGetParams(sessionConfig, options), + id, + }; + + const { body, headers } = await client.sql.getAsync(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } else { + const params: SqlQueryRequest = { + format: request.params?.format ?? 'json', + ...getDefaultAsyncSubmitParams(sessionConfig, options), + ...request.params, + }; + + const { headers, body } = await client.sql.query(params, { + signal: options.abortSignal, + meta: true, + }); + + return toAsyncKibanaSearchResponse(body, headers?.warning); + } + }; + + const cancel = async () => { + if (id) { + await cancelAsyncSearch(id, esClient); + } + }; + + return pollSearch(search, cancel, options).pipe( + tap((response) => (id = response.id)), + catchError((e) => { + throw getKbnServerError(e); + }) + ); + } + + return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable>` + * @throws `KbnServerError` + */ + search: (request, options: IAsyncSearchOptions, deps) => { + logger.debug(`sql search: search request=${JSON.stringify(request)}`); + + return asyncSearch(request, options, deps); + }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + cancel: async (id, options, { esClient }) => { + logger.debug(`sql search: cancel async_search_id=${id}`); + await cancelAsyncSearch(id, esClient); + }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise` + * @throws `KbnServerError` + */ + extend: async (id, keepAlive, options, { esClient }) => { + logger.debug(`sql search: extend async_search_id=${id} keep_alive=${keepAlive}`); + try { + const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser; + await client.sql.getAsync({ + id, + keep_alive: keepAlive, + }); + } catch (e) { + throw getKbnServerError(e); + } + }, + }; +}; diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index d5d6e928b5483..cde0c925d91ff 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./sql_search')); loadTestFile(require.resolve('./bsearch')); }); } diff --git a/test/api_integration/apis/search/sql_search.ts b/test/api_integration/apis/search/sql_search.ts new file mode 100644 index 0000000000000..c57d424e56fc7 --- /dev/null +++ b/test/api_integration/apis/search/sql_search.ts @@ -0,0 +1,90 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + + describe('SQL search', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + }); + describe('post', () => { + it('should return 200 when correctly formatted searches are provided', async () => { + const resp = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + }, + }) + .expect(200); + + expect(resp.body).to.have.property('id'); + expect(resp.body).to.have.property('isPartial'); + expect(resp.body).to.have.property('isRunning'); + expect(resp.body).to.have.property('rawResponse'); + }); + + it('should fetch search results by id', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + const resp2 = await supertest.post(`/internal/search/sql/${id}`).send({}); + + expect(resp2.status).to.be(200); + expect(resp2.body.id).to.be(id); + expect(resp2.body).to.have.property('isPartial'); + expect(resp2.body).to.have.property('isRunning'); + expect(resp2.body).to.have.property('rawResponse'); + }); + }); + + describe('delete', () => { + it('should delete search', async () => { + const resp1 = await supertest + .post(`/internal/search/sql`) + .send({ + params: { + query: sqlQuery, + keep_on_completion: true, // force keeping the results even if completes early + }, + }) + .expect(200); + const id = resp1.body.id; + + // confirm it was saved + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(200); + + // delete it + await supertest.delete(`/internal/search/sql/${id}`).send().expect(200); + + // check it was deleted + await supertest.post(`/internal/search/sql/${id}`).send({}).expect(404); + }); + }); + }); +} diff --git a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts index abfe089e82a38..aa8c2c0e3aa00 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/get_search_status.ts @@ -17,6 +17,7 @@ export async function getSearchStatus( asyncId: string ): Promise> { // TODO: Handle strategies other than the default one + // https://github.com/elastic/kibana/issues/127880 try { // @ts-expect-error start_time_in_millis: EpochMillis is string | number const apiResponse: TransportResult = await client.asyncSearch.status( diff --git a/x-pack/test/examples/search_examples/index.ts b/x-pack/test/examples/search_examples/index.ts index ac9e385d3d391..18b2acbd56564 100644 --- a/x-pack/test/examples/search_examples/index.ts +++ b/x-pack/test/examples/search_examples/index.ts @@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC loadTestFile(require.resolve('./search_example')); loadTestFile(require.resolve('./search_sessions_cache')); loadTestFile(require.resolve('./partial_results_example')); + loadTestFile(require.resolve('./sql_search_example')); }); } diff --git a/x-pack/test/examples/search_examples/sql_search_example.ts b/x-pack/test/examples/search_examples/sql_search_example.ts new file mode 100644 index 0000000000000..a51ea21ea36bd --- /dev/null +++ b/x-pack/test/examples/search_examples/sql_search_example.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const toasts = getService('toasts'); + + describe('SQL search example', () => { + const appId = 'searchExamples'; + + before(async function () { + await PageObjects.common.navigateToApp(appId, { insertTimestamp: false }); + await testSubjects.click('/sql-search'); + }); + + it('should search', async () => { + const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`; + await (await testSubjects.find('sqlQueryInput')).type(sqlQuery); + + await testSubjects.click(`querySubmitButton`); + + await testSubjects.stringExistsInCodeBlockOrFail( + 'requestCodeBlock', + JSON.stringify(sqlQuery) + ); + await testSubjects.stringExistsInCodeBlockOrFail( + 'responseCodeBlock', + `"logstash-2015.09.22"` + ); + expect(await toasts.getToastCount()).to.be(0); + }); + }); +} From 632d64e5d19be9ab84c23753328f07d0d5c7285c Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 24 Mar 2022 08:40:53 -0500 Subject: [PATCH 123/132] skip flaky suite. #128441 --- x-pack/plugins/task_manager/server/task_events.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/task_manager/server/task_events.test.ts b/x-pack/plugins/task_manager/server/task_events.test.ts index 5d72120da725c..607453b7ea92f 100644 --- a/x-pack/plugins/task_manager/server/task_events.test.ts +++ b/x-pack/plugins/task_manager/server/task_events.test.ts @@ -45,7 +45,8 @@ describe('task_events', () => { expect(result.eventLoopBlockMs).toBe(undefined); }); - describe('startTaskTimerWithEventLoopMonitoring', () => { + // FLAKY: https://github.com/elastic/kibana/issues/128441 + describe.skip('startTaskTimerWithEventLoopMonitoring', () => { test('non-blocking', async () => { const stopTaskTimer = startTaskTimerWithEventLoopMonitoring({ monitor: true, From c591e46aa3d9c0623624a7b62cb629d215fe9b3b Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 24 Mar 2022 08:46:02 -0500 Subject: [PATCH 124/132] skip flaky suite. #126414 --- test/functional/apps/console/_autocomplete.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index cd17244b1f498..4b424b2a79c66 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - describe('with a missing comma in query', () => { + // FLAKY: https://github.com/elastic/kibana/issues/126414 + describe.skip('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); From a3e3ce81fe0a0dbb7e4eee527af71980e2872826 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 24 Mar 2022 09:53:13 -0400 Subject: [PATCH 125/132] [Alerting] Add UI indicators for rules with less than configured minimum schedule interval (#128254) * Adding warning icon next to rule interval * Adding rule type id and and id to warning * Changing to info icon. Showing toast on rule details view * Adding unit tests * Adding functional tests * Fixing unit tests * Fixing functional test * PR feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/rules_client/rules_client.ts | 49 +++---- .../server/rules_client/tests/create.test.ts | 2 +- .../server/rules_client/tests/update.test.ts | 2 +- .../components/rule_details.test.tsx | 23 ++++ .../rule_details/components/rule_details.tsx | 68 +++++++++- .../components/rule_details_route.test.tsx | 5 + .../rules_list/components/rules_list.test.tsx | 15 ++- .../rules_list/components/rules_list.tsx | 122 ++++++++++++++---- .../public/common/lib/config_api.ts | 7 +- .../triggers_actions_ui/public/types.ts | 1 + .../apps/triggers_actions_ui/alerts_list.ts | 25 +++- .../apps/triggers_actions_ui/details.ts | 33 ++++- x-pack/test/functional_with_es_ssl/config.ts | 2 +- 13 files changed, 299 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 666617dcf3fd8..02901ca3fdc70 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -368,19 +368,12 @@ export class RulesClient { await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be created - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error creating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -472,6 +465,14 @@ export class RulesClient { }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; } + + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${createdAlert.attributes.alertTypeId}" rule type with ID "${createdAlert.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + ); + } + return this.getAlertFromRaw( createdAlert.id, createdAlert.attributes.alertTypeId, @@ -1117,19 +1118,12 @@ export class RulesClient { const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); await this.validateActions(ruleType, data.actions); - // Validate that schedule interval is not less than configured minimum + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); - if (intervalInMs < this.minimumScheduleIntervalInMs) { - if (this.minimumScheduleInterval.enforce) { - throw Boom.badRequest( - `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` - ); - } else { - // just log warning but allow rule to be updated - this.logger.warn( - `Rule schedule interval (${data.schedule.interval}) is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` - ); - } + if (intervalInMs < this.minimumScheduleIntervalInMs && this.minimumScheduleInterval.enforce) { + throw Boom.badRequest( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); } // Extract saved object references for this rule @@ -1192,6 +1186,13 @@ export class RulesClient { throw e; } + // Log warning if schedule interval is less than the minimum but we're not enforcing it + if (intervalInMs < this.minimumScheduleIntervalInMs && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${data.schedule.interval}) for "${ruleType.id}" rule type with ID "${id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } + return this.getPartialRuleFromRaw( id, ruleType, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index df0e806e5e798..91be42ecd9e1f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -2602,7 +2602,7 @@ describe('create()', () => { await rulesClient.create({ data }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` + `Rule schedule interval (1s) for "123" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent creation of these rules.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); expect(taskManager.schedule).toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index a087dfd436817..4bc0276a9ae1a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -1947,7 +1947,7 @@ describe('update()', () => { }, }); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( - `Rule schedule interval (1s) is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + `Rule schedule interval (1s) for "myType" rule type with ID "1" is less than the minimum value (1m). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` ); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index e6b5fdbdb1883..dddfc357f2eaa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -31,6 +31,11 @@ import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); jest.mock('react-router-dom', () => ({ useHistory: () => ({ push: jest.fn(), @@ -142,6 +147,24 @@ describe('rule_details', () => { ).toBeTruthy(); }); + it('displays a toast message when interval is less than configured minimum', async () => { + const rule = mockRule({ + schedule: { + interval: '1s', + }, + }); + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(useKibanaMock().services.notifications.toasts.addInfo).toHaveBeenCalled(); + }); + describe('actions', () => { it('renders an rule action', () => { const rule = mockRule({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index de948c2fd21de..736178cc5ab3e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -27,11 +27,18 @@ import { EuiPageTemplate, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { AlertExecutionStatusErrorReasons } from '../../../../../../alerting/common'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { AlertExecutionStatusErrorReasons, parseDuration } from '../../../../../../alerting/common'; import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { getAlertingSectionBreadcrumb, getRuleDetailsBreadcrumb } from '../../../lib/breadcrumb'; import { getCurrentDocTitle } from '../../../lib/doc_title'; -import { Rule, RuleType, ActionType, ActionConnector } from '../../../../types'; +import { + Rule, + RuleType, + ActionType, + ActionConnector, + TriggersActionsUiConfig, +} from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, withBulkRuleOperations, @@ -47,6 +54,7 @@ import { import { useKibana } from '../../../../common/lib/kibana'; import { ruleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; export type RuleDetailsProps = { rule: Rule; @@ -75,6 +83,7 @@ export const RuleDetails: React.FunctionComponent = ({ setBreadcrumbs, chrome, http, + notifications: { toasts }, } = useKibana().services; const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { @@ -84,6 +93,14 @@ export const RuleDetails: React.FunctionComponent = ({ const [hasActionsWithBrokenConnector, setHasActionsWithBrokenConnector] = useState(false); + const [config, setConfig] = useState({}); + + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + // Set breadcrumb and page title useEffect(() => { setBreadcrumbs([ @@ -141,6 +158,53 @@ export const RuleDetails: React.FunctionComponent = ({ const [dismissRuleErrors, setDismissRuleErrors] = useState(false); const [dismissRuleWarning, setDismissRuleWarning] = useState(false); + // Check whether interval is below configured minium + useEffect(() => { + if (rule.schedule.interval && config.minimumScheduleInterval) { + if ( + parseDuration(rule.schedule.interval) < parseDuration(config.minimumScheduleInterval.value) + ) { + const configurationToast = toasts.addInfo({ + 'data-test-subj': 'intervalConfigToast', + title: i18n.translate( + 'xpack.triggersActionsUI.sections.ruleDetails.scheduleIntervalToastTitle', + { + defaultMessage: 'Configuration settings', + } + ), + text: toMountPoint( + <> +

+ +

+ {hasEditButton && ( + + + { + toasts.remove(configurationToast); + setEditFlyoutVisibility(true); + }} + > + + + + + )} + + ), + }); + } + } + }, [rule.schedule.interval, config.minimumScheduleInterval, toasts, hasEditButton]); + const setRule = async () => { history.push(routeToRuleDetails.replace(`:ruleId`, rule.id)); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx index 1289b81eb8169..032d69fa7ccc4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details_route.test.tsx @@ -19,6 +19,11 @@ import { spacesPluginMock } from '../../../../../../spaces/public/mocks'; import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/config_api', () => ({ + triggersActionsUiConfig: jest + .fn() + .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), +})); describe('rule_details_route', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx index 36c102c6f54bb..021ea3c2d0055 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.test.tsx @@ -169,7 +169,7 @@ describe('rules_list component with items', () => { tags: ['tag1'], enabled: true, ruleTypeId: 'test_rule_type', - schedule: { interval: '5d' }, + schedule: { interval: '1s' }, actions: [], params: { name: 'test rule type name' }, scheduledTaskId: null, @@ -476,6 +476,19 @@ describe('rules_list component with items', () => { wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-interval"]').length ).toEqual(mockedRulesData.length); + // Schedule interval tooltip + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe( + 'Below configured minimum intervalRule interval of 1 second is below the minimum configured interval of 1 minute. This may impact alerting performance.' + ); + + wrapper.find('[data-test-subj="ruleInterval-config-tooltip-0"]').first().simulate('mouseOut'); + // Duration column expect( wrapper.find('EuiTableRowCell[data-test-subj="rulesTableCell-duration"]').length diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index c55f1303120f0..3cb1ac7b93dca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -48,6 +48,7 @@ import { RuleTypeIndex, Pagination, Percentiles, + TriggersActionsUiConfig, } from '../../../../types'; import { RuleAdd, RuleEdit } from '../../rule_form'; import { BulkOperationPopover } from '../../common/components/bulk_operation_popover'; @@ -75,6 +76,7 @@ import { ALERTS_FEATURE_ID, AlertExecutionStatusErrorReasons, formatDuration, + parseDuration, MONITORING_HISTORY_LIMIT, } from '../../../../../../alerting/common'; import { rulesStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; @@ -89,6 +91,7 @@ import { PercentileSelectablePopover } from './percentile_selectable_popover'; import { RuleDurationFormat } from './rule_duration_format'; import { shouldShowDurationWarning } from '../../../lib/execution_duration_utils'; import { getFormattedSuccessRatio } from '../../../lib/monitoring_utils'; +import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; const ENTER_KEY = 13; @@ -135,6 +138,7 @@ export const RulesList: React.FunctionComponent = () => { const [initialLoad, setInitialLoad] = useState(true); const [noData, setNoData] = useState(true); + const [config, setConfig] = useState({}); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [isPerformingAction, setIsPerformingAction] = useState(false); @@ -150,6 +154,12 @@ export const RulesList: React.FunctionComponent = () => { const [currentRuleToEdit, setCurrentRuleToEdit] = useState(null); const [tagPopoverOpenIndex, setTagPopoverOpenIndex] = useState(-1); + useEffect(() => { + (async () => { + setConfig(await triggersActionsUiConfig({ http })); + })(); + }, [http]); + const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); @@ -609,7 +619,59 @@ export const RulesList: React.FunctionComponent = () => { sortable: false, truncateText: false, 'data-test-subj': 'rulesTableCell-interval', - render: (interval: string) => formatDuration(interval), + render: (interval: string, item: RuleTableItem) => { + const durationString = formatDuration(interval); + return ( + <> + + {durationString} + + {item.showIntervalWarning && ( + + { + if (item.isEditable && isRuleTypeEditableInContext(item.ruleTypeId)) { + onRuleEdit(item); + } + }} + iconType="flag" + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.rulesList.rulesListTable.columns.intervalIconAriaLabel', + { defaultMessage: 'Below configured minimum interval' } + )} + /> + + )} + + + + ); + }, }, { field: 'executionStatus.lastDuration', @@ -850,11 +912,12 @@ export const RulesList: React.FunctionComponent = () => { setIsPerformingAction(true)} onActionPerformed={() => { loadRulesData(); @@ -1037,7 +1100,12 @@ export const RulesList: React.FunctionComponent = () => { items={ ruleTypesState.isInitialized === false ? [] - : convertRulesToTableItems(rulesState.data, ruleTypesState.data, canExecuteActions) + : convertRulesToTableItems({ + rules: rulesState.data, + ruleTypeIndex: ruleTypesState.data, + canExecuteActions, + config, + }) } itemId="id" columns={getRulesTableColumns()} @@ -1202,19 +1270,29 @@ function filterRulesById(rules: Rule[], ids: string[]): Rule[] { return rules.filter((rule) => ids.includes(rule.id)); } -function convertRulesToTableItems( - rules: Rule[], - ruleTypeIndex: RuleTypeIndex, - canExecuteActions: boolean -) { - return rules.map((rule, index: number) => ({ - ...rule, - index, - actionsCount: rule.actions.length, - ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, - isEditable: - hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && - (canExecuteActions || (!canExecuteActions && !rule.actions.length)), - enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, - })); +interface ConvertRulesToTableItemsOpts { + rules: Rule[]; + ruleTypeIndex: RuleTypeIndex; + canExecuteActions: boolean; + config: TriggersActionsUiConfig; +} + +function convertRulesToTableItems(opts: ConvertRulesToTableItemsOpts): RuleTableItem[] { + const { rules, ruleTypeIndex, canExecuteActions, config } = opts; + const minimumDuration = config.minimumScheduleInterval + ? parseDuration(config.minimumScheduleInterval.value) + : 0; + return rules.map((rule, index: number) => { + return { + ...rule, + index, + actionsCount: rule.actions.length, + ruleType: ruleTypeIndex.get(rule.ruleTypeId)?.name ?? rule.ruleTypeId, + isEditable: + hasAllPrivilege(rule, ruleTypeIndex.get(rule.ruleTypeId)) && + (canExecuteActions || (!canExecuteActions && !rule.actions.length)), + enabledInLicense: !!ruleTypeIndex.get(rule.ruleTypeId)?.enabledInLicense, + showIntervalWarning: parseDuration(rule.schedule.interval) < minimumDuration, + }; + }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts index aa0321ef8346b..fe9f921fc7f88 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/config_api.ts @@ -7,7 +7,12 @@ import { HttpSetup } from 'kibana/public'; import { BASE_TRIGGERS_ACTIONS_UI_API_PATH } from '../../../common'; +import { TriggersActionsUiConfig } from '../../types'; -export async function triggersActionsUiConfig({ http }: { http: HttpSetup }): Promise { +export async function triggersActionsUiConfig({ + http, +}: { + http: HttpSetup; +}): Promise { return await http.get(`${BASE_TRIGGERS_ACTIONS_UI_API_PATH}/_config`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0835ef2b7453e..7a1efaed33abf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -251,6 +251,7 @@ export interface RuleTableItem extends Rule { actionsCount: number; isEditable: boolean; enabledInLicense: boolean; + showIntervalWarning?: boolean; } export interface RuleTypeParamsExpressionProps< diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts index 0f6e99ccf27f3..14f169d778ebe 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts_list.ts @@ -30,7 +30,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('rulesTab'); } - describe('alerts list', function () { + describe('rules list', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -390,6 +390,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + it('should render interval info icon when schedule interval is less than configured minimum', async () => { + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'b', schedule: { interval: '1s' } }, + }); + await createAlert({ + supertest, + objectRemover, + overwrites: { name: 'c' }, + }); + await refreshAlertsList(); + + await testSubjects.existOrFail('ruleInterval-config-icon-0'); + await testSubjects.missingOrFail('ruleInterval-config-icon-1'); + + // open edit flyout when icon is clicked + const infoIcon = await testSubjects.find('ruleInterval-config-icon-0'); + await infoIcon.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should delete all selection', async () => { const namePrefix = generateUniqueKey(); const createdAlert = await createAlertManualCleanup({ diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index b280e9a3e78c5..74595e812f42a 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -91,6 +91,28 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } + async function createRuleWithSmallInterval( + testRunUuid: string, + params: Record = {} + ) { + const connectors = await createConnectors(testRunUuid); + return await createAlwaysFiringRule({ + name: `test-rule-${testRunUuid}`, + schedule: { + interval: '1s', + }, + actions: connectors.map((connector) => ({ + id: connector.id, + group: 'default', + params: { + message: 'from alert 1s', + level: 'warn', + }, + })), + params, + }); + } + async function getAlertSummary(ruleId: string) { const { body: summary } = await supertest .get(`/internal/alerting/rule/${encodeURIComponent(ruleId)}/_alert_summary`) @@ -116,7 +138,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const testRunUuid = uuid.v4(); before(async () => { await pageObjects.common.navigateToApp('triggersActions'); - const rule = await createRuleWithActionsAndParams(testRunUuid); + const rule = await createRuleWithSmallInterval(testRunUuid); // refresh to see rule await browser.refresh(); @@ -145,6 +167,15 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(connectorType).to.be(`Slack`); }); + it('renders toast when schedule is less than configured minimum', async () => { + await testSubjects.existOrFail('intervalConfigToast'); + + const editButton = await testSubjects.find('ruleIntervalToastEditButton'); + await editButton.click(); + + await testSubjects.click('cancelSaveEditedRuleButton'); + }); + it('should disable the rule', async () => { const enableSwitch = await testSubjects.find('enableSwitch'); diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index e906e239a8892..b2b6735a99c8b 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -67,7 +67,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, - `--xpack.alerting.rules.minimumScheduleInterval.value="1s"`, + `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.preconfiguredAlertHistoryEsIndex=false`, `--xpack.actions.preconfigured=${JSON.stringify({ From f462be78a3e94979be528e70d19918eed2c1b35b Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Thu, 24 Mar 2022 13:53:59 +0000 Subject: [PATCH 126/132] [APM] Make size required for ES search requests (#127970) * [APM] Make size required for ES search requests * fix tests * remove size field in unpack_processor_events.ts --- .../create_es_client/create_apm_event_client/index.test.ts | 1 + .../create_es_client/create_apm_event_client/index.ts | 3 +++ .../create_apm_event_client/unpack_processor_events.test.ts | 5 ++++- x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts | 6 ++++-- .../get_is_using_transaction_events.test.ts.snap | 4 ++++ .../helpers/transactions/get_is_using_transaction_events.ts | 1 + x-pack/plugins/apm/server/lib/helpers/transactions/index.ts | 1 + x-pack/plugins/apm/server/projections/typings.ts | 3 ++- .../get_overall_latency_distribution.ts | 4 ++-- .../latency_distribution/get_percentile_threshold_value.ts | 2 +- .../services/profiling/get_service_profiling_statistics.ts | 1 + .../custom_link/__snapshots__/get_transaction.test.ts.snap | 4 ++-- .../server/routes/settings/custom_link/get_transaction.ts | 2 +- 13 files changed, 27 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index 68e29f7afcc79..d69740c51d04d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -61,6 +61,7 @@ describe('APMEventClient', () => { apm: { events: [], }, + body: { size: 0 }, }); return res.ok({ body: 'ok' }); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index fdf023e197b7c..4b8f63e33799c 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -40,6 +40,9 @@ export type APMEventESSearchRequest = Omit & { events: ProcessorEvent[]; includeLegacyData?: boolean; }; + body: { + size: number; + }; }; export type APMEventESTermsEnumRequest = Omit & { diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts index d3f0fca0bb259..3b17c656b06e3 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/unpack_processor_events.test.ts @@ -14,7 +14,10 @@ describe('unpackProcessorEvents', () => { beforeEach(() => { const request = { apm: { events: ['transaction', 'error'] }, - body: { query: { bool: { filter: [{ terms: { foo: 'bar' } }] } } }, + body: { + size: 0, + query: { bool: { filter: [{ terms: { foo: 'bar' } }] } }, + }, } as APMEventESSearchRequest; const indices = { diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 6d3789837d2d9..ae47abb01942e 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -108,7 +108,7 @@ describe('setupRequest', () => { const { apmEventClient } = await setupRequest(mockResources); await apmEventClient.search('foo', { apm: { events: [ProcessorEvent.transaction] }, - body: { foo: 'bar' }, + body: { size: 10 }, }); expect( @@ -117,7 +117,7 @@ describe('setupRequest', () => { { index: ['apm-*'], body: { - foo: 'bar', + size: 10, query: { bool: { filter: [{ terms: { 'processor.event': ['transaction'] } }], @@ -172,6 +172,7 @@ describe('with includeFrozen=false', () => { apm: { events: [], }, + body: { size: 10 }, }); const params = @@ -193,6 +194,7 @@ describe('with includeFrozen=true', () => { await apmEventClient.search('foo', { apm: { events: [] }, + body: { size: 10 }, }); const params = diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap index 56d735b5df115..06e80110b6f20 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/__snapshots__/get_is_using_transaction_events.test.ts.snap @@ -31,6 +31,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -55,6 +56,7 @@ Object { ], }, }, + "size": 1, }, "terminate_after": 1, } @@ -82,6 +84,7 @@ Array [ ], }, }, + "size": 1, }, "terminate_after": 1, }, @@ -100,6 +103,7 @@ Array [ "filter": Array [], }, }, + "size": 0, }, "terminate_after": 1, }, diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts index 12c47936374e1..a28fe1ad1ecea 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/get_is_using_transaction_events.ts @@ -64,6 +64,7 @@ async function getHasTransactions({ events: [ProcessorEvent.transaction], }, body: { + size: 0, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts index 577a7544d93ea..573cb0a3cf6b4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transactions/index.ts @@ -33,6 +33,7 @@ export async function getHasAggregatedTransactions({ events: [ProcessorEvent.metric], }, body: { + size: 1, query: { bool: { filter: [ diff --git a/x-pack/plugins/apm/server/projections/typings.ts b/x-pack/plugins/apm/server/projections/typings.ts index d252fd311b4fe..5558fba4cde2a 100644 --- a/x-pack/plugins/apm/server/projections/typings.ts +++ b/x-pack/plugins/apm/server/projections/typings.ts @@ -11,8 +11,9 @@ import { APMEventESSearchRequest } from '../lib/helpers/create_es_client/create_ export type Projection = Omit & { body: Omit< Required['body'], - 'aggs' | 'aggregations' + 'aggs' | 'aggregations' | 'size' > & { + size?: number; aggs?: { [key: string]: { terms: AggregationOptionsByType['terms'] & { field: string }; diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts index 521a846c3e1df..d8e4cf7af0bc5 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_overall_latency_distribution.ts @@ -64,7 +64,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: histogramIntervalRequestBody, + body: { size: 0, ...histogramIntervalRequestBody }, } )) as { aggregations?: { @@ -101,7 +101,7 @@ export async function getOverallLatencyDistribution( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationRangesRequestBody, + body: { size: 0, ...transactionDurationRangesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts index 3961b1a2ca603..c40834919f7f5 100644 --- a/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts +++ b/x-pack/plugins/apm/server/routes/latency_distribution/get_percentile_threshold_value.ts @@ -31,7 +31,7 @@ export async function getPercentileThresholdValue( { // TODO: add support for metrics apm: { events: [ProcessorEvent.transaction] }, - body: transactionDurationPercentilesRequestBody, + body: { size: 0, ...transactionDurationPercentilesRequestBody }, } )) as { aggregations?: { diff --git a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts index 009d974e33721..3713b4faa73d9 100644 --- a/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts +++ b/x-pack/plugins/apm/server/routes/services/profiling/get_service_profiling_statistics.ts @@ -141,6 +141,7 @@ function getProfilesWithStacks({ events: [ProcessorEvent.profile], }, body: { + size: 0, query: { bool: { filter, diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap index 921129cf2c1da..06011abc193c5 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/__snapshots__/get_transaction.test.ts.snap @@ -42,8 +42,8 @@ Object { ], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; @@ -61,8 +61,8 @@ Object { "filter": Array [], }, }, + "size": 1, }, - "size": 1, "terminate_after": 1, } `; diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts index 88d2ae9f339ac..d4e21f219f372 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link/get_transaction.ts @@ -36,8 +36,8 @@ export async function getTransaction({ apm: { events: [ProcessorEvent.transaction as const], }, - size: 1, body: { + size: 1, query: { bool: { filter: esFilters, From a743498436a863e142592cb535b43f44c448851a Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Thu, 24 Mar 2022 09:59:05 -0400 Subject: [PATCH 127/132] [RAM] Add aggs to know how many rules are snoozed (#128212) * add aggs to know how many of snoozed rule exist * simplify + update o11y * fix tests * fix jest * bring back test --- x-pack/plugins/alerting/common/alert.ts | 1 + .../server/routes/aggregate_rules.test.ts | 9 ++++++ .../alerting/server/routes/aggregate_rules.ts | 2 ++ .../server/rules_client/rules_client.ts | 22 +++++++++++++++ .../rules_client/tests/aggregate.test.ts | 28 +++++++++++++++++++ .../containers/alerts_page/alerts_page.tsx | 13 ++++++--- .../spaces_only/tests/alerting/aggregate.ts | 17 +++++++---- 7 files changed, 83 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index dd85fadb49878..1628abff7efc1 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -76,6 +76,7 @@ export interface AlertAggregations { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; + ruleSnoozedStatus: { snoozed: number }; } export interface MappedParamsProperties { diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 81fb66ef5cf55..038e923f28f0c 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -57,6 +57,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + ruleSnoozedStatus: { + snoozed: 4, + }, }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -88,6 +91,9 @@ describe('aggregateRulesRoute', () => { "muted": 2, "unmuted": 39, }, + "rule_snoozed_status": Object { + "snoozed": 4, + }, }, } `); @@ -120,6 +126,9 @@ describe('aggregateRulesRoute', () => { muted: 2, unmuted: 39, }, + rule_snoozed_status: { + snoozed: 4, + }, }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index ee05897848ecf..8c44f57b83789 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -49,12 +49,14 @@ const rewriteBodyRes: RewriteResponseCase = ({ alertExecutionStatus, ruleEnabledStatus, ruleMutedStatus, + ruleSnoozedStatus, ...rest }) => ({ ...rest, rule_execution_status: alertExecutionStatus, rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, + rule_snoozed_status: ruleSnoozedStatus, }); export const aggregateRulesRoute = ( diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 02901ca3fdc70..5f5baf41affae 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -125,6 +125,13 @@ export interface RuleAggregation { doc_count: number; }>; }; + snoozed: { + buckets: Array<{ + key: number; + key_as_string: string; + doc_count: number; + }>; + }; } export interface ConstructorOptions { @@ -191,6 +198,7 @@ export interface AggregateResult { alertExecutionStatus: { [status: string]: number }; ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; + ruleSnoozedStatus?: { snoozed: number }; } export interface FindResult { @@ -859,6 +867,7 @@ export class RulesClient { ); throw error; } + const { filter: authorizationFilter } = authorizationTuple; const resp = await this.unsecuredSavedObjectsClient.find({ ...options, @@ -879,6 +888,13 @@ export class RulesClient { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }); @@ -894,6 +910,7 @@ export class RulesClient { muted: 0, unmuted: 0, }, + ruleSnoozedStatus: { snoozed: 0 }, }; for (const key of RuleExecutionStatusValues) { @@ -935,6 +952,11 @@ export class RulesClient { unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0, }; + const snoozedBuckets = resp.aggregations.snoozed.buckets; + ret.ruleSnoozedStatus = { + snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0), + }; + return ret; } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index aa910f4203f46..af27decb73a2a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -101,6 +101,17 @@ describe('aggregate()', () => { { key: 1, key_as_string: '1', doc_count: 3 }, ], }, + snoozed: { + buckets: [ + { + key: '2022-03-21T20:22:01.501Z-*', + format: 'strict_date_time', + from: 1.647894121501e12, + from_as_string: '2022-03-21T20:22:01.501Z', + doc_count: 2, + }, + ], + }, }, }); @@ -146,6 +157,9 @@ describe('aggregate()', () => { "muted": 3, "unmuted": 27, }, + "ruleSnoozedStatus": Object { + "snoozed": 2, + }, } `); expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -166,6 +180,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); @@ -193,6 +214,13 @@ describe('aggregate()', () => { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + snoozed: { + date_range: { + field: 'alert.attributes.snoozeEndTime', + format: 'strict_date_time', + ranges: [{ from: 'now' }], + }, + }, }, }, ]); diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index cf6ae92d1b9c8..939223feb87c0 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -44,6 +44,7 @@ interface RuleStatsState { disabled: number; muted: number; error: number; + snoozed: number; } export interface TopAlert { @@ -90,6 +91,7 @@ function AlertsPage() { disabled: 0, muted: 0, error: 0, + snoozed: 0, }); useEffect(() => { @@ -111,18 +113,21 @@ function AlertsPage() { const response = await loadRuleAggregations({ http, }); - const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus } = response; - if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus) { + const { ruleExecutionStatus, ruleMutedStatus, ruleEnabledStatus, ruleSnoozedStatus } = + response; + if (ruleExecutionStatus && ruleMutedStatus && ruleEnabledStatus && ruleSnoozedStatus) { const total = Object.values(ruleExecutionStatus).reduce((acc, value) => acc + value, 0); const { disabled } = ruleEnabledStatus; const { muted } = ruleMutedStatus; const { error } = ruleExecutionStatus; + const { snoozed } = ruleSnoozedStatus; setRuleStats({ ...ruleStats, total, disabled, muted, error, + snoozed, }); } setRuleStatsLoading(false); @@ -263,9 +268,9 @@ function AlertsPage() { data-test-subj="statDisabled" />, Date: Thu, 24 Mar 2022 15:22:00 +0100 Subject: [PATCH 128/132] [IM] Remove `axios` dependency in tests (#128171) --- .../helpers/http_requests.ts | 249 ++++++++---------- .../helpers/setup_environment.tsx | 20 +- .../home/data_streams_tab.helpers.ts | 8 +- .../home/data_streams_tab.test.ts | 89 +++---- .../client_integration/home/home.helpers.ts | 9 +- .../client_integration/home/home.test.ts | 8 +- .../home/index_templates_tab.helpers.ts | 9 +- .../home/index_templates_tab.test.ts | 74 +++--- .../home/indices_tab.helpers.ts | 8 +- .../home/indices_tab.test.ts | 142 +++++++--- .../template_clone.helpers.ts | 12 +- .../template_clone.test.tsx | 34 +-- .../template_create.helpers.ts | 7 +- .../template_create.test.tsx | 81 +++--- .../template_edit.helpers.ts | 10 +- .../template_edit.test.tsx | 150 +++++------ .../template_form.helpers.ts | 2 +- .../component_template_create.test.tsx | 50 ++-- .../component_template_details.test.ts | 33 ++- .../component_template_edit.test.tsx | 37 +-- .../component_template_list.test.ts | 34 +-- .../component_template_create.helpers.ts | 9 +- .../component_template_details.helpers.ts | 5 +- .../component_template_edit.helpers.ts | 9 +- .../component_template_list.helpers.ts | 9 +- .../helpers/http_requests.ts | 118 +++++---- .../helpers/setup_environment.tsx | 22 +- 27 files changed, 638 insertions(+), 600 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 64b8b79d4b2a1..4726286319e52 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -5,10 +5,11 @@ * 2.0. */ -import sinon, { SinonFakeServer } from 'sinon'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; import { API_BASE_PATH } from '../../../common/constants'; type HttpResponse = Record | any[]; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; export interface ResponseError { statusCode: number; @@ -17,139 +18,105 @@ export interface ResponseError { } // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadTemplatesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/index_templates`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadIndicesResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/indices`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setReloadIndicesResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/indices/reload`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadDataStreamsResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/data_streams`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadDataStreamResponse = (response: HttpResponse = []) => { - server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setDeleteDataStreamResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setDeleteTemplateResponse = (response: HttpResponse = []) => { - server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); - }; - - const setLoadTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/index_templates/:id`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setCreateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.body.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('POST', `${API_BASE_PATH}/index_templates`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setUpdateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('PUT', `${API_BASE_PATH}/index_templates/:name`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setUpdateIndexSettingsResponse = (response?: HttpResponse, error?: ResponseError) => { - const status = error ? error.statusCode || 400 : 200; - const body = error ?? response; - - server.respondWith('PUT', `${API_BASE_PATH}/settings/:name`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); - - server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; - - const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; - - const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: any) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; - - server.respondWith('GET', `${API_BASE_PATH}/nodes/plugins`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); + + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); + }; + + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); + + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; + + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); + }; + + const setLoadTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/index_templates`, response, error); + + const setLoadIndicesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/indices`, response, error); + + const setReloadIndicesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/indices/reload`, response, error); + + const setLoadDataStreamsResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/data_streams`, response, error); + + const setLoadDataStreamResponse = ( + dataStreamId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse( + 'GET', + `${API_BASE_PATH}/data_streams/${encodeURIComponent(dataStreamId)}`, + response, + error + ); + + const setDeleteDataStreamResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/delete_data_streams`, response, error); + + const setDeleteTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/delete_index_templates`, response, error); + + const setLoadTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/index_templates/${templateId}`, response, error); + + const setCreateTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/index_templates`, response, error); + + const setUpdateTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('PUT', `${API_BASE_PATH}/index_templates/${templateId}`, response, error); + + const setUpdateIndexSettingsResponse = ( + indexName: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('PUT', `${API_BASE_PATH}/settings/${indexName}`, response, error); + + const setSimulateTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/index_templates/simulate`, response, error); + + const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error); + + const setLoadNodesPluginsResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/nodes/plugins`, response, error); + + const setLoadTelemetryResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', '/api/ui_counters/_report', response, error); return { setLoadTemplatesResponse, @@ -166,22 +133,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setSimulateTemplateResponse, setLoadComponentTemplatesResponse, setLoadNodesPluginsResponse, + setLoadTelemetryResponse, }; }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultSinonMockServerResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup); return { - server, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index 1682431900a84..c5b077ef00333 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,11 +6,10 @@ */ import React from 'react'; -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { merge } from 'lodash'; import SemVer from 'semver/classes/semver'; +import { HttpSetup } from 'src/core/public'; import { notificationServiceMock, docLinksServiceMock, @@ -36,7 +35,6 @@ import { import { componentTemplatesMockDependencies } from '../../../public/application/components/component_templates/__jest__'; import { init as initHttpRequests } from './http_requests'; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; export const services = { @@ -64,30 +62,24 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ }); export const setupEnvironment = () => { - // Mock initialization of services - // @ts-ignore - httpService.setup(mockHttpClient); breadcrumbService.setup(() => undefined); documentationService.setup(docLinksServiceMock.createStartContract()); notificationService.setup(notificationServiceMock.createSetupContract()); - const { server, httpRequestsMockHelpers } = initHttpRequests(); - - return { - server, - httpRequestsMockHelpers, - }; + return initHttpRequests(); }; export const WithAppDependencies = - (Comp: any, overridingDependencies: any = {}) => + (Comp: any, httpSetup: HttpSetup, overridingDependencies: any = {}) => (props: any) => { + httpService.setup(httpSetup); const mergedDependencies = merge({}, appDependencies, overridingDependencies); + return ( - + diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index e3295a8f4fb18..9eeab1d3ca78b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -15,6 +15,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { DataStream } from '../../../common'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; @@ -46,7 +47,10 @@ export interface DataStreamsTabTestBed extends TestBed { findDetailPanelIndexTemplateLink: () => ReactWrapper; } -export const setup = async (overridingDependencies: any = {}): Promise => { +export const setup = async ( + httpSetup: HttpSetup, + overridingDependencies: any = {} +): Promise => { const testBedConfig: AsyncTestBedConfig = { store: () => indexManagementStore(services as any), memoryRouter: { @@ -57,7 +61,7 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; - afterAll(() => { - server.restore(); - }); - describe('when there are no data streams', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); @@ -53,7 +49,7 @@ describe('Data Streams tab', () => { }); test('displays an empty prompt', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { url: urlServiceMock, }); @@ -69,7 +65,7 @@ describe('Data Streams tab', () => { }); test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: {}, url: urlServiceMock, }); @@ -89,7 +85,7 @@ describe('Data Streams tab', () => { }); test('when Fleet is enabled, links to Fleet', async () => { - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: { isFleetEnabled: true }, url: urlServiceMock, }); @@ -112,7 +108,7 @@ describe('Data Streams tab', () => { }); httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { plugins: {}, url: urlServiceMock, }); @@ -156,13 +152,13 @@ describe('Data Streams tab', () => { }), ]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' }); setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] }); - setLoadTemplateResponse(indexTemplate); + setLoadTemplateResponse(indexTemplate.name, indexTemplate); - testBed = await setup({ history: createMemoryHistory() }); + testBed = await setup(httpSetup, { history: createMemoryHistory() }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -181,7 +177,6 @@ describe('Data Streams tab', () => { test('has a button to reload the data streams', async () => { const { exists, actions } = testBed; - const totalRequests = server.requests.length; expect(exists('reloadButton')).toBe(true); @@ -189,13 +184,14 @@ describe('Data Streams tab', () => { actions.clickReloadButton(); }); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams`, + expect.anything() + ); }); test('has a switch that will reload the data streams with additional stats when clicked', async () => { const { exists, actions, table, component } = testBed; - const totalRequests = server.requests.length; expect(exists('includeStatsSwitch')).toBe(true); @@ -205,9 +201,10 @@ describe('Data Streams tab', () => { }); component.update(); - // A request is sent, but sinon isn't capturing the query parameters for some reason. - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}/data_streams`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/data_streams`, + expect.anything() + ); // The table renders with the stats columns though. const { tableCellsValues } = table.getMetaData('dataStreamTable'); @@ -279,19 +276,17 @@ describe('Data Streams tab', () => { await clickConfirmDelete(); - const { method, url, requestBody } = server.requests[server.requests.length - 1]; - - expect(method).toBe('POST'); - expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); - expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ - dataStreams: ['dataStream1'], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_data_streams`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) + ); }); }); describe('detail panel', () => { test('opens when the data stream name in the table is clicked', async () => { const { actions, findDetailPanel, findDetailPanelTitle } = testBed; + httpRequestsMockHelpers.setLoadDataStreamResponse('dataStream1'); await actions.clickNameAt(0); expect(findDetailPanel().length).toBe(1); expect(findDetailPanelTitle()).toBe('dataStream1'); @@ -315,13 +310,10 @@ describe('Data Streams tab', () => { await clickConfirmDelete(); - const { method, url, requestBody } = server.requests[server.requests.length - 1]; - - expect(method).toBe('POST'); - expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`); - expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({ - dataStreams: ['dataStream1'], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_data_streams`, + expect.objectContaining({ body: JSON.stringify({ dataStreams: ['dataStream1'] }) }) + ); }); test('clicking index template name navigates to the index template details', async () => { @@ -358,9 +350,9 @@ describe('Data Streams tab', () => { const dataStreamPercentSign = createDataStreamPayload({ name: '%dataStream' }); setLoadDataStreamsResponse([dataStreamPercentSign]); - setLoadDataStreamResponse(dataStreamPercentSign); + setLoadDataStreamResponse(dataStreamPercentSign.name, dataStreamPercentSign); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -396,10 +388,11 @@ describe('Data Streams tab', () => { name: 'dataStream1', ilmPolicyName: 'my_ilm_policy', }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -417,10 +410,11 @@ describe('Data Streams tab', () => { const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; const dataStreamForDetailPanel = createDataStreamPayload({ name: 'dataStream1' }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -442,10 +436,11 @@ describe('Data Streams tab', () => { name: 'dataStream1', ilmPolicyName: 'my_ilm_policy', }); + setLoadDataStreamsResponse([dataStreamForDetailPanel]); - setLoadDataStreamResponse(dataStreamForDetailPanel); + setLoadDataStreamResponse(dataStreamForDetailPanel.name, dataStreamForDetailPanel); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: { locators: { @@ -476,9 +471,10 @@ describe('Data Streams tab', () => { }, }); const nonManagedDataStream = createDataStreamPayload({ name: 'non-managed-data-stream' }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([managedDataStream, nonManagedDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -520,9 +516,10 @@ describe('Data Streams tab', () => { name: 'hidden-data-stream', hidden: true, }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock, }); @@ -561,7 +558,7 @@ describe('Data Streams tab', () => { beforeEach(async () => { setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); - testBed = await setup({ history: createMemoryHistory(), url: urlServiceMock }); + testBed = await setup(httpSetup, { history: createMemoryHistory(), url: urlServiceMock }); await act(async () => { testBed.actions.goToDataStreamsList(); }); @@ -599,7 +596,7 @@ describe('Data Streams tab', () => { actions: { clickNameAt }, find, } = testBed; - setLoadDataStreamResponse(dataStreamWithDelete); + setLoadDataStreamResponse(dataStreamWithDelete.name, dataStreamWithDelete); await clickNameAt(1); expect(find('deleteDataStreamButton').exists()).toBeTruthy(); @@ -610,7 +607,7 @@ describe('Data Streams tab', () => { actions: { clickNameAt }, find, } = testBed; - setLoadDataStreamResponse(dataStreamNoDelete); + setLoadDataStreamResponse(dataStreamNoDelete.name, dataStreamNoDelete); await clickNameAt(0); expect(find('deleteDataStreamButton').exists()).toBeFalsy(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts index 46287fcdcf074..b73985dc8372b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -19,8 +20,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig); - export interface HomeTestBed extends TestBed { actions: { selectHomeTab: (tab: 'indicesTab' | 'templatesTab') => void; @@ -28,7 +27,11 @@ export interface HomeTestBed extends TestBed { }; } -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(IndexManagementHome, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); const { find } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts index 60d4b7d3f2317..c3f8a5b17068d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/home.test.ts @@ -20,18 +20,14 @@ import { stubWebWorker } from '@kbn/test-jest-helpers'; stubWebWorker(); describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: HomeTestBed; - afterAll(() => { - server.restore(); - }); - describe('on component mount', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(); + testBed = await setup(httpSetup); await act(async () => { const { component } = testBed; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index 69dcabc287d6b..a16ba0768e675 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -13,6 +13,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateList } from '../../../public/application/sections/home/template_list'; import { TemplateDeserialized } from '../../../common'; import { WithAppDependencies, TestSubjects } from '../helpers'; @@ -25,8 +26,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateList), testBedConfig); - const createActions = (testBed: TestBed) => { /** * Additional helpers @@ -132,7 +131,11 @@ const createActions = (testBed: TestBed) => { }; }; -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateList, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index bf1a78e3cfe90..3d1360d620ff5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -24,19 +24,15 @@ const removeWhiteSpaceOnArrayValues = (array: any[]) => }); describe('Index Templates tab', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: IndexTemplatesTabTestBed; - afterAll(() => { - server.restore(); - }); - describe('when there are no index templates of either kind', () => { test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { exists, component } = testBed; component.update(); @@ -54,7 +50,7 @@ describe('Index Templates tab', () => { }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { exists, component } = testBed; component.update(); @@ -68,7 +64,8 @@ describe('Index Templates tab', () => { describe('when there are index templates', () => { // Add a default loadIndexTemplate response - httpRequestsMockHelpers.setLoadTemplateResponse(fixtures.getTemplate()); + const templateMock = fixtures.getTemplate(); + httpRequestsMockHelpers.setLoadTemplateResponse(templateMock.name, templateMock); const template1 = fixtures.getTemplate({ name: `a${getRandomString()}`, @@ -132,7 +129,7 @@ describe('Index Templates tab', () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates, legacyTemplates }); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -194,7 +191,6 @@ describe('Index Templates tab', () => { test('should have a button to reload the index templates', async () => { const { exists, actions } = testBed; - const totalRequests = server.requests.length; expect(exists('reloadButton')).toBe(true); @@ -202,9 +198,9 @@ describe('Index Templates tab', () => { actions.clickReloadButton(); }); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/index_templates` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.anything() ); }); @@ -235,6 +231,7 @@ describe('Index Templates tab', () => { const { find, exists, actions, component } = testBed; // Composable templates + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); expect(exists('templateList')).toBe(true); expect(exists('templateDetails')).toBe(true); @@ -246,6 +243,7 @@ describe('Index Templates tab', () => { }); component.update(); + httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplates[0].name, legacyTemplates[0]); await actions.clickTemplateAt(0, true); expect(exists('templateList')).toBe(true); @@ -380,13 +378,14 @@ describe('Index Templates tab', () => { confirmButton!.click(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - templates: [{ name: templates[0].name, isLegacy }], - }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + templates: [{ name: templates[0].name, isLegacy }], + }), + }) + ); }); }); @@ -442,16 +441,14 @@ describe('Index Templates tab', () => { confirmButton!.click(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - expect(latestRequest.method).toBe('POST'); - expect(latestRequest.url).toBe(`${API_BASE_PATH}/delete_index_templates`); - - // Commenting as I don't find a way to make it work. - // It keeps on returning the composable template instead of the legacy one - // expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual({ - // templates: [{ name: templateName, isLegacy }], - // }); + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/delete_index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + templates: [{ name: templates[0].name, isLegacy: false }], + }), + }) + ); }); }); @@ -463,7 +460,7 @@ describe('Index Templates tab', () => { isLegacy: true, }); - httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setLoadTemplateResponse(template.name, template); }); test('should show details when clicking on a template', async () => { @@ -471,6 +468,7 @@ describe('Index Templates tab', () => { expect(exists('templateDetails')).toBe(false); + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); expect(exists('templateDetails')).toBe(true); @@ -480,6 +478,7 @@ describe('Index Templates tab', () => { beforeEach(async () => { const { actions } = testBed; + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, templates[0]); await actions.clickTemplateAt(0); }); @@ -544,7 +543,7 @@ describe('Index Templates tab', () => { const { find, actions, exists } = testBed; - httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, template); httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' }); await actions.clickTemplateAt(0); @@ -598,8 +597,10 @@ describe('Index Templates tab', () => { const { actions, find, exists } = testBed; - httpRequestsMockHelpers.setLoadTemplateResponse(templateWithNoOptionalFields); - + httpRequestsMockHelpers.setLoadTemplateResponse( + templates[0].name, + templateWithNoOptionalFields + ); await actions.clickTemplateAt(0); expect(find('templateDetails.tab').length).toBe(5); @@ -621,13 +622,12 @@ describe('Index Templates tab', () => { it('should render an error message if error fetching template details', async () => { const { actions, exists } = testBed; const error = { - status: 404, + statusCode: 404, error: 'Not found', message: 'Template not found', }; - httpRequestsMockHelpers.setLoadTemplateResponse(undefined, { body: error }); - + httpRequestsMockHelpers.setLoadTemplateResponse(templates[0].name, undefined, error); await actions.clickTemplateAt(0); expect(exists('sectionError')).toBe(true); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts index 7daa3cc9e2221..5feb7840f259c 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.helpers.ts @@ -14,6 +14,7 @@ import { AsyncTestBedConfig, findTestSubject, } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { IndexManagementHome } from '../../../public/application/sections/home'; import { indexManagementStore } from '../../../public/application/store'; import { WithAppDependencies, services, TestSubjects } from '../helpers'; @@ -42,9 +43,12 @@ export interface IndicesTestBed extends TestBed { findDataStreamDetailPanelTitle: () => string; } -export const setup = async (overridingDependencies: any = {}): Promise => { +export const setup = async ( + httpSetup: HttpSetup, + overridingDependencies: any = {} +): Promise => { const initTestBed = registerTestBed( - WithAppDependencies(IndexManagementHome, overridingDependencies), + WithAppDependencies(IndexManagementHome, httpSetup, overridingDependencies), testBedConfig ); const testBed = await initTestBed(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts index 8193d48629f6f..541f2b587b69f 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/indices_tab.test.ts @@ -49,22 +49,20 @@ stubWebWorker(); describe('', () => { let testBed: IndicesTestBed; - let server: ReturnType['server']; + let httpSetup: ReturnType['httpSetup']; let httpRequestsMockHelpers: ReturnType['httpRequestsMockHelpers']; beforeEach(() => { - ({ server, httpRequestsMockHelpers } = setupEnvironment()); - }); - - afterAll(() => { - server.restore(); + const mockEnvironment = setupEnvironment(); + httpRequestsMockHelpers = mockEnvironment.httpRequestsMockHelpers; + httpSetup = mockEnvironment.httpSetup; }); describe('on component mount', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([]); - testBed = await setup(); + testBed = await setup(httpSetup); await act(async () => { const { component } = testBed; @@ -118,10 +116,11 @@ describe('', () => { httpRequestsMockHelpers.setLoadDataStreamsResponse([]); httpRequestsMockHelpers.setLoadDataStreamResponse( + 'dataStream1', createDataStreamPayload({ name: 'dataStream1' }) ); - testBed = await setup({ + testBed = await setup(httpSetup, { history: createMemoryHistory(), }); @@ -162,7 +161,7 @@ describe('', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find } = testBed; component.update(); @@ -174,32 +173,36 @@ describe('', () => { const { actions } = testBed; await actions.selectIndexDetailsTab('settings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when loading mappings in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('mappings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/mapping/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when loading stats in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('stats'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/stats/${encodeURIComponent(indexName)}` + ); }); test('should encode indexName when editing settings in detail panel', async () => { const { actions } = testBed; await actions.selectIndexDetailsTab('edit_settings'); - const latestRequest = server.requests[server.requests.length - 1]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}`); + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/settings/${encodeURIComponent(indexName)}` + ); }); }); @@ -222,7 +225,7 @@ describe('', () => { ]); httpRequestsMockHelpers.setReloadIndicesResponse({ indexNames: [indexNameA, indexNameB] }); - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find } = testBed; component.update(); @@ -236,8 +239,14 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('refreshIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/refresh`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/refresh`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to close an open index', async () => { @@ -246,13 +255,20 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('closeIndexMenuButton'); - // A refresh call was added after closing an index so we need to check the second to last request. - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/close`); + // After the index is closed, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/close`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to open a closed index', async () => { - testBed = await setup(); + testBed = await setup(httpSetup); const { component, find, actions } = testBed; component.update(); @@ -262,9 +278,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('openIndexMenuButton'); - // A refresh call was added after closing an index so we need to check the second to last request. - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/open`); + // After the index is opened, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/open`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to flush index', async () => { @@ -273,11 +296,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('flushIndexMenuButton'); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/flush`); - // After the indices are flushed, we imediately reload them. So we need to expect to see + // After the index is flushed, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/flush`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test("should be able to clear an index's cache", async () => { @@ -287,8 +315,16 @@ describe('', () => { await actions.clickManageContextMenuButton(); await actions.clickContextMenuOption('clearCacheIndexMenuButton'); - const latestRequest = server.requests[server.requests.length - 2]; - expect(latestRequest.url).toBe(`${API_BASE_PATH}/indices/clear_cache`); + // After the index cache is cleared, we imediately do a reload. So we need to expect to see + // a reload server call also. + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/clear_cache`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); test('should be able to unfreeze a frozen index', async () => { @@ -302,11 +338,17 @@ describe('', () => { expect(exists('unfreezeIndexMenuButton')).toBe(true); await actions.clickContextMenuOption('unfreezeIndexMenuButton'); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/unfreeze`); // After the index is unfrozen, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/unfreeze`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); + // Open context menu once again, since clicking an action will close it. await actions.clickManageContextMenuButton(); // The unfreeze action should not be present anymore @@ -326,15 +368,33 @@ describe('', () => { await actions.clickModalConfirm(); - const requestsCount = server.requests.length; - expect(server.requests[requestsCount - 2].url).toBe(`${API_BASE_PATH}/indices/forcemerge`); - // After the index is force merged, we immediately do a reload. So we need to expect to see + // After the index force merged, we imediately do a reload. So we need to expect to see // a reload server call also. - expect(server.requests[requestsCount - 1].url).toBe(`${API_BASE_PATH}/indices/reload`); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/forcemerge`, + expect.anything() + ); + expect(httpSetup.post).toHaveBeenCalledWith( + `${API_BASE_PATH}/indices/reload`, + expect.anything() + ); }); }); describe('Edit index settings', () => { + const indexName = 'test'; + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadIndicesResponse([createNonDataStreamIndex(indexName)]); + + testBed = await setup(httpSetup); + const { component, find } = testBed; + + component.update(); + + find('indexTableIndexNameLink').at(0).simulate('click'); + }); + test('shows error callout when request fails', async () => { const { actions, find, component, exists } = testBed; @@ -347,7 +407,7 @@ describe('', () => { error: 'Bad Request', message: 'invalid tier names found in ...', }; - httpRequestsMockHelpers.setUpdateIndexSettingsResponse(undefined, error); + httpRequestsMockHelpers.setUpdateIndexSettingsResponse(indexName, undefined, error); await actions.selectIndexDetailsTab('edit_settings'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts index 9aec6cae7a17e..2ee82c2b4c418 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.helpers.ts @@ -6,10 +6,11 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateClone } from '../../../public/application/sections/template_clone'; import { WithAppDependencies } from '../helpers'; -import { formSetup } from './template_form.helpers'; +import { formSetup, TestSubjects } from './template_form.helpers'; import { TEMPLATE_NAME } from './constants'; const testBedConfig: AsyncTestBedConfig = { @@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateClone), testBedConfig); +export const setup = async (httpSetup: HttpSetup) => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateClone, httpSetup), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup(initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx index 31e65625cfdd0..861b1041a4f14 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { getComposableTemplate } from '../../../test/fixtures'; import { setupEnvironment } from '../helpers'; @@ -44,23 +45,22 @@ const templateToClone = getComposableTemplate({ describe('', () => { let testBed: TemplateFormTestBed; - - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); + httpRequestsMockHelpers.setLoadTelemetryResponse({}); httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); - httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone); + httpRequestsMockHelpers.setLoadTemplateResponse(templateToClone.name, templateToClone); }); afterAll(() => { - server.restore(); jest.useRealTimers(); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -98,17 +98,19 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - ...templateToClone, - name: `${templateToClone.name}-copy`, - indexPatterns: DEFAULT_INDEX_PATTERNS, - }; - - delete expected.template; // As no settings, mappings or aliases have been defined, no "template" param is sent - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + const { priority, version, _kbnMeta } = templateToClone; + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: `${templateToClone.name}-copy`, + indexPatterns: DEFAULT_INDEX_PATTERNS, + priority, + version, + _kbnMeta, + }), + }) + ); }); }); }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts index b039fa83000ed..e57e89a6762c2 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts @@ -6,12 +6,13 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateCreate } from '../../../public/application/sections/template_create'; import { WithAppDependencies } from '../helpers'; import { formSetup, TestSubjects } from './template_form.helpers'; -export const setup: any = (isLegacy: boolean = false) => { +export const setup = async (httpSetup: HttpSetup, isLegacy: boolean = false) => { const route = isLegacy ? { pathname: '/create_template', search: '?legacy=true' } : { pathname: '/create_template' }; @@ -25,9 +26,9 @@ export const setup: any = (isLegacy: boolean = false) => { }; const initTestBed = registerTestBed( - WithAppDependencies(TemplateCreate), + WithAppDependencies(TemplateCreate, httpSetup), testBedConfig ); - return formSetup.call(null, initTestBed); + return formSetup(initTestBed); }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 65d3678735689..078a171ac6a75 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment } from '../helpers'; import { @@ -76,7 +77,7 @@ const componentTemplates = [componentTemplate1, componentTemplate2]; describe('', () => { let testBed: TemplateFormTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -89,7 +90,6 @@ describe('', () => { }); afterAll(() => { - server.restore(); jest.useRealTimers(); (window as any)['__react-beautiful-dnd-disable-dev-warnings'] = false; }); @@ -97,7 +97,7 @@ describe('', () => { describe('composable index template', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); }); @@ -130,7 +130,7 @@ describe('', () => { describe('legacy index template', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(true); + testBed = await setup(httpSetup, true); }); }); @@ -150,7 +150,7 @@ describe('', () => { describe('form validation', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -367,7 +367,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadNodesPluginsResponse(['mapper-size']); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); await navigateToMappingsStep(); @@ -415,7 +415,7 @@ describe('', () => { describe('review (step 6)', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -472,7 +472,7 @@ describe('', () => { it('should render a warning message if a wildcard is used as an index pattern', async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -505,7 +505,7 @@ describe('', () => { const MAPPING_FIELDS = [BOOLEAN_MAPPING_FIELD, TEXT_MAPPING_FIELD, KEYWORD_MAPPING_FIELD]; await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -534,49 +534,50 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: TEMPLATE_NAME, - indexPatterns: DEFAULT_INDEX_PATTERNS, - composedOf: ['test_component_template_1'], - template: { - settings: SETTINGS, - mappings: { - properties: { - [BOOLEAN_MAPPING_FIELD.name]: { - type: BOOLEAN_MAPPING_FIELD.type, - }, - [TEXT_MAPPING_FIELD.name]: { - type: TEXT_MAPPING_FIELD.type, - }, - [KEYWORD_MAPPING_FIELD.name]: { - type: KEYWORD_MAPPING_FIELD.type, + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: TEMPLATE_NAME, + indexPatterns: DEFAULT_INDEX_PATTERNS, + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: false, + }, + composedOf: ['test_component_template_1'], + template: { + settings: SETTINGS, + mappings: { + properties: { + [BOOLEAN_MAPPING_FIELD.name]: { + type: BOOLEAN_MAPPING_FIELD.type, + }, + [TEXT_MAPPING_FIELD.name]: { + type: TEXT_MAPPING_FIELD.type, + }, + [KEYWORD_MAPPING_FIELD.name]: { + type: KEYWORD_MAPPING_FIELD.type, + }, + }, }, + aliases: ALIASES, }, - }, - aliases: ALIASES, - }, - _kbnMeta: { - type: 'default', - isLegacy: false, - hasDatastream: false, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }), + }) + ); }); it('should surface the API errors from the put HTTP request', async () => { const { component, actions, find, exists } = testBed; const error = { - status: 409, + statusCode: 409, error: 'Conflict', message: `There is already a template with name '${TEMPLATE_NAME}'`, }; - httpRequestsMockHelpers.setCreateTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setCreateTemplateResponse(undefined, error); await act(async () => { actions.clickNextButton(); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts index a7f87d828eb23..97166970568d3 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { TemplateEdit } from '../../../public/application/sections/template_edit'; import { WithAppDependencies } from '../helpers'; @@ -20,6 +21,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(TemplateEdit), testBedConfig); +export const setup = async (httpSetup: HttpSetup) => { + const initTestBed = registerTestBed( + WithAppDependencies(TemplateEdit, httpSetup), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup(initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index d4680e7663322..4b94cb92c83d0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils'; import '../../../test/global_mocks'; import * as fixtures from '../../../test/fixtures'; +import { API_BASE_PATH } from '../../../common/constants'; import { setupEnvironment, kibanaVersion } from '../helpers'; import { TEMPLATE_NAME, SETTINGS, ALIASES, MAPPINGS as DEFAULT_MAPPING } from './constants'; @@ -48,7 +49,7 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: TemplateFormTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); beforeAll(() => { jest.useFakeTimers(); @@ -56,7 +57,6 @@ describe('', () => { }); afterAll(() => { - server.restore(); jest.useRealTimers(); }); @@ -71,12 +71,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -117,24 +117,25 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: 'test', - indexPatterns: ['myPattern*'], - dataStream: { - hidden: true, - anyUnknownKey: 'should_be_kept', - }, - version: 1, - _kbnMeta: { - type: 'default', - isLegacy: false, - hasDatastream: true, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/test`, + expect.objectContaining({ + body: JSON.stringify({ + name: 'test', + indexPatterns: ['myPattern*'], + version: 1, + dataStream: { + hidden: true, + anyUnknownKey: 'should_be_kept', + }, + _kbnMeta: { + type: 'default', + hasDatastream: true, + isLegacy: false, + }, + }), + }) + ); }); }); @@ -148,12 +149,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(templateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', templateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); }); @@ -225,40 +226,40 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - const { version } = templateToEdit; - - const expected = { - name: TEMPLATE_NAME, - version, - priority: 3, - indexPatterns: UPDATED_INDEX_PATTERN, - template: { - mappings: { - properties: { - [UPDATED_MAPPING_TEXT_FIELD_NAME]: { - type: 'text', - store: false, - index: true, - fielddata: false, - eager_global_ordinals: false, - index_phrases: false, - norms: true, - index_options: 'positions', + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + name: TEMPLATE_NAME, + indexPatterns: UPDATED_INDEX_PATTERN, + priority: 3, + version: templateToEdit.version, + _kbnMeta: { + type: 'default', + hasDatastream: false, + isLegacy: templateToEdit._kbnMeta.isLegacy, + }, + template: { + settings: SETTINGS, + mappings: { + properties: { + [UPDATED_MAPPING_TEXT_FIELD_NAME]: { + type: 'text', + index: true, + eager_global_ordinals: false, + index_phrases: false, + norms: true, + fielddata: false, + store: false, + index_options: 'positions', + }, + }, }, + aliases: ALIASES, }, - }, - settings: SETTINGS, - aliases: ALIASES, - }, - _kbnMeta: { - type: 'default', - isLegacy: templateToEdit._kbnMeta.isLegacy, - hasDatastream: false, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + }), + }) + ); }); }); }); @@ -277,12 +278,12 @@ describe('', () => { }); beforeAll(() => { - httpRequestsMockHelpers.setLoadTemplateResponse(legacyTemplateToEdit); + httpRequestsMockHelpers.setLoadTemplateResponse('my_template', legacyTemplateToEdit); }); beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -305,24 +306,25 @@ describe('', () => { actions.clickNextButton(); }); - const latestRequest = server.requests[server.requests.length - 1]; - const { version, template, name, indexPatterns, _kbnMeta, order } = legacyTemplateToEdit; - const expected = { - name, - indexPatterns, - version, - order, - template: { - aliases: undefined, - mappings: template!.mappings, - settings: undefined, - }, - _kbnMeta, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/index_templates/${TEMPLATE_NAME}`, + expect.objectContaining({ + body: JSON.stringify({ + name, + indexPatterns, + version, + order, + template: { + aliases: undefined, + mappings: template!.mappings, + settings: undefined, + }, + _kbnMeta, + }), + }) + ); }); }); } diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 57d0b282d351d..9a68fe41fce27 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -10,7 +10,7 @@ import { act } from 'react-dom/test-utils'; import { TestBed, SetupFunc } from '@kbn/test-jest-helpers'; import { TemplateDeserialized } from '../../../common'; -interface MappingField { +export interface MappingField { name: string; type: string; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index f3957e0cc15c9..81f43a1b46073 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils'; import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock'; import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; +import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; jest.mock('@elastic/eui', () => { @@ -34,16 +35,12 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: ComponentTemplateCreateTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); describe('On component mount', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -108,7 +105,7 @@ describe('', () => { beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); const { actions, component } = testBed; @@ -164,37 +161,38 @@ describe('', () => { component.update(); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - name: COMPONENT_TEMPLATE_NAME, - template: { - settings: SETTINGS, - mappings: { - properties: { - [BOOLEAN_MAPPING_FIELD.name]: { - type: BOOLEAN_MAPPING_FIELD.type, + expect(httpSetup.post).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates`, + expect.objectContaining({ + body: JSON.stringify({ + name: COMPONENT_TEMPLATE_NAME, + template: { + settings: SETTINGS, + mappings: { + properties: { + [BOOLEAN_MAPPING_FIELD.name]: { + type: BOOLEAN_MAPPING_FIELD.type, + }, + }, }, + aliases: ALIASES, }, - }, - aliases: ALIASES, - }, - _kbnMeta: { usedBy: [], isManaged: false }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + _kbnMeta: { usedBy: [], isManaged: false }, + }), + }) + ); }); test('should surface API errors if the request is unsuccessful', async () => { const { component, actions, find, exists } = testBed; const error = { - status: 409, + statusCode: 409, error: 'Conflict', message: `There is already a template with name '${COMPONENT_TEMPLATE_NAME}'`, }; - httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setCreateComponentTemplateResponse(undefined, error); await act(async () => { actions.clickNextButton(); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 36ea2c27ec4fe..95495af1272c3 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -32,19 +32,18 @@ const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = { }; describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: ComponentTemplateDetailsTestBed; - afterAll(() => { - server.restore(); - }); - describe('With component template details', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + COMPONENT_TEMPLATE + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, }); @@ -104,11 +103,12 @@ describe('', () => { describe('With only required component template fields', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name, COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS.name, onClose: () => {}, }); @@ -156,10 +156,13 @@ describe('', () => { describe('With actions', () => { beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + COMPONENT_TEMPLATE + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, actions: [ @@ -197,16 +200,20 @@ describe('', () => { describe('Error handling', () => { const error = { - status: 500, + statusCode: 500, error: 'Internal server error', message: 'Internal server error', }; beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(undefined, { body: error }); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE.name, + undefined, + error + ); await act(async () => { - testBed = setup({ + testBed = setup(httpSetup, { componentTemplateName: COMPONENT_TEMPLATE.name, onClose: () => {}, }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 1f4abac806276..f3b5b52fe2c41 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -10,6 +10,7 @@ import { act } from 'react-dom/test-utils'; import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; +import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; jest.mock('@elastic/eui', () => { @@ -33,11 +34,7 @@ jest.mock('@elastic/eui', () => { describe('', () => { let testBed: ComponentTemplateEditTestBed; - const { server, httpRequestsMockHelpers } = setupEnvironment(); - - afterAll(() => { - server.restore(); - }); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); const COMPONENT_TEMPLATE_NAME = 'comp-1'; const COMPONENT_TEMPLATE_TO_EDIT = { @@ -49,10 +46,13 @@ describe('', () => { }; beforeEach(async () => { - httpRequestsMockHelpers.setLoadComponentTemplateResponse(COMPONENT_TEMPLATE_TO_EDIT); + httpRequestsMockHelpers.setLoadComponentTemplateResponse( + COMPONENT_TEMPLATE_TO_EDIT.name, + COMPONENT_TEMPLATE_TO_EDIT + ); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -98,17 +98,18 @@ describe('', () => { component.update(); - const latestRequest = server.requests[server.requests.length - 1]; - - const expected = { - version: 1, - ...COMPONENT_TEMPLATE_TO_EDIT, - template: { - ...COMPONENT_TEMPLATE_TO_EDIT.template, - }, - }; - - expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); + expect(httpSetup.put).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates/${COMPONENT_TEMPLATE_TO_EDIT.name}`, + expect.objectContaining({ + body: JSON.stringify({ + ...COMPONENT_TEMPLATE_TO_EDIT, + template: { + ...COMPONENT_TEMPLATE_TO_EDIT.template, + }, + version: 1, + }), + }) + ); }); }); }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts index dee15f2ae3a45..a3e9524dcd3ca 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts @@ -16,16 +16,12 @@ import { API_BASE_PATH } from './helpers/constants'; const { setup } = pageHelpers.componentTemplateList; describe('', () => { - const { server, httpRequestsMockHelpers } = setupEnvironment(); + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); let testBed: ComponentTemplateListTestBed; - afterAll(() => { - server.restore(); - }); - beforeEach(async () => { await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -69,7 +65,6 @@ describe('', () => { test('should reload the component templates data', async () => { const { component, actions } = testBed; - const totalRequests = server.requests.length; await act(async () => { actions.clickReloadButton(); @@ -77,9 +72,9 @@ describe('', () => { component.update(); - expect(server.requests.length).toBe(totalRequests + 1); - expect(server.requests[server.requests.length - 1].url).toBe( - `${API_BASE_PATH}/component_templates` + expect(httpSetup.get).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates`, + expect.anything() ); }); @@ -103,7 +98,7 @@ describe('', () => { expect(modal).not.toBe(null); expect(modal!.textContent).toContain('Delete component template'); - httpRequestsMockHelpers.setDeleteComponentTemplateResponse({ + httpRequestsMockHelpers.setDeleteComponentTemplateResponse(componentTemplateName, { itemsDeleted: [componentTemplateName], errors: [], }); @@ -114,13 +109,10 @@ describe('', () => { component.update(); - const deleteRequest = server.requests[server.requests.length - 2]; - - expect(deleteRequest.method).toBe('DELETE'); - expect(deleteRequest.url).toBe( - `${API_BASE_PATH}/component_templates/${componentTemplateName}` + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/component_templates/${componentTemplateName}`, + expect.anything() ); - expect(deleteRequest.status).toEqual(200); }); }); @@ -129,7 +121,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadComponentTemplatesResponse([]); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); @@ -147,15 +139,15 @@ describe('', () => { describe('Error handling', () => { beforeEach(async () => { const error = { - status: 500, + statusCode: 500, error: 'Internal server error', message: 'Internal server error', }; - httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, { body: error }); + httpRequestsMockHelpers.setLoadComponentTemplatesResponse(undefined, error); await act(async () => { - testBed = await setup(); + testBed = await setup(httpSetup); }); testBed.component.update(); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts index 18b5bbfd775bb..846c921e776c3 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_create.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { BASE_PATH } from '../../../../../../../common'; import { ComponentTemplateCreate } from '../../../component_template_wizard'; @@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateCreate), testBedConfig); - -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateCreate, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts index cdf376028ff1d..18fe2b59f21c6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { WithAppDependencies } from './setup_environment'; import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details'; @@ -43,9 +44,9 @@ const createActions = (testBed: TestBed) = }; }; -export const setup = (props: any): ComponentTemplateDetailsTestBed => { +export const setup = (httpSetup: HttpSetup, props: any): ComponentTemplateDetailsTestBed => { const setupTestBed = registerTestBed( - WithAppDependencies(ComponentTemplateDetailsFlyoutContent), + WithAppDependencies(ComponentTemplateDetailsFlyoutContent, httpSetup), { memoryRouter: { wrapComponent: false, diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts index 6e0f9d55ef7f0..dfc73e0ccafb0 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_edit.helpers.ts @@ -6,6 +6,7 @@ */ import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { HttpSetup } from 'src/core/public'; import { BASE_PATH } from '../../../../../../../common'; import { ComponentTemplateEdit } from '../../../component_template_wizard'; @@ -27,9 +28,11 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateEdit), testBedConfig); - -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateEdit, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts index 2a01518e25466..3005eae0d6bf1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_list.helpers.ts @@ -6,6 +6,7 @@ */ import { act } from 'react-dom/test-utils'; +import { HttpSetup } from 'src/core/public'; import { registerTestBed, @@ -26,8 +27,6 @@ const testBedConfig: AsyncTestBedConfig = { doMountAsync: true, }; -const initTestBed = registerTestBed(WithAppDependencies(ComponentTemplateList), testBedConfig); - export type ComponentTemplateListTestBed = TestBed & { actions: ReturnType; }; @@ -74,7 +73,11 @@ const createActions = (testBed: TestBed) => { }; }; -export const setup = async (): Promise => { +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ComponentTemplateList, httpSetup), + testBedConfig + ); const testBed = await initTestBed(); return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts index 520da90c58862..025f34066908c 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/http_requests.ts @@ -5,65 +5,74 @@ * 2.0. */ -import sinon, { SinonFakeServer } from 'sinon'; -import { - ComponentTemplateListItem, - ComponentTemplateDeserialized, - ComponentTemplateSerialized, -} from '../../../shared_imports'; +import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks'; import { API_BASE_PATH } from './constants'; +type HttpResponse = Record | any[]; +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; + +export interface ResponseError { + statusCode: number; + message: string | Error; + attributes?: Record; +} + // Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { - const setLoadComponentTemplatesResponse = ( - response?: ComponentTemplateListItem[], - error?: any - ) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; +const registerHttpRequestMockHelpers = ( + httpSetup: ReturnType +) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); - server.respondWith('GET', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); }; - const setLoadComponentTemplateResponse = ( - response?: ComponentTemplateDeserialized, - error?: any - ) => { - const status = error ? error.status || 400 : 200; - const body = error ? error.body : response; + httpSetup.get.mockImplementation((path) => + mockMethodImplementation('GET', path as unknown as string) + ); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', path as unknown as string) + ); + httpSetup.post.mockImplementation((path) => + mockMethodImplementation('POST', path as unknown as string) + ); + httpSetup.put.mockImplementation((path) => + mockMethodImplementation('PUT', path as unknown as string) + ); - server.respondWith('GET', `${API_BASE_PATH}/component_templates/:name`, [ - status, - { 'Content-Type': 'application/json' }, - JSON.stringify(body), - ]); - }; + const mockResponse = (method: HttpMethod, path: string, response?: unknown, error?: unknown) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; - const setDeleteComponentTemplateResponse = (response?: object) => { - server.respondWith('DELETE', `${API_BASE_PATH}/component_templates/:name`, [ - 200, - { 'Content-Type': 'application/json' }, - JSON.stringify(response), - ]); + return mockResponses + .get(method)! + .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); }; - const setCreateComponentTemplateResponse = ( - response?: ComponentTemplateSerialized, - error?: any - ) => { - const status = error ? error.body.status || 400 : 200; - const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + const setLoadComponentTemplatesResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/component_templates`, response, error); - server.respondWith('POST', `${API_BASE_PATH}/component_templates`, [ - status, - { 'Content-Type': 'application/json' }, - body, - ]); - }; + const setLoadComponentTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => mockResponse('GET', `${API_BASE_PATH}/component_templates/${templateId}`, response, error); + + const setDeleteComponentTemplateResponse = ( + templateId: string, + response?: HttpResponse, + error?: ResponseError + ) => + mockResponse('DELETE', `${API_BASE_PATH}/component_templates/${templateId}`, response, error); + + const setCreateComponentTemplateResponse = (response?: HttpResponse, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/component_templates`, response, error); return { setLoadComponentTemplatesResponse, @@ -74,18 +83,11 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; export const init = () => { - const server = sinon.fakeServer.create(); - server.respondImmediately = true; - - // Define default response for unhandled requests. - // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, - // and we can mock them all with a 200 instead of mocking each one individually. - server.respondWith([200, {}, 'DefaultMockedResponse']); - - const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + const httpSetup = httpServiceMock.createSetupContract(); + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(httpSetup); return { - server, + httpSetup, httpRequestsMockHelpers, }; }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index d532eaaba8923..9c2017ad651f1 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,8 +6,6 @@ */ import React from 'react'; -import axios from 'axios'; -import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { HttpSetup } from 'kibana/public'; import { @@ -24,7 +22,6 @@ import { ComponentTemplatesProvider } from '../../../component_templates_context import { init as initHttpRequests } from './http_requests'; import { API_BASE_PATH } from './constants'; -const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; // We provide the minimum deps required to make the tests pass @@ -32,30 +29,23 @@ const appDependencies = { docLinks: {} as any, } as any; -export const componentTemplatesDependencies = { - httpClient: mockHttpClient as unknown as HttpSetup, +export const componentTemplatesDependencies = (httpSetup: HttpSetup) => ({ + httpClient: httpSetup, apiBasePath: API_BASE_PATH, trackMetric: () => {}, docLinks: docLinksServiceMock.createStartContract(), toasts: notificationServiceMock.createSetupContract().toasts, setBreadcrumbs: () => {}, getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp, -}; +}); -export const setupEnvironment = () => { - const { server, httpRequestsMockHelpers } = initHttpRequests(); +export const setupEnvironment = initHttpRequests; - return { - server, - httpRequestsMockHelpers, - }; -}; - -export const WithAppDependencies = (Comp: any) => (props: any) => +export const WithAppDependencies = (Comp: any, httpSetup: HttpSetup) => (props: any) => ( - + From 2e861694ef3effdfaefd2e81bd4d2659c6d09a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Thu, 24 Mar 2022 15:28:52 +0100 Subject: [PATCH 129/132] [Unified observability] Guided setup progress (#128382) * wip - add observability status progress bar * added option to dismiss guided setup * Remove extra panel * open flyout on view details button click * add some tests for status progress * fix type * Add telemetry * Not show titles when there are no boxes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../observability_status_boxes.tsx | 70 ++++++----- .../observability_status_progress.test.tsx | 63 ++++++++++ .../observability_status_progress.tsx | 118 ++++++++++++++++++ .../pages/overview/old_overview_page.tsx | 2 + 4 files changed, 222 insertions(+), 31 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx create mode 100644 x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx index 48779569131d6..2fdf0a07f4647 100644 --- a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_boxes.tsx @@ -27,39 +27,47 @@ export function ObservabilityStatusBoxes({ boxes }: ObservabilityStatusProps) { return ( - - -

- -

-
-
- {noHasDataBoxes.map((box) => ( - - - - ))} + {noHasDataBoxes.length > 0 && ( + <> + + +

+ +

+
+
+ {noHasDataBoxes.map((box) => ( + + + + ))} + + )} - {noHasDataBoxes.length > 0 && } + {noHasDataBoxes.length > 0 && hasDataBoxes.length > 0 && } - - -

- -

-
-
- {hasDataBoxes.map((box) => ( - - - - ))} + {hasDataBoxes.length > 0 && ( + <> + + +

+ +

+
+
+ {hasDataBoxes.map((box) => ( + + + + ))} + + )}
); } diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx new file mode 100644 index 0000000000000..6e79c3691402a --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { HasDataContextValue } from '../../../context/has_data_context'; +import * as hasDataHook from '../../../hooks/use_has_data'; +import { ObservabilityStatusProgress } from './observability_status_progress'; +import { I18nProvider } from '@kbn/i18n-react'; + +describe('ObservabilityStatusProgress', () => { + const onViewDetailsClickFn = jest.fn(); + + beforeEach(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasDataMap: { + apm: { hasData: true, status: 'success' }, + synthetics: { hasData: true, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: false, status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + onRefreshTimeRange: () => {}, + forceUpdate: '', + } as HasDataContextValue); + }); + it('should render the progress', () => { + render( + + + + ); + const progressBar = screen.getByRole('progressbar') as HTMLProgressElement; + expect(progressBar).toBeInTheDocument(); + expect(progressBar.value).toBe(50); + }); + + it('should call the onViewDetailsCallback when view details button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('View details')); + expect(onViewDetailsClickFn).toHaveBeenCalled(); + }); + + it('should hide the component when dismiss button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('Dismiss')); + expect(screen.queryByTestId('status-progress')).toBe(null); + }); +}); diff --git a/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx new file mode 100644 index 0000000000000..81f08537c775f --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/observability_status/observability_status_progress.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState, useEffect } from 'react'; +import { + EuiPanel, + EuiProgress, + EuiTitle, + EuiButtonEmpty, + EuiButton, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { reduce } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useHasData } from '../../../hooks/use_has_data'; +import { useUiTracker } from '../../../hooks/use_track_metric'; + +const LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY = 'HIDE_GUIDED_SETUP'; + +interface ObservabilityStatusProgressProps { + onViewDetailsClick: () => void; +} +export function ObservabilityStatusProgress({ + onViewDetailsClick, +}: ObservabilityStatusProgressProps) { + const { hasDataMap, isAllRequestsComplete } = useHasData(); + const trackMetric = useUiTracker({ app: 'observability-overview' }); + const hideGuidedSetupLocalStorageKey = window.localStorage.getItem( + LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY + ); + const [isGuidedSetupHidden, setIsGuidedSetupHidden] = useState( + JSON.parse(hideGuidedSetupLocalStorageKey || 'false') + ); + const [progress, setProgress] = useState(0); + + useEffect(() => { + const totalCounts = Object.keys(hasDataMap); + if (isAllRequestsComplete) { + const hasDataCount = reduce( + hasDataMap, + (result, value) => { + return value?.hasData ? result + 1 : result; + }, + 0 + ); + + const percentage = (hasDataCount / totalCounts.length) * 100; + setProgress(isFinite(percentage) ? percentage : 0); + } + }, [isAllRequestsComplete, hasDataMap]); + + const hideGuidedSetup = () => { + window.localStorage.setItem(LOCAL_STORAGE_HIDE_GUIDED_SETUP_KEY, 'true'); + setIsGuidedSetupHidden(true); + trackMetric({ metric: 'guided_setup_progress_dismiss' }); + }; + + const showDetails = () => { + onViewDetailsClick(); + trackMetric({ metric: 'guided_setup_progress_view_details' }); + }; + + return !isGuidedSetupHidden ? ( + <> + + + + + + +

+ +

+
+ +

+ +

+
+
+ + + + + + + + + + + + + + +
+
+ + + ) : null; +} diff --git a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx index 88c82d8c355ac..65f9def2d0f4a 100644 --- a/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx +++ b/x-pack/plugins/observability/public/pages/overview/old_overview_page.tsx @@ -45,6 +45,7 @@ import { ObservabilityAppServices } from '../../application/types'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { paths } from '../../config'; import { useDatePickerContext } from '../../hooks/use_date_picker_context'; +import { ObservabilityStatusProgress } from '../../components/app/observability_status/observability_status_progress'; import { ObservabilityStatus } from '../../components/app/observability_status'; interface Props { routeParams: RouteParams<'/overview'>; @@ -145,6 +146,7 @@ export function OverviewPage({ routeParams }: Props) { {hasData && ( <> + setIsFlyoutVisible(true)} /> Date: Thu, 24 Mar 2022 16:31:08 +0200 Subject: [PATCH 130/132] [Cloud Posture] Support csp rule asset type (#127741) Co-authored-by: Uri Weisman uri.weisman@elastic.co --- .../common/schemas/csp_rule_template.ts | 23 +++ .../cloud_security_posture/server/config.ts | 1 - .../cloud_security_posture/server/index.ts | 1 + .../cloud_security_posture/server/plugin.ts | 6 +- .../server/saved_objects/csp_rule_template.ts | 29 ++++ .../{cis_1_4_1 => }/csp_rule_type.ts | 7 +- .../{cis_1_4_1 => }/initialize_rules.ts | 4 +- .../context/fixtures/integration.nginx.ts | 1 + .../context/fixtures/integration.okta.ts | 1 + .../plugins/fleet/common/openapi/bundled.json | 3 +- .../plugins/fleet/common/openapi/bundled.yaml | 1 + .../schemas/kibana_saved_object_type.yaml | 1 + .../package_to_package_policy.test.ts | 1 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../components/assets_facet_group.stories.tsx | 1 + .../integrations/sections/epm/constants.tsx | 7 + .../services/epm/kibana/assets/install.ts | 2 + ...kage_policies_to_agent_permissions.test.ts | 4 + .../apis/epm/install_remove_assets.ts | 144 ++++++++++++++---- .../apis/epm/update_assets.ts | 5 + .../sample_csp_rule_template.json | 17 +++ .../sample_csp_rule_template.json | 17 +++ x-pack/test/fleet_api_integration/config.ts | 1 + 23 files changed, 240 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts create mode 100644 x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts rename x-pack/plugins/cloud_security_posture/server/saved_objects/{cis_1_4_1 => }/csp_rule_type.ts (90%) rename x-pack/plugins/cloud_security_posture/server/saved_objects/{cis_1_4_1 => }/initialize_rules.ts (83%) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts new file mode 100644 index 0000000000000..e6c7740f87fd3 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule_template.ts @@ -0,0 +1,23 @@ +/* + * 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 { schema as rt, TypeOf } from '@kbn/config-schema'; + +const cspRuleTemplateSchema = rt.object({ + name: rt.string(), + description: rt.string(), + rationale: rt.string(), + impact: rt.string(), + default_value: rt.string(), + remediation: rt.string(), + benchmark: rt.object({ name: rt.string(), version: rt.string() }), + severity: rt.string(), + benchmark_rule_id: rt.string(), + rego_rule_id: rt.string(), + tags: rt.arrayOf(rt.string()), +}); +export const cloudSecurityPostureRuleTemplateSavedObjectType = 'csp-rule-template'; +export type CloudSecurityPostureRuleTemplateSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/server/config.ts b/x-pack/plugins/cloud_security_posture/server/config.ts index 9c9ff926a2c38..e40adadc55e98 100644 --- a/x-pack/plugins/cloud_security_posture/server/config.ts +++ b/x-pack/plugins/cloud_security_posture/server/config.ts @@ -11,7 +11,6 @@ import type { PluginConfigDescriptor } from 'kibana/server'; const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), }); - type CloudSecurityPostureConfig = TypeOf; export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts index f790ac5256ff8..82f2872a859f7 100755 --- a/x-pack/plugins/cloud_security_posture/server/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/index.ts @@ -8,6 +8,7 @@ import type { PluginInitializerContext } from '../../../../src/core/server'; import { CspPlugin } from './plugin'; export type { CspServerPluginSetup, CspServerPluginStart } from './types'; +export type { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template'; export const plugin = (initializerContext: PluginInitializerContext) => new CspPlugin(initializerContext); diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index 2709518ffbc5f..386eb2373ad63 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -21,8 +21,9 @@ import type { CspRequestHandlerContext, } from './types'; import { defineRoutes } from './routes'; -import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; -import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules'; +import { cspRuleTemplateAssetType } from './saved_objects/csp_rule_template'; +import { cspRuleAssetType } from './saved_objects/csp_rule_type'; +import { initializeCspRules } from './saved_objects/initialize_rules'; import { initializeCspTransformsIndices } from './create_indices/create_transforms_indices'; export interface CspAppContext { @@ -55,6 +56,7 @@ export class CspPlugin }; core.savedObjects.registerType(cspRuleAssetType); + core.savedObjects.registerType(cspRuleTemplateAssetType); const router = core.http.createRouter(); diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts new file mode 100644 index 0000000000000..e1082cc59db3f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_template.ts @@ -0,0 +1,29 @@ +/* + * 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 { SavedObjectsType } from '../../../../../src/core/server'; +import { + type CloudSecurityPostureRuleTemplateSchema, + cloudSecurityPostureRuleTemplateSavedObjectType, +} from '../../common/schemas/csp_rule_template'; + +const ruleTemplateAssetSavedObjectMappings: SavedObjectsType['mappings'] = + { + dynamic: false, + properties: {}, + }; + +export const cspRuleTemplateAssetType: SavedObjectsType = { + name: cloudSecurityPostureRuleTemplateSavedObjectType, + hidden: false, + management: { + importableAndExportable: true, + visibleInManagement: true, + }, + namespaceType: 'agnostic', + mappings: ruleTemplateAssetSavedObjectMappings, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts similarity index 90% rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts rename to x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts index fcff7449fb3f5..4b323c127c0e6 100644 --- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/csp_rule_type.ts @@ -6,15 +6,12 @@ */ import { i18n } from '@kbn/i18n'; -import type { - SavedObjectsType, - SavedObjectsValidationMap, -} from '../../../../../../src/core/server'; +import type { SavedObjectsType, SavedObjectsValidationMap } from '../../../../../src/core/server'; import { type CspRuleSchema, cspRuleSchema, cspRuleAssetSavedObjectType, -} from '../../../common/schemas/csp_rule'; +} from '../../common/schemas/csp_rule'; const validationMap: SavedObjectsValidationMap = { '1.0.0': cspRuleSchema, diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts similarity index 83% rename from x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts rename to x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts index 1cb08ddc1be1a..71e7697296acb 100644 --- a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/initialize_rules.ts @@ -6,8 +6,8 @@ */ import type { ISavedObjectsRepository } from 'src/core/server'; -import { CIS_BENCHMARK_1_4_1_RULES } from './rules'; -import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; +import { CIS_BENCHMARK_1_4_1_RULES } from './cis_1_4_1/rules'; +import { cspRuleAssetSavedObjectType } from '../../common/schemas/csp_rule'; export const initializeCspRules = async (client: ISavedObjectsRepository) => { const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 }); diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts index 6f48b15158f8d..0b4f30a137192 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -252,6 +252,7 @@ export const item: GetInfoResponse['item'] = { lens: [], map: [], security_rule: [], + csp_rule_template: [], tag: [], }, elasticsearch: { diff --git a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts index 6b766c2d126df..5c08120084cb9 100644 --- a/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/.storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const item: GetInfoResponse['item'] = { lens: [], ml_module: [], security_rule: [], + csp_rule_template: [], tag: [], }, elasticsearch: { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index b355a62fbf241..e9bb796626f58 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3585,7 +3585,8 @@ "map", "lens", "ml-module", - "security-rule" + "security-rule", + "csp-rule-template" ] }, "elasticsearch_asset_type": { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9a352f94e8252..f7941f863c120 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2238,6 +2238,7 @@ components: - lens - ml-module - security-rule + - csp_rule_template elasticsearch_asset_type: title: Elasticsearch asset type type: string diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml index 4ec82e7507166..1a7d29311e4fe 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml @@ -9,3 +9,4 @@ enum: - lens - ml-module - security-rule + - csp_rule_template diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index 0cf8c3e88f568..ee47c3faa305a 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -25,6 +25,7 @@ describe('Fleet - packageToPackagePolicy', () => { path: '', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index dcff9f503bfe0..93be8684698ca 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -72,6 +72,7 @@ export enum KibanaAssetType { map = 'map', lens = 'lens', securityRule = 'security_rule', + cloudSecurityPostureRuleTemplate = 'csp_rule_template', mlModule = 'ml_module', tag = 'tag', } @@ -88,6 +89,7 @@ export enum KibanaSavedObjectType { lens = 'lens', mlModule = 'ml-module', securityRule = 'security-rule', + cloudSecurityPostureRuleTemplate = 'csp-rule-template', tag = 'tag', } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx index 8b949fe8634ee..f460005722b41 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx @@ -28,6 +28,7 @@ export const AssetsFacetGroup = ({ width }: Args) => { = { tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', { defaultMessage: 'Tag', }), + csp_rule_template: i18n.translate( + 'xpack.fleet.epm.assetTitles.cloudSecurityPostureRuleTemplate', + { + defaultMessage: 'Cloud Security Posture rule template', + } + ), }; export const ServiceTitleMap: Record = { @@ -89,6 +95,7 @@ export const AssetIcons: Record = { map: 'emsApp', lens: 'lensApp', security_rule: 'securityApp', + csp_rule_template: 'securityApp', // TODO ICON ml_module: 'mlApp', tag: 'tagApp', }; diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index e76e44476df03..491e4e27825c4 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -52,6 +52,8 @@ const KibanaSavedObjectTypeMapping: Record { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -170,6 +171,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -262,6 +264,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], @@ -386,6 +389,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { status: 'not_installed', assets: { kibana: { + csp_rule_template: [], dashboard: [], visualization: [], search: [], diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index a44b8be478874..82b19cb02faf8 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -447,6 +447,11 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', }); expect(resSecurityRule.id).equal('sample_security_rule'); + const resCloudSecurityPostureRuleTemplate = await kibanaServer.savedObjects.get({ + type: 'csp-rule-template', + id: 'sample_csp_rule_template', + }); + expect(resCloudSecurityPostureRuleTemplate.id).equal('sample_csp_rule_template'); const resTag = await kibanaServer.savedObjects.get({ type: 'tag', id: 'sample_tag', @@ -496,8 +501,11 @@ const expectAssetsInstalled = ({ package_assets: sortBy(res.attributes.package_assets, (o: AssetReference) => o.type), }; expect(sortedRes).eql({ - installed_kibana_space_id: 'default', installed_kibana: [ + { + id: 'sample_csp_rule_template', + type: 'csp-rule-template', + }, { id: 'sample_dashboard', type: 'dashboard', @@ -535,6 +543,7 @@ const expectAssetsInstalled = ({ type: 'visualization', }, ], + installed_kibana_space_id: 'default', installed_es: [ { id: 'logs-all_assets.test_logs@mappings', @@ -593,37 +602,116 @@ const expectAssetsInstalled = ({ type: 'ml_model', }, ], + package_assets: [ + { + id: '333a22a1-e639-5af5-ae62-907ffc83d603', + type: 'epm-packages-assets', + }, + { + id: '256f3dad-6870-56c3-80a1-8dfa11e2d568', + type: 'epm-packages-assets', + }, + { + id: '3fa0512f-bc01-5c2e-9df1-bc2f2a8259c8', + type: 'epm-packages-assets', + }, + { + id: 'ea334ad8-80c2-5acd-934b-2a377290bf97', + type: 'epm-packages-assets', + }, + { + id: '96c6eb85-fe2e-56c6-84be-5fda976796db', + type: 'epm-packages-assets', + }, + { + id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', + type: 'epm-packages-assets', + }, + { + id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', + type: 'epm-packages-assets', + }, + { + id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', + type: 'epm-packages-assets', + }, + { + id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', + type: 'epm-packages-assets', + }, + { + id: 'f839c76e-d194-555a-90a1-3265a45789e4', + type: 'epm-packages-assets', + }, + { + id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', + type: 'epm-packages-assets', + }, + { + id: '1e97a20f-9d1c-529b-8ff2-da4e8ba8bb71', + type: 'epm-packages-assets', + }, + { + id: 'ed5d54d5-2516-5d49-9e61-9508b0152d2b', + type: 'epm-packages-assets', + }, + { + id: 'bd5ff3c5-655e-5385-9918-b60ff3040aad', + type: 'epm-packages-assets', + }, + { + id: '943d5767-41f5-57c3-ba02-48e0f6a837db', + type: 'epm-packages-assets', + }, + { + id: '0954ce3b-3165-5c1f-a4c0-56eb5f2fa487', + type: 'epm-packages-assets', + }, + { + id: '60d6d054-57e4-590f-a580-52bf3f5e7cca', + type: 'epm-packages-assets', + }, + { + id: '47758dc2-979d-5fbe-a2bd-9eded68a5a43', + type: 'epm-packages-assets', + }, + { + id: '318959c9-997b-5a14-b328-9fc7355b4b74', + type: 'epm-packages-assets', + }, + { + id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', + type: 'epm-packages-assets', + }, + { + id: '4c758d70-ecf1-56b3-b704-6d8374841b34', + type: 'epm-packages-assets', + }, + { + id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', + type: 'epm-packages-assets', + }, + { + id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', + type: 'epm-packages-assets', + }, + { + id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', + type: 'epm-packages-assets', + }, + { + id: '53c94591-aa33-591d-8200-cd524c2a0561', + type: 'epm-packages-assets', + }, + { + id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', + type: 'epm-packages-assets', + }, + ], es_index_patterns: { test_logs: 'logs-all_assets.test_logs-*', test_metrics: 'metrics-all_assets.test_metrics-*', }, - package_assets: [ - { id: '333a22a1-e639-5af5-ae62-907ffc83d603', type: 'epm-packages-assets' }, - { id: '256f3dad-6870-56c3-80a1-8dfa11e2d568', type: 'epm-packages-assets' }, - { id: '3fa0512f-bc01-5c2e-9df1-bc2f2a8259c8', type: 'epm-packages-assets' }, - { id: 'ea334ad8-80c2-5acd-934b-2a377290bf97', type: 'epm-packages-assets' }, - { id: '96c6eb85-fe2e-56c6-84be-5fda976796db', type: 'epm-packages-assets' }, - { id: '2d73a161-fa69-52d0-aa09-1bdc691b95bb', type: 'epm-packages-assets' }, - { id: '0a00c2d2-ce63-5b9c-9aa0-0cf1938f7362', type: 'epm-packages-assets' }, - { id: '691f0505-18c5-57a6-9f40-06e8affbdf7a', type: 'epm-packages-assets' }, - { id: 'b36e6dd0-58f7-5dd0-a286-8187e4019274', type: 'epm-packages-assets' }, - { id: 'f839c76e-d194-555a-90a1-3265a45789e4', type: 'epm-packages-assets' }, - { id: '9af7bbb3-7d8a-50fa-acc9-9dde6f5efca2', type: 'epm-packages-assets' }, - { id: '1e97a20f-9d1c-529b-8ff2-da4e8ba8bb71', type: 'epm-packages-assets' }, - { id: 'ed5d54d5-2516-5d49-9e61-9508b0152d2b', type: 'epm-packages-assets' }, - { id: 'bd5ff3c5-655e-5385-9918-b60ff3040aad', type: 'epm-packages-assets' }, - { id: '0954ce3b-3165-5c1f-a4c0-56eb5f2fa487', type: 'epm-packages-assets' }, - { id: '60d6d054-57e4-590f-a580-52bf3f5e7cca', type: 'epm-packages-assets' }, - { id: '47758dc2-979d-5fbe-a2bd-9eded68a5a43', type: 'epm-packages-assets' }, - { id: '318959c9-997b-5a14-b328-9fc7355b4b74', type: 'epm-packages-assets' }, - { id: 'e21b59b5-eb76-5ab0-bef2-1c8e379e6197', type: 'epm-packages-assets' }, - { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, - { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, - { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, - { id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', type: 'epm-packages-assets' }, - { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, - { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, - ], name: 'all_assets', version: '0.1.0', removable: true, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 7d28b04c28a53..844a6abe3da06 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -393,6 +393,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_security_rule', type: 'security-rule', }, + { + id: 'sample_csp_rule_template2', + type: 'csp-rule-template', + }, { id: 'sample_ml_module', type: 'ml-module', @@ -488,6 +492,7 @@ export default function (providerContext: FtrProviderContext) { { id: '5c3aa147-089c-5084-beca-53c00e72ac80', type: 'epm-packages-assets' }, { id: '0c8c3c6a-90cb-5f0e-8359-d807785b046c', type: 'epm-packages-assets' }, { id: '48e582df-b1d2-5f88-b6ea-ba1fafd3a569', type: 'epm-packages-assets' }, + { id: '7f97600c-d983-53e0-ae2a-a59bf35d7f0d', type: 'epm-packages-assets' }, { id: 'bf3b0b65-9fdc-53c6-a9ca-e76140e56490', type: 'epm-packages-assets' }, { id: '7f4c5aca-b4f5-5f0a-95af-051da37513fc', type: 'epm-packages-assets' }, { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json new file mode 100644 index 0000000000000..cdcd06876e010 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/csp_rule_template/sample_csp_rule_template.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "benchmark": { "name": "CIS Kubernetes", "version": "1.4.1" }, + "benchmark_rule_id": "1.1.1", + "default_value": "By default, anonymous access is enabled.", + "description": "'Disable anonymous requests to the API server", + "impact": "Anonymous requests will be rejected.", + "name": "Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)", + "rationale": "When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.", + "rego_rule_id": "cis_k8s.cis_1_1_1", + "remediation": "Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false", + "severity": "low", + "tags": ["Kubernetes", "Containers"] + }, + "id": "sample_csp_rule_template", + "type": "csp-rule-template" +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json new file mode 100644 index 0000000000000..97a24faebb3fd --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/csp_rule_template/sample_csp_rule_template.json @@ -0,0 +1,17 @@ +{ + "attributes": { + "benchmark": { "name": "CIS Kubernetes", "version": "1.4.1" }, + "benchmark_rule_id": "1.1.2", + "default_value": "By default, anonymous access is enabled.", + "description": "'Disable anonymous requests to the API server", + "impact": "Anonymous requests will be rejected.", + "name": "Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)", + "rationale": "When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.", + "rego_rule_id": "cis_k8s.cis_1_1_1", + "remediation": "Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false", + "severity": "low", + "tags": ["Kubernetes", "Containers"] + }, + "id": "sample_csp_rule_template2", + "type": "csp-rule-template" +} diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index 38c0d2593070d..c58666259dc07 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -66,6 +66,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.fleet.packages.0.version=latest`, ...(registryPort ? [`--xpack.fleet.registryUrl=http://localhost:${registryPort}`] : []), `--xpack.fleet.developer.bundledPackageLocation=${BUNDLED_PACKAGE_DIR}`, + '--xpack.cloudSecurityPosture.enabled=true', // Enable debug fleet logs by default `--logging.loggers[0].name=plugins.fleet`, `--logging.loggers[0].level=debug`, From 76321bceeb2d0a0b1d350f1b1b2593a9ee7c3cbb Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Thu, 24 Mar 2022 15:03:46 +0000 Subject: [PATCH 131/132] Update sec telemetry filterlist for diag alerts. (#128355) --- .../server/lib/telemetry/filterlists/endpoint_alerts.ts | 2 ++ .../security_solution/server/lib/telemetry/sender.test.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts index 3b55d4a789fc0..15f7b0a2a54c8 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filterlists/endpoint_alerts.ts @@ -62,6 +62,8 @@ const allowlistBaseEventFields: AllowlistFields = { directory: true, hash: true, Ext: { + compressed_bytes: true, + compressed_bytes_present: true, code_signature: true, header_bytes: true, header_data: true, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index d055f3843d479..dff3676c20c8a 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -59,6 +59,8 @@ describe('TelemetryEventsSender', () => { test: 'me', another: 'nope', Ext: { + compressed_bytes: 'data up to 4mb', + compressed_bytes_present: 'data up to 4mb', code_signature: { key1: 'X', key2: 'Y', @@ -131,6 +133,8 @@ describe('TelemetryEventsSender', () => { created: 0, path: 'X', Ext: { + compressed_bytes: 'data up to 4mb', + compressed_bytes_present: 'data up to 4mb', code_signature: { key1: 'X', key2: 'Y', From d940231edcf80bf2239ab27d3dd3770db2a45981 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Thu, 24 Mar 2022 11:09:26 -0400 Subject: [PATCH 132/132] [CI] Try using spot instances for the hourly pipeline again (#128431) --- .buildkite/pipelines/hourly.yml | 66 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index a236f9c37b313..1335866675564 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -19,24 +19,28 @@ steps: label: 'Default CI Group' parallelism: 27 agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 250 key: default-cigroup retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: CI_GROUP=Docker .buildkite/scripts/steps/functional/xpack_cigroup.sh label: 'Docker CI Group' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: default-cigroup-docker retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 @@ -44,78 +48,92 @@ steps: label: 'OSS CI Group' parallelism: 11 agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 key: oss-cigroup retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_accessibility.sh label: 'OSS Accessibility Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_accessibility.sh label: 'Default Accessibility Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_firefox.sh label: 'OSS Firefox Tests' agents: - queue: ci-group-4d + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_firefox.sh label: 'Default Firefox Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/oss_misc.sh label: 'OSS Misc Functional Tests' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 - command: .buildkite/scripts/steps/functional/xpack_saved_object_field_metrics.sh label: 'Saved Object Field Metrics' agents: - queue: n2-4 + queue: n2-4-spot depends_on: build timeout_in_minutes: 120 retry: automatic: + - exit_status: '-1' + limit: 3 - exit_status: '*' limit: 1 @@ -123,31 +141,47 @@ steps: label: 'Jest Tests' parallelism: 8 agents: - queue: n2-4 + queue: n2-4-spot timeout_in_minutes: 90 key: jest + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' parallelism: 3 agents: - queue: n2-4 + queue: n2-4-spot timeout_in_minutes: 120 key: jest-integration + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/test/api_integration.sh label: 'API Integration Tests' agents: - queue: n2-2 + queue: n2-2-spot timeout_in_minutes: 120 key: api-integration + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/lint.sh label: 'Linting' agents: - queue: n2-2 + queue: n2-2-spot key: linting timeout_in_minutes: 90 + retry: + automatic: + - exit_status: '-1' + limit: 3 - command: .buildkite/scripts/steps/lint_with_types.sh label: 'Linting (with types)' @@ -166,9 +200,13 @@ steps: - command: .buildkite/scripts/steps/storybooks/build_and_upload.sh label: 'Build Storybooks' agents: - queue: c2-4 + queue: n2-4-spot key: storybooks timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '-1' + limit: 3 - wait: ~ continue_on_failure: true