diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index ed642f22cfeb4c..97099c6f87448c 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -14,6 +14,7 @@ pipeline { HOME = "${env.WORKSPACE}" E2E_DIR = 'x-pack/plugins/apm/e2e' PIPELINE_LOG_LEVEL = 'DEBUG' + KBN_OPTIMIZER_THEMES = 'v7light' } options { timeout(time: 1, unit: 'HOURS') diff --git a/package.json b/package.json index 3c15d9ee3c97b5..8e51f9207eaf18 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.10.1", "@babel/register": "^7.10.1", "@elastic/apm-rum": "^5.2.0", - "@elastic/charts": "19.6.3", + "@elastic/charts": "19.7.0", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.9.3", "@elastic/eui": "24.1.0", diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index 93a7240849b949..abbeb72af784cc 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -63,7 +63,8 @@ afterAll(async () => { await del(TMP_DIR); }); -it('builds expected bundles, saves bundle counts to metadata', async () => { +// FLAKY: https://github.com/elastic/kibana/issues/70762 +it.skip('builds expected bundles, saves bundle counts to metadata', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], @@ -167,7 +168,8 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { `); }); -it('uses cache on second run and exist cleanly', async () => { +// FLAKY: https://github.com/elastic/kibana/issues/70764 +it.skip('uses cache on second run and exist cleanly', async () => { const config = OptimizerConfig.create({ repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index ff09d8d4fc5ab6..5f306cd5128b9f 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,7 +9,7 @@ "kbn:watch": "node scripts/build --dev --watch" }, "dependencies": { - "@elastic/charts": "19.6.3", + "@elastic/charts": "19.7.0", "@elastic/eui": "24.1.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", diff --git a/src/plugins/home/public/assets/googlecloud_metrics/screenshot.png b/src/plugins/home/public/assets/googlecloud_metrics/screenshot.png new file mode 100644 index 00000000000000..d4d90d27ad3025 Binary files /dev/null and b/src/plugins/home/public/assets/googlecloud_metrics/screenshot.png differ diff --git a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts index 33252401476404..210d563696667b 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts @@ -82,7 +82,7 @@ export interface TutorialSchema { name: string; isBeta?: boolean; shortDescription: string; - euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/icon; + euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/display/icons; longDescription: string; completionTimeMinutes?: number; previewImagePath?: string; diff --git a/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts new file mode 100644 index 00000000000000..504ede04c12d8e --- /dev/null +++ b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts @@ -0,0 +1,77 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { TutorialsCategory } from '../../services/tutorials'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../instructions/metricbeat_instructions'; +import { + TutorialContext, + TutorialSchema, +} from '../../services/tutorials/lib/tutorials_registry_types'; + +export function googlecloudMetricsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'googlecloud'; + return { + id: 'googlecloudMetrics', + name: i18n.translate('home.tutorials.googlecloudMetrics.nameTitle', { + defaultMessage: 'Google Cloud metrics', + }), + category: TutorialsCategory.METRICS, + shortDescription: i18n.translate('home.tutorials.googlecloudMetrics.shortDescription', { + defaultMessage: + 'Fetch monitoring metrics from Google Cloud Platform using Stackdriver Monitoring API.', + }), + longDescription: i18n.translate('home.tutorials.googlecloudMetrics.longDescription', { + defaultMessage: + 'The `googlecloud` Metricbeat module fetches monitoring metrics from Google Cloud Platform using Stackdriver Monitoring API. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-googlecloud.html', + }, + }), + euiIconType: 'logoGCP', + isBeta: false, + artifacts: { + dashboards: [ + { + id: 'f40ee870-5e4a-11ea-a4f6-717338406083', + linkLabel: i18n.translate( + 'home.tutorials.googlecloudMetrics.artifacts.dashboards.linkLabel', + { + defaultMessage: 'Google Cloud metrics dashboard', + } + ), + isOverview: true, + }, + ], + exportedFields: { + documentationUrl: '{config.docs.beats.metricbeat}/exported-fields-googlecloud.html', + }, + }, + completionTimeMinutes: 10, + previewImagePath: '/plugins/home/assets/googlecloud_metrics/screenshot.png', + onPrem: onPremInstructions(moduleName, context), + elasticCloud: cloudInstructions(moduleName), + onPremElasticCloud: onPremCloudInstructions(moduleName), + }; +} diff --git a/src/plugins/home/server/tutorials/register.ts b/src/plugins/home/server/tutorials/register.ts index d13cce1c22784e..c48423edb2a07d 100644 --- a/src/plugins/home/server/tutorials/register.ts +++ b/src/plugins/home/server/tutorials/register.ts @@ -91,6 +91,7 @@ import { openmetricsMetricsSpecProvider } from './openmetrics_metrics'; import { oracleMetricsSpecProvider } from './oracle_metrics'; import { iisMetricsSpecProvider } from './iis_metrics'; import { azureLogsSpecProvider } from './azure_logs'; +import { googlecloudMetricsSpecProvider } from './googlecloud_metrics'; export const builtInTutorials = [ systemLogsSpecProvider, @@ -168,4 +169,5 @@ export const builtInTutorials = [ oracleMetricsSpecProvider, iisMetricsSpecProvider, azureLogsSpecProvider, + googlecloudMetricsSpecProvider, ]; diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index 1e310c1ddd2684..5a30456bd59aba 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }) { after(unloadCurrentData); loadTestFile(require.resolve('./empty_dashboard')); + loadTestFile(require.resolve('./url_field_formatter')); loadTestFile(require.resolve('./embeddable_rendering')); loadTestFile(require.resolve('./create_and_add_embeddables')); loadTestFile(require.resolve('./edit_embeddable_redirects')); diff --git a/test/functional/apps/dashboard/url_field_formatter.ts b/test/functional/apps/dashboard/url_field_formatter.ts new file mode 100644 index 00000000000000..9b05b9b777b942 --- /dev/null +++ b/test/functional/apps/dashboard/url_field_formatter.ts @@ -0,0 +1,91 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common, dashboard, settings, timePicker, visChart } = getPageObjects([ + 'common', + 'dashboard', + 'settings', + 'timePicker', + 'visChart', + ]); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const fieldName = 'clientip'; + + const clickFieldAndCheckUrl = async (fieldLink: WebElementWrapper) => { + const fieldValue = await fieldLink.getVisibleText(); + await fieldLink.click(); + const windowHandlers = await browser.getAllWindowHandles(); + expect(windowHandlers.length).to.equal(2); + await browser.switchToWindow(windowHandlers[1]); + const currentUrl = await browser.getCurrentUrl(); + const fieldUrl = common.getHostPort() + '/app/' + fieldValue; + expect(currentUrl).to.equal(fieldUrl); + }; + + describe('Changing field formatter to Url', () => { + before(async function () { + await esArchiver.load('dashboard/current/kibana'); + await kibanaServer.uiSettings.replace({ + defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', + }); + await common.navigateToApp('settings'); + await settings.clickKibanaIndexPatterns(); + await settings.clickIndexPatternLogstash(); + await settings.filterField(fieldName); + await settings.openControlsByName(fieldName); + await settings.setFieldFormat('url'); + await settings.controlChangeSave(); + }); + + it('applied on dashboard', async () => { + await common.navigateToApp('dashboard'); + await dashboard.loadSavedDashboard('dashboard with everything'); + await dashboard.waitForRenderComplete(); + const fieldLink = await visChart.getFieldLinkInVisTable(`${fieldName}: Descending`, 1); + await clickFieldAndCheckUrl(fieldLink); + }); + + it('applied on discover', async () => { + await common.navigateToApp('discover'); + await timePicker.setAbsoluteRange( + 'Sep 19, 2017 @ 06:31:44.000', + 'Sep 23, 2018 @ 18:31:44.000' + ); + await testSubjects.click('docTableExpandToggleColumn'); + const fieldLink = await testSubjects.find(`tableDocViewRow-${fieldName}-value`); + await clickFieldAndCheckUrl(fieldLink); + }); + + afterEach(async function () { + const windowHandlers = await browser.getAllWindowHandles(); + if (windowHandlers.length > 1) { + await browser.closeCurrentWindow(); + await browser.switchToWindow(windowHandlers[0]); + } + }); + }); +} diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 673fba0c346b87..590631ad48b00a 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -302,6 +302,20 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr return element.getVisibleText(); } + public async getFieldLinkInVisTable(fieldName: string, rowIndex: number = 1) { + const tableVis = await testSubjects.find('tableVis'); + const $ = await tableVis.parseDomContent(); + const headers = $('span[ng-bind="::col.title"]') + .toArray() + .map((header: any) => $(header).text()); + const fieldColumnIndex = headers.indexOf(fieldName); + return await find.byCssSelector( + `[data-test-subj="paginated-table-body"] tr:nth-of-type(${rowIndex}) td:nth-of-type(${ + fieldColumnIndex + 1 + }) a` + ); + } + /** * If you are writing new tests, you should rather look into getTableVisContent method instead. * @deprecated Use getTableVisContent instead. diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts new file mode 100644 index 00000000000000..65dcb2e43c6f7f --- /dev/null +++ b/x-pack/plugins/infra/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_SOURCE_ID = 'default'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts index 15615046bdd6a6..30b6be435837b4 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -8,3 +8,4 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; +export * from './log_entry_rate_examples'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts index dfc3d2aabd11af..b7e8a49735152b 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts @@ -30,6 +30,7 @@ export type GetLogEntryRateRequestPayload = rt.TypeOf; + export const logEntryRatePartitionRT = rt.type({ analysisBucketCount: rt.number, anomalies: rt.array(logEntryRateAnomalyRT), diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts new file mode 100644 index 00000000000000..700f87ec3beb1e --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH = + '/api/infra/log_analysis/results/log_entry_rate_examples'; + +/** + * request + */ + +export const getLogEntryRateExamplesRequestPayloadRT = rt.type({ + data: rt.type({ + // the dataset to fetch the log rate examples from + dataset: rt.string, + // the number of examples to fetch + exampleCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the log rate examples from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryRateExamplesRequestPayload = rt.TypeOf< + typeof getLogEntryRateExamplesRequestPayloadRT +>; + +/** + * response + */ + +const logEntryRateExampleRT = rt.type({ + id: rt.string, + dataset: rt.string, + message: rt.string, + timestamp: rt.number, + tiebreaker: rt.number, +}); + +export type LogEntryRateExample = rt.TypeOf; + +export const getLogEntryRateExamplesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + examples: rt.array(logEntryRateExampleRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryRateExamplesSuccessReponsePayload = rt.TypeOf< + typeof getLogEntryRateExamplesSuccessReponsePayloadRT +>; + +export const getLogEntryRateExamplesResponsePayloadRT = rt.union([ + getLogEntryRateExamplesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryRateExamplesResponsePayload = rt.TypeOf< + typeof getLogEntryRateExamplesResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/anomaly_severity_indicator.tsx similarity index 95% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_results/anomaly_severity_indicator.tsx index e50231316fb5af..e85145b83a30ba 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/anomaly_severity_indicator.tsx @@ -10,7 +10,7 @@ import { formatAnomalyScore, getSeverityCategoryForScore, ML_SEVERITY_COLORS, -} from '../../../../../../common/log_analysis'; +} from '../../../../common/log_analysis'; export const AnomalySeverityIndicator: React.FunctionComponent<{ anomalyScore: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.tsx new file mode 100644 index 00000000000000..2ec9922d945553 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { euiStyled } from '../../../../../observability/public'; +import { LogEntryExampleMessagesEmptyIndicator } from './log_entry_examples_empty_indicator'; +import { LogEntryExampleMessagesFailureIndicator } from './log_entry_examples_failure_indicator'; +import { LogEntryExampleMessagesLoadingIndicator } from './log_entry_examples_loading_indicator'; + +interface Props { + isLoading: boolean; + hasFailedLoading: boolean; + hasResults: boolean; + exampleCount: number; + onReload: () => void; +} +export const LogEntryExampleMessages: React.FunctionComponent = ({ + isLoading, + hasFailedLoading, + exampleCount, + hasResults, + onReload, + children, +}) => { + return ( + + {isLoading ? ( + + ) : hasFailedLoading ? ( + + ) : !hasResults ? ( + + ) : ( + children + )} + + ); +}; + +const Wrapper = euiStyled.div` + align-items: stretch; + flex-direction: column; + flex: 1 0 0%; + overflow: hidden; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx similarity index 81% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx index ac572a5f6cf21a..1d6028ed032a2f 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_empty_indicator.tsx @@ -7,20 +7,20 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -export const CategoryExampleMessagesEmptyIndicator: React.FunctionComponent<{ +export const LogEntryExampleMessagesEmptyIndicator: React.FunctionComponent<{ onReload: () => void; }> = ({ onReload }) => ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_failure_indicator.tsx similarity index 75% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_failure_indicator.tsx index 7865dcd0226e0e..dca786bce3b713 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_failure_indicator.tsx @@ -7,22 +7,22 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiTextColor } from '@elastic/eui import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -export const CategoryExampleMessagesFailureIndicator: React.FunctionComponent<{ +export const LogEntryExampleMessagesFailureIndicator: React.FunctionComponent<{ onRetry: () => void; }> = ({ onRetry }) => ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_loading_indicator.tsx similarity index 89% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx rename to x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_loading_indicator.tsx index cad87a96a13263..8217b6ef80960d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_examples/log_entry_examples_loading_indicator.tsx @@ -7,7 +7,7 @@ import { EuiLoadingContent } from '@elastic/eui'; import React from 'react'; -export const CategoryExampleMessagesLoadingIndicator: React.FunctionComponent<{ +export const LogEntryExampleMessagesLoadingIndicator: React.FunctionComponent<{ exampleCount: number; }> = ({ exampleCount }) => ( <> diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx index bc592c71898b0d..c50a82006941a5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx @@ -68,7 +68,7 @@ export const LogColumnHeaders: React.FunctionComponent<{ ); }; -const LogColumnHeader: React.FunctionComponent<{ +export const LogColumnHeader: React.FunctionComponent<{ columnWidth: LogEntryColumnWidth; 'data-test-subj'?: string; }> = ({ children, columnWidth, 'data-test-subj': dataTestSubj }) => ( @@ -77,7 +77,7 @@ const LogColumnHeader: React.FunctionComponent<{ ); -const LogColumnHeadersWrapper = euiStyled.div.attrs((props) => ({ +export const LogColumnHeadersWrapper = euiStyled.div.attrs((props) => ({ role: props.role ?? 'row', }))` align-items: stretch; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts index dbf162171cac33..bc687baf7c4668 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts @@ -4,9 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export { LogEntryColumn, LogEntryColumnWidths, useColumnWidths } from './log_entry_column'; +export { + LogEntryColumn, + LogEntryColumnWidths, + useColumnWidths, + iconColumnId, +} from './log_entry_column'; export { LogEntryFieldColumn } from './log_entry_field_column'; export { LogEntryMessageColumn } from './log_entry_message_column'; export { LogEntryRowWrapper } from './log_entry_row'; export { LogEntryTimestampColumn } from './log_entry_timestamp_column'; export { ScrollableLogTextStreamView } from './scrollable_log_text_stream_view'; +export { LogEntryContextMenu } from './log_entry_context_menu'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx index 4aa81846d90ef9..adc1ce4d8c9fd8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx @@ -13,7 +13,8 @@ import { LogEntryColumnContent } from './log_entry_column'; interface LogEntryContextMenuItem { label: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; + href?: string; } interface LogEntryContextMenuProps { @@ -40,9 +41,9 @@ export const LogEntryContextMenu: React.FC = ({ }) => { const closeMenuAndCall = useMemo(() => { return (callback: LogEntryContextMenuItem['onClick']) => { - return () => { + return (e: React.MouseEvent) => { onClose(); - callback(); + callback(e); }; }; }, [onClose]); @@ -60,7 +61,7 @@ export const LogEntryContextMenu: React.FC = ({ const wrappedItems = useMemo(() => { return items.map((item, i) => ( - + {item.label} )); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx index dafaa37006be0c..47bb31ab4ae3e3 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; -import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; +import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; export const AnomalySeverityIndicatorList: React.FunctionComponent<{ datasets: LogEntryCategoryDataset[]; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx index c0728c0a55483e..d939d6738c5339 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx @@ -5,14 +5,10 @@ */ import React, { useEffect } from 'react'; - -import { euiStyled } from '../../../../../../../observability/public'; -import { TimeRange } from '../../../../../../common/http_api/shared'; import { useLogEntryCategoryExamples } from '../../use_log_entry_category_examples'; +import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; +import { TimeRange } from '../../../../../../common/http_api/shared'; import { CategoryExampleMessage } from './category_example_message'; -import { CategoryExampleMessagesEmptyIndicator } from './category_example_messages_empty_indicator'; -import { CategoryExampleMessagesFailureIndicator } from './category_example_messages_failure_indicator'; -import { CategoryExampleMessagesLoadingIndicator } from './category_example_messages_loading_indicator'; const exampleCount = 5; @@ -39,30 +35,21 @@ export const CategoryDetailsRow: React.FunctionComponent<{ }, [getLogEntryCategoryExamples]); return ( - - {isLoadingLogEntryCategoryExamples ? ( - - ) : hasFailedLoadingLogEntryCategoryExamples ? ( - - ) : logEntryCategoryExamples.length === 0 ? ( - - ) : ( - logEntryCategoryExamples.map((categoryExample, categoryExampleIndex) => ( - - )) - )} - + 0} + exampleCount={exampleCount} + onReload={getLogEntryCategoryExamples} + > + {logEntryCategoryExamples.map((example, exampleIndex) => ( + + ))} + ); }; - -const CategoryExampleMessages = euiStyled.div` - align-items: stretch; - flex-direction: column; - flex: 1 0 0%; - overflow: hidden; -`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index a1d3d56beee2cb..c527b8c49d099a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -4,86 +4,129 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiStat } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; - +import React from 'react'; +import { useMount } from 'react-use'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; -import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -import { - getAnnotationsForPartition, - getLogEntryRateSeriesForPartition, - getTotalNumberOfLogEntriesForPartition, -} from '../helpers/data_formatters'; -import { AnomaliesChart } from './chart'; +import { AnomalyRecord } from '../../use_log_entry_rate_results'; +import { useLogEntryRateModuleContext } from '../../use_log_entry_rate_module'; +import { useLogEntryRateExamples } from '../../use_log_entry_rate_examples'; +import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; +import { LogEntryRateExampleMessage, LogEntryRateExampleMessageHeaders } from './log_entry_example'; +import { euiStyled } from '../../../../../../../observability/public'; + +const EXAMPLE_COUNT = 5; + +const examplesTitle = i18n.translate('xpack.infra.logs.analysis.anomaliesTableExamplesTitle', { + defaultMessage: 'Example log entries', +}); export const AnomaliesTableExpandedRow: React.FunctionComponent<{ - partitionId: string; - results: LogEntryRateResults; - setTimeRange: (timeRange: TimeRange) => void; + anomaly: AnomalyRecord; timeRange: TimeRange; jobId: string; -}> = ({ results, timeRange, setTimeRange, partitionId, jobId }) => { - const logEntryRateSeries = useMemo( - () => - results?.histogramBuckets ? getLogEntryRateSeriesForPartition(results, partitionId) : [], - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [results, partitionId] - ); - const anomalyAnnotations = useMemo( - () => - results?.histogramBuckets - ? getAnnotationsForPartition(results, partitionId) - : { - warning: [], - minor: [], - major: [], - critical: [], - }, - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [results, partitionId] - ); - const totalNumberOfLogEntries = useMemo( - () => - results?.histogramBuckets - ? getTotalNumberOfLogEntriesForPartition(results, partitionId) - : undefined, - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [results, partitionId] - ); +}> = ({ anomaly, timeRange, jobId }) => { + const { + sourceConfiguration: { sourceId }, + } = useLogEntryRateModuleContext(); + + const { + getLogEntryRateExamples, + hasFailedLoadingLogEntryRateExamples, + isLoadingLogEntryRateExamples, + logEntryRateExamples, + } = useLogEntryRateExamples({ + dataset: anomaly.partitionId, + endTime: anomaly.startTime + anomaly.duration, + exampleCount: EXAMPLE_COUNT, + sourceId, + startTime: anomaly.startTime, + }); + + useMount(() => { + getLogEntryRateExamples(); + }); + return ( - - - - - - - - - - - - - - - + <> + + + +

{examplesTitle}

+
+ 0} + exampleCount={EXAMPLE_COUNT} + onReload={getLogEntryRateExamples} + > + {logEntryRateExamples.length > 0 ? ( + <> + + {logEntryRateExamples.map((example, exampleIndex) => ( + + ))} + + ) : null} + +
+ + + + + + + + + + +
+ ); }; + +const ExpandedContentWrapper = euiStyled(EuiFlexGroup)` + overflow: hidden; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index 5ff3f318629f4b..a2d37455eac1df 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -9,23 +9,15 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiStat, EuiTitle, EuiLoadingSpinner, } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; - import { euiStyled } from '../../../../../../../observability/public'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { formatAnomalyScore } from '../../../../../../common/log_analysis'; -import { - getAnnotationsForAll, - getLogEntryRateCombinedSeries, - getTopAnomalyScoreAcrossAllPartitions, -} from '../helpers/data_formatters'; +import { getAnnotationsForAll, getLogEntryRateCombinedSeries } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; import { RecreateJobButton } from '../../../../../components/logging/log_analysis_job_status'; @@ -67,14 +59,6 @@ export const AnomaliesResults: React.FunctionComponent<{ [results] ); - const topAnomalyScore = useMemo( - () => - results && results.histogramBuckets - ? getTopAnomalyScoreAcrossAllPartitions(results) - : undefined, - [results] - ); - return ( <> @@ -124,7 +108,7 @@ export const AnomaliesResults: React.FunctionComponent<{ ) : ( <> - + - - - - ; + interface ParsedAnnotationDetails { anomalyScoresByPartition: Array<{ partitionName: string; maximumAnomalyScore: number }>; } @@ -222,10 +189,3 @@ const renderAnnotationTooltip = (details?: string) => { const TooltipWrapper = euiStyled('div')` white-space: nowrap; `; - -const loadingAriaLabel = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel', - { defaultMessage: 'Loading anomalies' } -); - -const LoadingOverlayContent = () => ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx new file mode 100644 index 00000000000000..96f665b3693ca2 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -0,0 +1,291 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useCallback, useState } from 'react'; +import moment from 'moment'; +import { encode } from 'rison-node'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '../../../../../../../observability/public'; +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; +import { + LogEntryColumn, + LogEntryFieldColumn, + LogEntryMessageColumn, + LogEntryRowWrapper, + LogEntryTimestampColumn, + LogEntryContextMenu, + LogEntryColumnWidths, + iconColumnId, +} from '../../../../../components/logging/log_text_stream'; +import { + LogColumnHeadersWrapper, + LogColumnHeader, +} from '../../../../../components/logging/log_text_stream/column_headers'; +import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; +import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; +import { LogEntryRateExample } from '../../../../../../common/http_api/log_analysis/results'; +import { + LogColumnConfiguration, + isTimestampLogColumnConfiguration, + isFieldLogColumnConfiguration, + isMessageLogColumnConfiguration, +} from '../../../../../utils/source_configuration'; +import { localizedDate } from '../../../../../../common/formatters/datetime'; + +export const exampleMessageScale = 'medium' as const; +export const exampleTimestampFormat = 'time' as const; + +const MENU_LABEL = i18n.translate('xpack.infra.logAnomalies.logEntryExamplesMenuLabel', { + defaultMessage: 'View actions for log entry', +}); + +const VIEW_IN_STREAM_LABEL = i18n.translate( + 'xpack.infra.logs.analysis.logEntryExamplesViewInStreamLabel', + { + defaultMessage: 'View in stream', + } +); + +const VIEW_ANOMALY_IN_ML_LABEL = i18n.translate( + 'xpack.infra.logs.analysis.logEntryExamplesViewAnomalyInMlLabel', + { + defaultMessage: 'View anomaly in machine learning', + } +); + +type Props = LogEntryRateExample & { + timeRange: TimeRange; + jobId: string; +}; + +export const LogEntryRateExampleMessage: React.FunctionComponent = ({ + id, + dataset, + message, + timestamp, + tiebreaker, + timeRange, + jobId, +}) => { + const [isHovered, setIsHovered] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const openMenu = useCallback(() => setIsMenuOpen(true), []); + const closeMenu = useCallback(() => setIsMenuOpen(false), []); + const setItemIsHovered = useCallback(() => setIsHovered(true), []); + const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); + + // the dataset must be encoded for the field column and the empty value must + // be turned into a user-friendly value + const encodedDatasetFieldValue = useMemo( + () => JSON.stringify(getFriendlyNameForPartitionId(dataset)), + [dataset] + ); + + const viewInStreamLinkProps = useLinkProps({ + app: 'logs', + pathname: 'stream', + search: { + logPosition: encode({ + end: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + position: { tiebreaker, time: timestamp }, + start: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + streamLive: false, + }), + flyoutOptions: encode({ + surroundingLogsId: id, + }), + logFilter: encode({ + expression: `${partitionField}: ${dataset}`, + kind: 'kuery', + }), + }, + }); + + const viewAnomalyInMachineLearningLinkProps = useLinkProps( + getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { + [partitionField]: dataset, + }) + ); + + const menuItems = useMemo(() => { + if (!viewInStreamLinkProps.onClick || !viewAnomalyInMachineLearningLinkProps.onClick) { + return undefined; + } + + return [ + { + label: VIEW_IN_STREAM_LABEL, + onClick: viewInStreamLinkProps.onClick, + href: viewInStreamLinkProps.href, + }, + { + label: VIEW_ANOMALY_IN_ML_LABEL, + onClick: viewAnomalyInMachineLearningLinkProps.onClick, + href: viewAnomalyInMachineLearningLinkProps.href, + }, + ]; + }, [viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); + + return ( + + + + + + + + + + + + {(isHovered || isMenuOpen) && menuItems ? ( + + ) : null} + + + ); +}; + +const noHighlights: never[] = []; +const timestampColumnId = 'log-entry-example-timestamp-column' as const; +const messageColumnId = 'log-entry-examples-message-column' as const; +const datasetColumnId = 'log-entry-examples-dataset-column' as const; + +const DETAIL_FLYOUT_ICON_MIN_WIDTH = 32; +const COLUMN_PADDING = 8; + +export const columnWidths: LogEntryColumnWidths = { + [timestampColumnId]: { + growWeight: 0, + shrinkWeight: 0, + // w_score - w_padding = 130 px - 8 px + baseWidth: '122px', + }, + [messageColumnId]: { + growWeight: 1, + shrinkWeight: 0, + baseWidth: '0%', + }, + [datasetColumnId]: { + growWeight: 0, + shrinkWeight: 0, + baseWidth: '250px', + }, + [iconColumnId]: { + growWeight: 0, + shrinkWeight: 0, + baseWidth: `${DETAIL_FLYOUT_ICON_MIN_WIDTH + 2 * COLUMN_PADDING}px`, + }, +}; + +export const exampleMessageColumnConfigurations: LogColumnConfiguration[] = [ + { + timestampColumn: { + id: timestampColumnId, + }, + }, + { + messageColumn: { + id: messageColumnId, + }, + }, + { + fieldColumn: { + field: 'event.dataset', + id: datasetColumnId, + }, + }, +]; + +export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{ + dateTime: number; +}> = ({ dateTime }) => { + return ( + + <> + {exampleMessageColumnConfigurations.map((columnConfiguration) => { + if (isTimestampLogColumnConfiguration(columnConfiguration)) { + return ( + + {localizedDate(dateTime)} + + ); + } else if (isMessageLogColumnConfiguration(columnConfiguration)) { + return ( + + Message + + ); + } else if (isFieldLogColumnConfiguration(columnConfiguration)) { + return ( + + {columnConfiguration.fieldColumn.field} + + ); + } + })} + + {null} + + + + ); +}; + +const LogEntryRateExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)` + border-bottom: none; + box-shadow: none; + padding-right: 0; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index a9090a90c0b922..c70a456bfe06a4 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -6,10 +6,10 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; import { useSet } from 'react-use'; -import { euiStyled } from '../../../../../../../observability/public'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { formatAnomalyScore, @@ -18,11 +18,16 @@ import { import { RowExpansionButton } from '../../../../../components/basic_table'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; +import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; +import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; interface TableItem { - partitionName: string; - partitionId: string; - topAnomalyScore: number; + id: string; + dataset: string; + datasetName: string; + anomalyScore: number; + anomalyMessage: string; + startTime: number; } interface SortingOptions { @@ -32,73 +37,132 @@ interface SortingOptions { }; } -const partitionColumnName = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTablePartitionColumnName', +interface PaginationOptions { + pageIndex: number; + pageSize: number; + totalItemCount: number; + pageSizeOptions: number[]; + hidePerPageOptions: boolean; +} + +const anomalyScoreColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyScoreColumnName', + { + defaultMessage: 'Anomaly score', + } +); + +const anomalyMessageColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyMessageName', { - defaultMessage: 'Partition', + defaultMessage: 'Anomaly', } ); -const maxAnomalyScoreColumnName = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName', +const anomalyStartTimeColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyStartTime', { - defaultMessage: 'Max anomaly score', + defaultMessage: 'Start time', } ); +const datasetColumnName = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableAnomalyDatasetName', + { + defaultMessage: 'Dataset', + } +); + +const moreThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', + { + defaultMessage: 'More log messages in this dataset than expected', + } +); + +const fewerThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage', + { + defaultMessage: 'Fewer log messages in this dataset than expected', + } +); + +const getAnomalyMessage = (actualRate: number, typicalRate: number): string => { + return actualRate < typicalRate + ? fewerThanExpectedAnomalyMessage + : moreThanExpectedAnomalyMessage; +}; + export const AnomaliesTable: React.FunctionComponent<{ results: LogEntryRateResults; setTimeRange: (timeRange: TimeRange) => void; timeRange: TimeRange; jobId: string; }> = ({ results, timeRange, setTimeRange, jobId }) => { + const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss'); + const tableItems: TableItem[] = useMemo(() => { - return Object.entries(results.partitionBuckets).map(([key, value]) => { + return results.anomalies.map((anomaly) => { return { - // The real ID - partitionId: key, - // Note: EUI's table expanded rows won't work with a key of '' in itemIdToExpandedRowMap, so we have to use the friendly name here - partitionName: getFriendlyNameForPartitionId(key), - topAnomalyScore: formatAnomalyScore(value.topAnomalyScore), + id: anomaly.id, + dataset: anomaly.partitionId, + datasetName: getFriendlyNameForPartitionId(anomaly.partitionId), + anomalyScore: formatAnomalyScore(anomaly.anomalyScore), + anomalyMessage: getAnomalyMessage(anomaly.actualLogEntryRate, anomaly.typicalLogEntryRate), + startTime: anomaly.startTime, }; }); }, [results]); - const [expandedDatasetIds, { add: expandDataset, remove: collapseDataset }] = useSet( - new Set() - ); + const [expandedIds, { add: expandId, remove: collapseId }] = useSet(new Set()); const expandedDatasetRowContents = useMemo( () => - [...expandedDatasetIds].reduce>( - (aggregatedDatasetRows, datasetId) => { - return { - ...aggregatedDatasetRows, - [getFriendlyNameForPartitionId(datasetId)]: ( - - ), - }; - }, - {} - ), - [expandedDatasetIds, jobId, results, setTimeRange, timeRange] + [...expandedIds].reduce>((aggregatedDatasetRows, id) => { + const anomaly = results.anomalies.find((_anomaly) => _anomaly.id === id); + + return { + ...aggregatedDatasetRows, + [id]: anomaly ? ( + + ) : null, + }; + }, {}), + [expandedIds, results, timeRange, jobId] ); const [sorting, setSorting] = useState({ sort: { - field: 'topAnomalyScore', + field: 'anomalyScore', direction: 'desc', }, }); + const [_pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + totalItemCount: results.anomalies.length, + pageSizeOptions: [10, 20, 50], + hidePerPageOptions: false, + }); + + const paginationOptions = useMemo(() => { + return { + ..._pagination, + totalItemCount: results.anomalies.length, + }; + }, [_pagination, results]); + const handleTableChange = useCallback( - ({ sort = {} }) => { + ({ page = {}, sort = {} }) => { + const { index, size } = page; + setPagination((currentPagination) => { + return { + ...currentPagination, + pageIndex: index, + pageSize: size, + }; + }); const { field, direction } = sort; setSorting({ sort: { @@ -107,33 +171,58 @@ export const AnomaliesTable: React.FunctionComponent<{ }, }); }, - [setSorting] + [setSorting, setPagination] ); const sortedTableItems = useMemo(() => { let sortedItems: TableItem[] = []; - if (sorting.sort.field === 'partitionName') { - sortedItems = tableItems.sort((a, b) => (a.partitionId > b.partitionId ? 1 : -1)); - } else if (sorting.sort.field === 'topAnomalyScore') { - sortedItems = tableItems.sort((a, b) => a.topAnomalyScore - b.topAnomalyScore); + if (sorting.sort.field === 'datasetName') { + sortedItems = tableItems.sort((a, b) => (a.datasetName > b.datasetName ? 1 : -1)); + } else if (sorting.sort.field === 'anomalyScore') { + sortedItems = tableItems.sort((a, b) => a.anomalyScore - b.anomalyScore); + } else if (sorting.sort.field === 'startTime') { + sortedItems = tableItems.sort((a, b) => a.startTime - b.startTime); } + return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); }, [tableItems, sorting]); + const pageOfItems: TableItem[] = useMemo(() => { + const { pageIndex, pageSize } = paginationOptions; + return sortedTableItems.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize); + }, [paginationOptions, sortedTableItems]); + const columns: Array> = useMemo( () => [ { - field: 'partitionName', - name: partitionColumnName, + field: 'anomalyScore', + name: anomalyScoreColumnName, + sortable: true, + truncateText: true, + dataType: 'number' as const, + width: '130px', + render: (anomalyScore: number) => , + }, + { + field: 'anomalyMessage', + name: anomalyMessageColumnName, + sortable: false, + truncateText: true, + }, + { + field: 'startTime', + name: anomalyStartTimeColumnName, sortable: true, truncateText: true, + width: '230px', + render: (startTime: number) => moment(startTime).format(dateFormat), }, { - field: 'topAnomalyScore', - name: maxAnomalyScoreColumnName, + field: 'datasetName', + name: datasetColumnName, sortable: true, truncateText: true, - dataType: 'number' as const, + width: '200px', }, { align: RIGHT_ALIGNMENT, @@ -141,33 +230,28 @@ export const AnomaliesTable: React.FunctionComponent<{ isExpander: true, render: (item: TableItem) => ( ), }, ], - [collapseDataset, expandDataset, expandedDatasetIds] + [collapseId, expandId, expandedIds, dateFormat] ); return ( - ); }; - -const StyledEuiBasicTable: typeof EuiBasicTable = euiStyled(EuiBasicTable as any)` - & .euiTable { - table-layout: auto; - } -` as any; // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts new file mode 100644 index 00000000000000..d3b30da72af961 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { + getLogEntryRateExamplesRequestPayloadRT, + getLogEntryRateExamplesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetLogEntryRateExamplesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryRateExamplesRequestPayloadRT.encode({ + data: { + dataset, + exampleCount, + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + }); + + return pipe( + getLogEntryRateExamplesSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts new file mode 100644 index 00000000000000..12bcdb2a4b4d65 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useState } from 'react'; + +import { LogEntryRateExample } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryRateExamplesAPI } from './service_calls/get_log_entry_rate_examples'; + +export const useLogEntryRateExamples = ({ + dataset, + endTime, + exampleCount, + sourceId, + startTime, +}: { + dataset: string; + endTime: number; + exampleCount: number; + sourceId: string; + startTime: number; +}) => { + const [logEntryRateExamples, setLogEntryRateExamples] = useState([]); + + const [getLogEntryRateExamplesRequest, getLogEntryRateExamples] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryRateExamplesAPI( + sourceId, + startTime, + endTime, + dataset, + exampleCount + ); + }, + onResolve: ({ data: { examples } }) => { + setLogEntryRateExamples(examples); + }, + }, + [dataset, endTime, exampleCount, sourceId, startTime] + ); + + const isLoadingLogEntryRateExamples = useMemo( + () => getLogEntryRateExamplesRequest.state === 'pending', + [getLogEntryRateExamplesRequest.state] + ); + + const hasFailedLoadingLogEntryRateExamples = useMemo( + () => getLogEntryRateExamplesRequest.state === 'rejected', + [getLogEntryRateExamplesRequest.state] + ); + + return { + getLogEntryRateExamples, + hasFailedLoadingLogEntryRateExamples, + isLoadingLogEntryRateExamples, + logEntryRateExamples, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts index de2b873001ccef..1cd27c64af53f8 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts @@ -10,6 +10,7 @@ import { GetLogEntryRateSuccessResponsePayload, LogEntryRateHistogramBucket, LogEntryRatePartition, + LogEntryRateAnomaly, } from '../../../../common/http_api/log_analysis'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { callGetLogEntryRateAPI } from './service_calls/get_log_entry_rate'; @@ -23,11 +24,16 @@ type PartitionRecord = Record< { buckets: PartitionBucket[]; topAnomalyScore: number; totalNumberOfLogEntries: number } >; +export type AnomalyRecord = LogEntryRateAnomaly & { + partitionId: string; +}; + export interface LogEntryRateResults { bucketDuration: number; totalNumberOfLogEntries: number; histogramBuckets: LogEntryRateHistogramBucket[]; partitionBuckets: PartitionRecord; + anomalies: AnomalyRecord[]; } export const useLogEntryRateResults = ({ @@ -55,6 +61,7 @@ export const useLogEntryRateResults = ({ totalNumberOfLogEntries: data.totalNumberOfLogEntries, histogramBuckets: data.histogramBuckets, partitionBuckets: formatLogEntryRateResultsByPartition(data), + anomalies: formatLogEntryRateResultsByAllAnomalies(data), }); }, onReject: () => { @@ -117,3 +124,23 @@ const formatLogEntryRateResultsByPartition = ( return resultsByPartition; }; + +const formatLogEntryRateResultsByAllAnomalies = ( + results: GetLogEntryRateSuccessResponsePayload['data'] +): AnomalyRecord[] => { + return results.histogramBuckets.reduce((anomalies, bucket) => { + return bucket.partitions.reduce((_anomalies, partition) => { + if (partition.anomalies.length > 0) { + partition.anomalies.forEach((anomaly) => { + _anomalies.push({ + partitionId: partition.partitionId, + ...anomaly, + }); + }); + return _anomalies; + } else { + return _anomalies; + } + }, anomalies); + }, []); +}; diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 46a0edf75b7566..65ea53a8465bbf 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -4,90 +4,220 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfraClientCoreSetup } from '../types'; -import { LogsFetchDataResponse } from '../../../observability/public'; +import { encode } from 'rison-node'; +import { i18n } from '@kbn/i18n'; +import { DEFAULT_SOURCE_ID } from '../../common/constants'; +import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; +import { + FetchData, + LogsFetchDataResponse, + HasData, + FetchDataParams, +} from '../../../observability/public'; +import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; +import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; -export function getLogsHasDataFetcher(getStartServices: InfraClientCoreSetup['getStartServices']) { - return async () => { - // if you need the data plugin, this is how you get it - // const [, startPlugins] = await getStartServices(); - // const { data } = startPlugins; +interface StatsAggregation { + buckets: Array<{ key: string; doc_count: number }>; +} + +interface SeriesAggregation { + buckets: Array<{ + key_as_string: string; + key: number; + doc_count: number; + dataset: StatsAggregation; + }>; +} + +interface LogParams { + index: string; + timestampField: string; +} - // if you need a core dep, we need to pass in more than just getStartServices +type StatsAndSeries = Pick; - // perform query - return true; +export function getLogsHasDataFetcher( + getStartServices: InfraClientCoreSetup['getStartServices'] +): HasData { + return async () => { + const [core] = await getStartServices(); + const sourceStatus = await callFetchLogSourceStatusAPI(DEFAULT_SOURCE_ID, core.http.fetch); + return sourceStatus.data.logIndicesExist; }; } export function getLogsOverviewDataFetcher( getStartServices: InfraClientCoreSetup['getStartServices'] -) { - return async (): Promise => { - // if you need the data plugin, this is how you get it - // const [, startPlugins] = await getStartServices(); - // const { data } = startPlugins; +): FetchData { + return async (params) => { + const [core, startPlugins] = await getStartServices(); + const { data } = startPlugins; + + const sourceConfiguration = await callFetchLogSourceConfigurationAPI( + DEFAULT_SOURCE_ID, + core.http.fetch + ); + + const { stats, series } = await fetchLogsOverview( + { + index: sourceConfiguration.data.configuration.logAlias, + timestampField: sourceConfiguration.data.configuration.fields.timestamp, + }, + params, + data + ); - // if you need a core dep, we need to pass in more than just getStartServices + const timeSpanInMinutes = + (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60); - // perform query return { - title: 'Log rate', - appLink: 'TBD', // TODO: what format should this be in, relative I assume? - stats: { - nginx: { - type: 'number', - label: 'nginx', - value: 345341, - }, - 'elasticsearch.audit': { - type: 'number', - label: 'elasticsearch.audit', - value: 164929, + title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', { + defaultMessage: 'Logs', + }), + appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode( + params.startTime + )})`, + stats: normalizeStats(stats, timeSpanInMinutes), + series: normalizeSeries(series), + }; + }; +} + +async function fetchLogsOverview( + logParams: LogParams, + params: FetchDataParams, + dataPlugin: InfraClientStartDeps['data'] +): Promise { + const esSearcher = dataPlugin.search.getSearchStrategy('es'); + return new Promise((resolve, reject) => { + esSearcher + .search({ + params: { + index: logParams.index, + body: { + size: 0, + query: buildLogOverviewQuery(logParams, params), + aggs: buildLogOverviewAggregations(logParams, params), + }, }, - 'haproxy.log': { - type: 'number', - label: 'haproxy.log', - value: 51101, + }) + .subscribe( + (response) => { + if (response.rawResponse.aggregations) { + resolve(processLogsOverviewAggregations(response.rawResponse.aggregations)); + } else { + resolve({ stats: {}, series: {} }); + } }, + (error) => reject(error) + ); + }); +} + +function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) { + return { + range: { + [logParams.timestampField]: { + gt: params.startTime, + lte: params.endTime, + format: 'strict_date_optional_time', }, - // Note: My understanding is that these series coordinates will be - // combined into objects that look like: - // { x: timestamp, y: value, g: label (e.g. nginx) } - // so they fit the stacked bar chart API - // https://elastic.github.io/elastic-charts/?path=/story/bar-chart--stacked-with-axis-and-legend - series: { - nginx: { - label: 'nginx', - coordinates: [ - { x: 1593000000000, y: 10014 }, - { x: 1593000900000, y: 12827 }, - { x: 1593001800000, y: 2946 }, - { x: 1593002700000, y: 14298 }, - { x: 1593003600000, y: 4096 }, - ], - }, - 'elasticsearch.audit': { - label: 'elasticsearch.audit', - coordinates: [ - { x: 1593000000000, y: 5676 }, - { x: 1593000900000, y: 6783 }, - { x: 1593001800000, y: 2394 }, - { x: 1593002700000, y: 4554 }, - { x: 1593003600000, y: 5659 }, - ], - }, - 'haproxy.log': { - label: 'haproxy.log', - coordinates: [ - { x: 1593000000000, y: 9085 }, - { x: 1593000900000, y: 9002 }, - { x: 1593001800000, y: 3940 }, - { x: 1593002700000, y: 5451 }, - { x: 1593003600000, y: 9133 }, - ], + }, + }; +} + +function buildLogOverviewAggregations(logParams: LogParams, params: FetchDataParams) { + return { + stats: { + terms: { + field: 'event.dataset', + size: 4, + }, + }, + series: { + date_histogram: { + field: logParams.timestampField, + fixed_interval: params.bucketSize, + }, + aggs: { + dataset: { + terms: { + field: 'event.dataset', + size: 4, + }, }, }, - }; + }, + }; +} + +function processLogsOverviewAggregations(aggregations: { + stats: StatsAggregation; + series: SeriesAggregation; +}): StatsAndSeries { + const processedStats = aggregations.stats.buckets.reduce( + (result, bucket) => { + result[bucket.key] = { + type: 'number', + label: bucket.key, + value: bucket.doc_count, + }; + + return result; + }, + {} + ); + + const processedSeries = aggregations.series.buckets.reduce( + (result, bucket) => { + const x = bucket.key; // the timestamp of the bucket + bucket.dataset.buckets.forEach((b) => { + const label = b.key; + result[label] = result[label] || { label, coordinates: [] }; + result[label].coordinates.push({ x, y: b.doc_count }); + }); + + return result; + }, + {} + ); + + return { + stats: processedStats, + series: processedSeries, }; } + +function normalizeStats( + stats: LogsFetchDataResponse['stats'], + timeSpanInMinutes: number +): LogsFetchDataResponse['stats'] { + return Object.keys(stats).reduce((normalized, key) => { + normalized[key] = { + ...stats[key], + value: stats[key].value / timeSpanInMinutes, + }; + return normalized; + }, {}); +} + +function normalizeSeries(series: LogsFetchDataResponse['series']): LogsFetchDataResponse['series'] { + const seriesKeys = Object.keys(series); + const timestamps = seriesKeys.flatMap((key) => series[key].coordinates.map((c) => c.x)); + const [first, second] = [...new Set(timestamps)].sort(); + const timeSpanInMinutes = (second - first) / (1000 * 60); + + return seriesKeys.reduce((normalized, key) => { + normalized[key] = { + ...series[key], + coordinates: series[key].coordinates.map((c) => { + if (c.y) { + return { ...c, y: c.y / timeSpanInMinutes }; + } + return c; + }), + }; + return normalized; + }, {}); +} diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts new file mode 100644 index 00000000000000..6f9e41fbd08f3a --- /dev/null +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { coreMock } from 'src/core/public/mocks'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { getLogsHasDataFetcher } from './logs_overview_fetchers'; +import { InfraClientStartDeps, InfraClientStartExports } from '../types'; +import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; + +// Note +// Calls to `.mock*` functions will fail the typecheck because how jest does the mocking. +// The calls will be preluded with a `@ts-expect-error` +jest.mock('../containers/logs/log_source/api/fetch_log_source_status'); + +function setup() { + const core = coreMock.createStart(); + const data = dataPluginMock.createStartContract(); + + const mockedGetStartServices = jest.fn(() => { + const deps = { data }; + return Promise.resolve([ + core as CoreStart, + deps as InfraClientStartDeps, + void 0 as InfraClientStartExports, + ]) as Promise<[CoreStart, InfraClientStartDeps, InfraClientStartExports]>; + }); + return { core, mockedGetStartServices }; +} + +describe('Logs UI Observability Homepage Functions', () => { + describe('getLogsHasDataFetcher()', () => { + beforeEach(() => { + // @ts-expect-error + callFetchLogSourceStatusAPI.mockReset(); + }); + it('should return true when some index is present', async () => { + const { mockedGetStartServices } = setup(); + + // @ts-expect-error + callFetchLogSourceStatusAPI.mockResolvedValue({ + data: { logIndexFields: [], logIndicesExist: true }, + }); + + const hasData = getLogsHasDataFetcher(mockedGetStartServices); + const response = await hasData(); + + expect(callFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1); + expect(response).toBe(true); + }); + + it('should return false when no index is present', async () => { + const { mockedGetStartServices } = setup(); + + // @ts-expect-error + callFetchLogSourceStatusAPI.mockResolvedValue({ + data: { logIndexFields: [], logIndicesExist: false }, + }); + + const hasData = getLogsHasDataFetcher(mockedGetStartServices); + const response = await hasData(); + + expect(callFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1); + expect(response).toBe(false); + }); + }); + + describe('getLogsOverviewDataFetcher()', () => { + it.skip('should work', async () => { + // Pending + }); + }); +}); diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 6fbdeff950d1a4..8af37a36ef7451 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -15,6 +15,7 @@ import { initGetLogEntryCategoryDatasetsRoute, initGetLogEntryCategoryExamplesRoute, initGetLogEntryRateRoute, + initGetLogEntryRateExamplesRoute, initValidateLogAnalysisDatasetsRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; @@ -56,6 +57,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initLogEntriesRoute(libs); + initGetLogEntryRateExamplesRoute(libs); initLogEntriesHighlightsRoute(libs); initLogEntriesSummaryRoute(libs); initLogEntriesSummaryHighlightsRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 125cc2b196e097..290cf03b67365b 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -7,16 +7,30 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { getJobId } from '../../../common/log_analysis'; +import { RequestHandlerContext } from 'src/core/server'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError } from './errors'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, LogRateModelPlotBucket, CompositeTimestampPartitionKey, } from './queries'; -import { MlSystem } from '../../types'; +import { startTracingSpan } from '../../../common/performance_tracing'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { getJobId, jobCustomSettingsRT } from '../../../common/log_analysis'; +import { + createLogEntryRateExamplesQuery, + logEntryRateExamplesResponseRT, +} from './queries/log_entry_rate_examples'; +import { + InsufficientLogAnalysisMlJobConfigurationError, + NoLogAnalysisMlJobError, + NoLogAnalysisResultsIndexError, +} from './errors'; +import { InfraSource } from '../sources'; +import type { MlSystem } from '../../types'; +import { InfraRequestHandlerContext } from '../../types'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -73,6 +87,7 @@ export async function getLogEntryRateBuckets( partitions: Array<{ analysisBucketCount: number; anomalies: Array<{ + id: string; actualLogEntryRate: number; anomalyScore: number; duration: number; @@ -91,7 +106,8 @@ export async function getLogEntryRateBuckets( const partition = { analysisBucketCount: timestampPartitionBucket.filter_model_plot.doc_count, anomalies: timestampPartitionBucket.filter_records.top_hits_record.hits.hits.map( - ({ _source: record }) => ({ + ({ _id, _source: record }) => ({ + id: _id, actualLogEntryRate: record.actual[0], anomalyScore: record.record_score, duration: record.bucket_span * 1000, @@ -127,3 +143,130 @@ export async function getLogEntryRateBuckets( } }, []); } + +export async function getLogEntryRateExamples( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + sourceConfiguration: InfraSource, + callWithRequest: KibanaFramework['callWithRequest'] +) { + const finalizeLogEntryRateExamplesSpan = startTracingSpan( + 'get log entry rate example log entries' + ); + + const jobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate'); + + const { + mlJob, + timing: { spans: fetchMlJobSpans }, + } = await fetchMlJob(context, jobId); + + const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); + const indices = customSettings?.logs_source_config?.indexPattern; + const timestampField = customSettings?.logs_source_config?.timestampField; + const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; + + if (indices == null || timestampField == null) { + throw new InsufficientLogAnalysisMlJobConfigurationError( + `Failed to find index configuration for ml job ${jobId}` + ); + } + + const { + examples, + timing: { spans: fetchLogEntryRateExamplesSpans }, + } = await fetchLogEntryRateExamples( + context, + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount, + callWithRequest + ); + + const logEntryRateExamplesSpan = finalizeLogEntryRateExamplesSpan(); + + return { + data: examples, + timing: { + spans: [logEntryRateExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryRateExamplesSpans], + }, + }; +} + +export async function fetchLogEntryRateExamples( + context: RequestHandlerContext & { infra: Required }, + indices: string, + timestampField: string, + tiebreakerField: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + callWithRequest: KibanaFramework['callWithRequest'] +) { + const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES'); + + const { + hits: { hits }, + } = decodeOrThrow(logEntryRateExamplesResponseRT)( + await callWithRequest( + context, + 'search', + createLogEntryRateExamplesQuery( + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + return { + examples: hits.map((hit) => ({ + id: hit._id, + dataset, + message: hit._source.message ?? '', + timestamp: hit.sort[0], + tiebreaker: hit.sort[1], + })), + timing: { + spans: [esSearchSpan], + }, + }; +} + +async function fetchMlJob( + context: RequestHandlerContext & { infra: Required }, + logEntryRateJobId: string +) { + const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); + const { + jobs: [mlJob], + } = await context.infra.mlAnomalyDetectors.jobs(logEntryRateJobId); + + const mlGetJobSpan = finalizeMlGetJobSpan(); + + if (mlJob == null) { + throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryRateJobId}.`); + } + + return { + mlJob, + timing: { + spans: [mlGetJobSpan], + }, + }; +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 269850e292636e..8d9c586b2ef67b 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -143,6 +143,7 @@ export const logRateModelPlotBucketRT = rt.type({ hits: rt.type({ hits: rt.array( rt.type({ + _id: rt.string, _source: logRateMlRecordRT, }) ), diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts new file mode 100644 index 00000000000000..ef06641caf7975 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters } from './common'; +import { partitionField } from '../../../../common/log_analysis'; + +export const createLogEntryRateExamplesQuery = ( + indices: string, + timestampField: string, + tiebreakerField: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + [timestampField]: { + gte: startTime, + lte: endTime, + }, + }, + }, + { + term: { + [partitionField]: dataset, + }, + }, + ], + }, + }, + sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }], + }, + _source: ['event.dataset', 'message'], + index: indices, + size: exampleCount, +}); + +export const logEntryRateExampleHitRT = rt.type({ + _id: rt.string, + _source: rt.partial({ + event: rt.partial({ + dataset: rt.string, + }), + message: rt.string, + }), + sort: rt.tuple([rt.number, rt.number]), +}); + +export type LogEntryRateExampleHit = rt.TypeOf; + +export const logEntryRateExamplesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryRateExampleHitRT), + }), + }), +]); + +export type LogEntryRateExamplesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts index 15615046bdd6a6..30b6be435837b4 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts @@ -8,3 +8,4 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; +export * from './log_entry_rate_examples'; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts new file mode 100644 index 00000000000000..b8ebcc66911dcb --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { createValidationFunction } from '../../../../common/runtime_types'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { NoLogAnalysisResultsIndexError, getLogEntryRateExamples } from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; +import { + getLogEntryRateExamplesRequestPayloadRT, + getLogEntryRateExamplesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, +} from '../../../../common/http_api/log_analysis'; + +export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, + validate: { + body: createValidationFunction(getLogEntryRateExamplesRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { + dataset, + exampleCount, + sourceId, + timeRange: { startTime, endTime }, + }, + } = request.body; + + const sourceConfiguration = await sources.getSourceConfiguration( + requestContext.core.savedObjects.client, + sourceId + ); + + try { + assertHasInfraMlPlugins(requestContext); + + const { data: logEntryRateExamples, timing } = await getLogEntryRateExamples( + requestContext, + sourceId, + startTime, + endTime, + dataset, + exampleCount, + sourceConfiguration, + framework.callWithRequest + ); + + return response.ok({ + body: getLogEntryRateExamplesSuccessReponsePayloadRT.encode({ + data: { + examples: logEntryRateExamples, + }, + timing, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/common.ts b/x-pack/plugins/security_solution/common/endpoint/schema/common.ts index 7f8c938d54feb5..fdb2570314cd0d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/common.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/common.ts @@ -6,6 +6,15 @@ import * as t from 'io-ts'; +export const compressionAlgorithm = t.keyof({ + none: null, + zlib: null, +}); + +export const encryptionAlgorithm = t.keyof({ + none: null, +}); + export const identifier = t.string; export const manifestVersion = t.string; @@ -15,8 +24,8 @@ export const manifestSchemaVersion = t.keyof({ }); export type ManifestSchemaVersion = t.TypeOf; +export const relativeUrl = t.string; + export const sha256 = t.string; export const size = t.number; - -export const url = t.string; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts b/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts index 470e9b13ef78ae..2f03895d91c74c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts @@ -5,13 +5,26 @@ */ import * as t from 'io-ts'; -import { identifier, manifestSchemaVersion, manifestVersion, sha256, size, url } from './common'; +import { + compressionAlgorithm, + encryptionAlgorithm, + identifier, + manifestSchemaVersion, + manifestVersion, + relativeUrl, + sha256, + size, +} from './common'; export const manifestEntrySchema = t.exact( t.type({ - url, - sha256, - size, + relative_url: relativeUrl, + precompress_sha256: sha256, + precompress_size: size, + postcompress_sha256: sha256, + postcompress_size: size, + compression_algorithm: compressionAlgorithm, + encryption_algorithm: encryptionAlgorithm, }) ); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx index b6db6eb93d77f3..b002700d7eff0c 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx @@ -69,6 +69,7 @@ interface AlertsHistogramPanelProps { showLinkToAlerts?: boolean; showTotalAlertsCount?: boolean; stackByOptions?: AlertsHistogramOption[]; + timelineId?: string; title?: string; to: number; updateDateRange: UpdateDateRange; @@ -98,8 +99,9 @@ export const AlertsHistogramPanel = memo( showLinkToAlerts = false, showTotalAlertsCount = false, stackByOptions, - to, + timelineId, title = i18n.HISTOGRAM_HEADER, + to, updateDateRange, }) => { // create a unique, but stable (across re-renders) query id @@ -163,11 +165,12 @@ export const AlertsHistogramPanel = memo( `draggable-legend-item-${uuid.v4()}-${selectedStackByOption.value}-${bucket.key}` ), field: selectedStackByOption.value, + timelineId, value: bucket.key, })) : NO_LEGEND_DATA, // eslint-disable-next-line react-hooks/exhaustive-deps - [alertsData, selectedStackByOption.value] + [alertsData, selectedStackByOption.value, timelineId] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index 42fc2ac9b8453d..fba8c3faa9237f 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -117,6 +117,7 @@ interface BarChartComponentProps { barChart: ChartSeriesData[] | null | undefined; configs?: ChartSeriesConfigs | undefined; stackByField?: string; + timelineId?: string; } const NO_LEGEND_DATA: LegendItem[] = []; @@ -125,6 +126,7 @@ export const BarChartComponent: React.FC = ({ barChart, configs, stackByField, + timelineId, }) => { const { ref: measureRef, width, height } = useThrottledResizeObserver(); const legendItems: LegendItem[] = useMemo( @@ -135,11 +137,12 @@ export const BarChartComponent: React.FC = ({ dataProviderId: escapeDataProviderId( `draggable-legend-item-${uuid.v4()}-${stackByField}-${d.key}` ), + timelineId, field: stackByField, value: d.key, })) : NO_LEGEND_DATA, - [barChart, stackByField] + [barChart, stackByField, timelineId] ); const customHeight = get('customHeight', configs); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx index cdda1733932d54..bb71e5e73475d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx @@ -21,13 +21,14 @@ export interface LegendItem { color?: string; dataProviderId: string; field: string; + timelineId?: string; value: string; } const DraggableLegendItemComponent: React.FC<{ legendItem: LegendItem; }> = ({ legendItem }) => { - const { color, dataProviderId, field, value } = legendItem; + const { color, dataProviderId, field, timelineId, value } = legendItem; return ( @@ -44,6 +45,7 @@ const DraggableLegendItemComponent: React.FC<{ data-test-subj={`legend-item-${dataProviderId}`} field={field} id={dataProviderId} + timelineId={timelineId} value={value} /> ) : ( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx index 3edc1d0d84b692..74efe2d34fcca7 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/drag_drop_context_wrapper.tsx @@ -18,11 +18,10 @@ import { IdToDataProvider } from '../../store/drag_and_drop/model'; import { State } from '../../store/types'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { reArrangeProviders } from '../../../timelines/components/timeline/data_providers/helpers'; -import { ACTIVE_TIMELINE_REDUX_ID } from '../top_n'; import { ADDED_TO_TIMELINE_MESSAGE } from '../../hooks/translations'; import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline'; import { displaySuccessToast, useStateToaster } from '../toasters'; - +import { TimelineId } from '../../../../common/types/timeline'; import { addFieldToTimelineColumns, addProviderToTimeline, @@ -35,7 +34,7 @@ import { userIsReArrangingProviders, } from './helpers'; -// @ts-ignore +// @ts-expect-error window['__react-beautiful-dnd-disable-dev-warnings'] = true; interface Props { @@ -67,7 +66,7 @@ const onDragEndHandler = ({ destination: result.destination, dispatch, source: result.source, - timelineId: ACTIVE_TIMELINE_REDUX_ID, + timelineId: TimelineId.active, }); } else if (providerWasDroppedOnTimeline(result)) { addProviderToTimeline({ @@ -76,7 +75,7 @@ const onDragEndHandler = ({ dispatch, onAddedToTimeline, result, - timelineId: ACTIVE_TIMELINE_REDUX_ID, + timelineId: TimelineId.active, }); } else if (fieldWasDroppedOnTimelineColumns(result)) { addFieldToTimelineColumns({ @@ -130,7 +129,6 @@ export const DragDropContextWrapperComponent = React.memo {children} @@ -152,7 +150,7 @@ const emptyActiveTimelineDataProviders: DataProvider[] = []; // stable reference const mapStateToProps = (state: State) => { const activeTimelineDataProviders = - timelineSelectors.getTimelineByIdSelector()(state, ACTIVE_TIMELINE_REDUX_ID)?.dataProviders ?? + timelineSelectors.getTimelineByIdSelector()(state, TimelineId.active)?.dataProviders ?? emptyActiveTimelineDataProviders; const dataProviders = dragAndDropSelectors.dataProvidersSelector(state) ?? emptyDataProviders; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index 22b95f0d0c0e90..e7594365e81034 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Draggable, DraggableProvided, @@ -22,7 +22,7 @@ import { DataProvider } from '../../../timelines/components/timeline/data_provid import { TruncatableText } from '../truncatable_text'; import { WithHoverActions } from '../with_hover_actions'; -import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content'; +import { DraggableWrapperHoverContent, useGetTimelineId } from './draggable_wrapper_hover_content'; import { getDraggableId, getDroppableId } from './helpers'; import { ProviderContainer } from './provider_container'; @@ -76,6 +76,7 @@ interface Props { dataProvider: DataProvider; inline?: boolean; render: RenderFunctionProp; + timelineId?: string; truncate?: boolean; onFilterAdded?: () => void; } @@ -100,16 +101,31 @@ export const getStyle = ( }; export const DraggableWrapper = React.memo( - ({ dataProvider, onFilterAdded, render, truncate }) => { + ({ dataProvider, onFilterAdded, render, timelineId, truncate }) => { + const draggableRef = useRef(null); + const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); - const toggleTopN = useCallback(() => { - setShowTopN(!showTopN); - }, [setShowTopN, showTopN]); - + const [goGetTimelineId, setGoGetTimelineId] = useState(false); + const timelineIdFind = useGetTimelineId(draggableRef, goGetTimelineId); const [providerRegistered, setProviderRegistered] = useState(false); const dispatch = useDispatch(); + const handleClosePopOverTrigger = useCallback( + () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), + [] + ); + + const toggleTopN = useCallback(() => { + setShowTopN((prevShowTopN) => { + const newShowTopN = !prevShowTopN; + if (newShowTopN === false) { + handleClosePopOverTrigger(); + } + return newShowTopN; + }); + }, [handleClosePopOverTrigger]); + const registerProvider = useCallback(() => { if (!providerRegistered) { dispatch(dragAndDropActions.registerProvider({ provider: dataProvider })); @@ -126,17 +142,19 @@ export const DraggableWrapper = React.memo( () => () => { unRegisterProvider(); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [unRegisterProvider] ); const hoverContent = useMemo( () => ( ( } /> ), - [dataProvider, onFilterAdded, showTopN, toggleTopN] + [ + dataProvider, + handleClosePopOverTrigger, + onFilterAdded, + showTopN, + timelineId, + timelineIdFind, + toggleTopN, + ] ); const renderContent = useCallback( @@ -184,7 +210,10 @@ export const DraggableWrapper = React.memo( { + provided.innerRef(e); + draggableRef.current = e; + }} data-test-subj="providerContainer" isDragging={snapshot.isDragging} registerProvider={registerProvider} @@ -214,7 +243,12 @@ export const DraggableWrapper = React.memo( ); return ( - + ); }, (prevProps, nextProps) => diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index ee1dc73b27fe2b..3507b0f8c447dd 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -52,6 +52,7 @@ jest.mock('../../../timelines/components/manage_timeline', () => { return { ...original, useManageTimeline: () => ({ + getManageTimelineById: jest.fn().mockReturnValue({ indexToAdd: [] }), getTimelineFilterManager: mockGetTimelineFilterManager, isManagedTimeline: jest.fn().mockReturnValue(false), }), @@ -63,8 +64,10 @@ const timelineId = TimelineId.active; const field = 'process.name'; const value = 'nice'; const toggleTopN = jest.fn(); +const goGetTimelineId = jest.fn(); const defaultProps = { field, + goGetTimelineId, showTopN: false, timelineId, toggleTopN, @@ -130,6 +133,18 @@ describe('DraggableWrapperHoverContent', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().exists() ).toBe(false); }); + + test(`it should call goGetTimelineId when user is over the 'Filter ${hoverAction} value' button`, () => { + const wrapper = mount( + + + + ); + const button = wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first(); + button.simulate('mouseenter'); + expect(goGetTimelineId).toHaveBeenCalledWith(true); + }); + describe('when run in the context of a timeline', () => { let wrapper: ReactWrapper; let onFilterAdded: () => void; @@ -151,6 +166,7 @@ describe('DraggableWrapperHoverContent', () => { ); }); + test('when clicked, it adds a filter to the timeline when running in the context of a timeline', () => { wrapper.find(`[data-test-subj="filter-${hoverAction}-value"]`).first().simulate('click'); wrapper.update(); @@ -459,6 +475,24 @@ describe('DraggableWrapperHoverContent', () => { expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(false); }); + test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, () => { + const whitelistedField = 'signal.rule.name'; + const wrapper = mount( + + + + ); + const button = wrapper.find(`[data-test-subj="show-top-field"]`).first(); + button.simulate('mouseenter'); + expect(goGetTimelineId).toHaveBeenCalledWith(true); + }); + test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => { const whitelistedField = 'signal.rule.name'; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index 4efdea5eee43b6..a951bfa98d64b5 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { DraggableId } from 'react-beautiful-dnd'; import { getAllFieldsByName, useWithSource } from '../../containers/source'; @@ -19,20 +19,25 @@ import { allowTopN } from './helpers'; import * as i18n from './translations'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../../../timelines/components/timeline/styles'; interface Props { + closePopOver?: () => void; draggableId?: DraggableId; field: string; + goGetTimelineId?: (args: boolean) => void; onFilterAdded?: () => void; showTopN: boolean; - timelineId?: string; + timelineId?: string | null; toggleTopN: () => void; value?: string[] | string | null; } const DraggableWrapperHoverContentComponent: React.FC = ({ + closePopOver, draggableId, field, + goGetTimelineId, onFilterAdded, showTopN, timelineId, @@ -44,17 +49,37 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [ kibana.services.data.query.filterManager, ]); - const { getTimelineFilterManager } = useManageTimeline(); + const { getManageTimelineById, getTimelineFilterManager } = useManageTimeline(); const filterManager = useMemo( () => - timelineId === TimelineId.active || - (draggableId != null && draggableId?.includes(TimelineId.active)) + timelineId === TimelineId.active ? getTimelineFilterManager(TimelineId.active) : filterManagerBackup, - [draggableId, timelineId, getTimelineFilterManager, filterManagerBackup] + [timelineId, getTimelineFilterManager, filterManagerBackup] ); + // Regarding data from useManageTimeline: + // * `indexToAdd`, which enables the alerts index to be appended to + // the `indexPattern` returned by `useWithSource`, may only be populated when + // this component is rendered in the context of the active timeline. This + // behavior enables the 'All events' view by appending the alerts index + // to the index pattern. + const { indexToAdd } = useMemo( + () => + timelineId === TimelineId.active + ? getManageTimelineById(TimelineId.active) + : { indexToAdd: null }, + [getManageTimelineById, timelineId] + ); + + const handleStartDragToTimeline = useCallback(() => { + startDragToTimeline(); + if (closePopOver != null) { + closePopOver(); + } + }, [closePopOver, startDragToTimeline]); + const filterForValue = useCallback(() => { const filter = value?.length === 0 ? createFilter(field, undefined) : createFilter(field, value); @@ -62,13 +87,15 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ if (activeFilterManager != null) { activeFilterManager.addFilters(filter); - + if (closePopOver != null) { + closePopOver(); + } if (onFilterAdded != null) { onFilterAdded(); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, value, filterManager, onFilterAdded]); + }, [closePopOver, field, value, filterManager, onFilterAdded]); const filterOutValue = useCallback(() => { const filter = @@ -78,14 +105,23 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ if (activeFilterManager != null) { activeFilterManager.addFilters(filter); + if (closePopOver != null) { + closePopOver(); + } if (onFilterAdded != null) { onFilterAdded(); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, value, filterManager, onFilterAdded]); + }, [closePopOver, field, value, filterManager, onFilterAdded]); - const { browserFields } = useWithSource(); + const handleGoGetTimelineId = useCallback(() => { + if (goGetTimelineId != null && timelineId == null) { + goGetTimelineId(true); + } + }, [goGetTimelineId, timelineId]); + + const { browserFields, indexPattern } = useWithSource('default', indexToAdd); return ( <> @@ -97,6 +133,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="filter-for-value" iconType="magnifyWithPlus" onClick={filterForValue} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -109,6 +146,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="filter-out-value" iconType="magnifyWithMinus" onClick={filterOutValue} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -120,7 +158,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ color="text" data-test-subj="add-to-timeline" iconType="timeline" - onClick={startDragToTimeline} + onClick={handleStartDragToTimeline} /> )} @@ -139,6 +177,7 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ data-test-subj="show-top-field" iconType="visBarVertical" onClick={toggleTopN} + onMouseEnter={handleGoGetTimelineId} /> )} @@ -147,7 +186,10 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ @@ -172,3 +214,30 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ DraggableWrapperHoverContentComponent.displayName = 'DraggableWrapperHoverContentComponent'; export const DraggableWrapperHoverContent = React.memo(DraggableWrapperHoverContentComponent); + +export const useGetTimelineId = function ( + elem: React.MutableRefObject, + getTimelineId: boolean = false +) { + const [timelineId, setTimelineId] = useState(null); + + useEffect(() => { + let startElem: Element | (Node & ParentNode) | null = elem.current; + if (startElem != null && getTimelineId) { + for (; startElem && startElem !== document; startElem = startElem.parentNode) { + const myElem: Element = startElem as Element; + if ( + myElem != null && + myElem.classList != null && + myElem.classList.contains(SELECTOR_TIMELINE_BODY_CLASS_NAME) && + myElem.hasAttribute('data-timeline-id') + ) { + setTimelineId(myElem.getAttribute('data-timeline-id')); + break; + } + } + } + }, [elem, getTimelineId]); + + return timelineId; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index fcf007a4cf1bab..62a07550650aa8 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -21,6 +21,7 @@ export interface DefaultDraggableType { name?: string | null; queryValue?: string | null; children?: React.ReactNode; + timelineId?: string; tooltipContent?: React.ReactNode; } @@ -83,7 +84,7 @@ Content.displayName = 'Content'; * @param queryValue - defaults to `value`, this query overrides the `queryMatch.value` used by the `DataProvider` that represents the data */ export const DefaultDraggable = React.memo( - ({ id, field, value, name, children, tooltipContent, queryValue }) => + ({ id, field, value, name, children, timelineId, tooltipContent, queryValue }) => value != null ? ( ( ) } + timelineId={timelineId} /> ) : null ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index e01ccf1e544bb6..7b6e9fb21a3e33 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -147,7 +147,6 @@ export const getColumns = ({ data-test-subj="field-name" fieldId={field} onUpdateColumns={onUpdateColumns} - timelineId={contextId} /> )} 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 3e196c4b7bad44..31f7e1b7fac7cb 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 @@ -53,6 +53,7 @@ export interface OwnProps extends QueryTemplateProps { showLegend?: boolean; stackByOptions: MatrixHistogramOption[]; subtitle?: string | GetSubTitle; + timelineId?: string; title: string | GetTitle; type: hostsModel.HostsType | networkModel.NetworkType; } @@ -94,6 +95,7 @@ export const MatrixHistogramComponent: React.FC< stackByOptions, startDate, subtitle, + timelineId, title, titleSize, dispatchSetAbsoluteRangeDatePicker, @@ -242,6 +244,7 @@ export const MatrixHistogramComponent: React.FC< barChart={barChartData} configs={barchartConfigs} stackByField={selectedStackByOption.value} + timelineId={timelineId} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts index a9e6cdd19bb209..f388409b443dbf 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/types.ts @@ -80,11 +80,12 @@ export interface MatrixHistogramQueryProps { } export interface MatrixHistogramProps extends MatrixHistogramBasicProps { + legendPosition?: Position; scaleType?: ScaleType; - yTickFormatter?: (value: number) => string; showLegend?: boolean; showSpacer?: boolean; - legendPosition?: Position; + timelineId?: string; + yTickFormatter?: (value: number) => string; } export interface HistogramBucket { diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index 336f906b3bed04..503e9983692f16 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -15,17 +15,19 @@ import { SUB_PLUGINS_REDUCER, kibanaObservable, createSecuritySolutionStorageMock, + mockIndexPattern, } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; import { createStore, State } from '../../store'; import { Props } from './top_n'; -import { ACTIVE_TIMELINE_REDUX_ID, StatefulTopN } from '.'; +import { StatefulTopN } from '.'; import { ManageGlobalTimeline, timelineDefaults, } from '../../../timelines/components/manage_timeline'; +import { TimelineId } from '../../../../common/types/timeline'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -94,9 +96,9 @@ const state: State = { timeline: { ...mockGlobalState.timeline, timelineById: { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...mockGlobalState.timeline.timelineById.test, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, dataProviders: [ { id: @@ -189,6 +191,9 @@ describe('StatefulTopN', () => { { beforeEach(() => { filterManager = new FilterManager(mockUiSettingsForFilterManager); const manageTimelineForTesting = { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...timelineDefaults, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, filterManager, }, }; @@ -278,6 +283,9 @@ describe('StatefulTopN', () => { { const filterManager = new FilterManager(mockUiSettingsForFilterManager); const manageTimelineForTesting = { - [ACTIVE_TIMELINE_REDUX_ID]: { + [TimelineId.active]: { ...timelineDefaults, - id: ACTIVE_TIMELINE_REDUX_ID, + id: TimelineId.active, filterManager, documentType: 'alerts', }, @@ -356,6 +364,9 @@ describe('StatefulTopN', () => { { // filters that appear at the top of most views in the app, and all the // filters in the active timeline: const mapStateToProps = (state: State) => { - const activeTimeline: TimelineModel = - getTimeline(state, ACTIVE_TIMELINE_REDUX_ID) ?? timelineDefaults; + const activeTimeline: TimelineModel = getTimeline(state, TimelineId.active) ?? timelineDefaults; const activeTimelineFilters = activeTimeline.filters ?? EMPTY_FILTERS; const activeTimelineInput: inputsModel.InputsRange = getInputsTimeline(state); @@ -48,7 +49,7 @@ const makeMapStateToProps = () => { activeTimelineEventType: activeTimeline.eventType, activeTimelineFilters, activeTimelineFrom: activeTimelineInput.timerange.from, - activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, ACTIVE_TIMELINE_REDUX_ID), + activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, TimelineId.active), activeTimelineTo: activeTimelineInput.timerange.to, dataProviders: activeTimeline.dataProviders, globalQuery: getGlobalQuerySelector(state), @@ -64,9 +65,17 @@ const mapDispatchToProps = { setAbsoluteRangeDatePicker: dispatchSetAbsoluteRang const connector = connect(makeMapStateToProps, mapDispatchToProps); +// * `indexToAdd`, which enables the alerts index to be appended to +// the `indexPattern` returned by `useWithSource`, may only be populated when +// this component is rendered in the context of the active timeline. This +// behavior enables the 'All events' view by appending the alerts index +// to the index pattern. interface OwnProps { browserFields: BrowserFields; field: string; + indexPattern: IIndexPattern; + indexToAdd: string[] | null; + timelineId?: string; toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; @@ -83,48 +92,29 @@ const StatefulTopNComponent: React.FC = ({ browserFields, dataProviders, field, + indexPattern, + indexToAdd, globalFilters = EMPTY_FILTERS, globalQuery = EMPTY_QUERY, kqlMode, onFilterAdded, setAbsoluteRangeDatePicker, + timelineId, toggleTopN, value, }) => { const kibana = useKibana(); - // Regarding data from useTimelineTypeContext: - // * `documentType` (e.g. 'alerts') may only be populated in some views, - // e.g. the `Alerts` view on the `Detections` page. - // * `id` (`timelineId`) may only be populated when we are rendered in the - // context of the active timeline. - // * `indexToAdd`, which enables the alerts index to be appended to - // the `indexPattern` returned by `useWithSource`, may only be populated when - // this component is rendered in the context of the active timeline. This - // behavior enables the 'All events' view by appending the alerts index - // to the index pattern. - const { isManagedTimeline, getManageTimelineById } = useManageTimeline(); - const { documentType, id: timelineId, indexToAdd } = useMemo( - () => - isManagedTimeline(ACTIVE_TIMELINE_REDUX_ID) - ? getManageTimelineById(ACTIVE_TIMELINE_REDUX_ID) - : { documentType: null, id: null, indexToAdd: null }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [getManageTimelineById] - ); - const options = getOptions( - timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineEventType : undefined + timelineId === TimelineId.active ? activeTimelineEventType : undefined ); - const { indexPattern } = useWithSource('default', indexToAdd); - return ( {({ from, deleteQuery, setQuery, to }) => ( = ({ : undefined } data-test-subj="top-n" - defaultView={documentType?.toLocaleLowerCase() === 'alerts' ? 'alert' : options[0].value} - deleteQuery={timelineId === ACTIVE_TIMELINE_REDUX_ID ? undefined : deleteQuery} + defaultView={ + timelineId === TimelineId.alertsPage || timelineId === TimelineId.alertsRulesDetailsPage + ? 'alert' + : options[0].value + } + deleteQuery={timelineId === TimelineId.active ? undefined : deleteQuery} field={field} - filters={timelineId === ACTIVE_TIMELINE_REDUX_ID ? EMPTY_FILTERS : globalFilters} - from={timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineFrom : from} + filters={timelineId === TimelineId.active ? EMPTY_FILTERS : globalFilters} + from={timelineId === TimelineId.active ? activeTimelineFrom : from} indexPattern={indexPattern} indexToAdd={indexToAdd} options={options} - query={timelineId === ACTIVE_TIMELINE_REDUX_ID ? EMPTY_QUERY : globalQuery} + query={timelineId === TimelineId.active ? EMPTY_QUERY : globalQuery} setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={ - timelineId === ACTIVE_TIMELINE_REDUX_ID ? 'timeline' : 'global' + timelineId === TimelineId.active ? 'timeline' : 'global' } setQuery={setQuery} - to={timelineId === ACTIVE_TIMELINE_REDUX_ID ? activeTimelineTo : to} + timelineId={timelineId} + to={timelineId === TimelineId.active ? activeTimelineTo : to} toggleTopN={toggleTopN} onFilterAdded={onFilterAdded} value={value} diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index 0ccb7e1e72f1f5..7d19bf21271aa6 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { ActionCreator } from 'typescript-fsa'; @@ -67,6 +67,7 @@ export interface Props { refetch: inputsModel.Refetch; }) => void; to: number; + timelineId?: string; toggleTopN: () => void; onFilterAdded?: () => void; value?: string[] | string | null; @@ -89,12 +90,17 @@ const TopNComponent: React.FC = ({ setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget, setQuery, + timelineId, to, toggleTopN, }) => { const [view, setView] = useState(defaultView); const onViewSelected = useCallback((value: string) => setView(value as EventType), [setView]); + useEffect(() => { + setView(defaultView); + }, [defaultView]); + const headerChildren = useMemo( () => ( = ({ setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} showSpacer={false} + timelineId={timelineId} to={to} /> ) : ( @@ -145,6 +152,7 @@ const TopNComponent: React.FC = ({ setAbsoluteRangeDatePicker={setAbsoluteRangeDatePicker} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} setQuery={setQuery} + timelineId={timelineId} to={to} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx index 8679dae4483321..361779a4a33b2e 100644 --- a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx @@ -5,7 +5,7 @@ */ import { EuiPopover } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { IS_DRAGGING_CLASS_NAME } from '../drag_and_drop/helpers'; @@ -22,6 +22,7 @@ interface Props { * Always show the hover menu contents (default: false) */ alwaysShow?: boolean; + closePopOverTrigger?: boolean; /** * The contents of the hover menu. It is highly recommended you wrap this * content in a `div` with `position: absolute` to prevent it from effecting @@ -47,7 +48,8 @@ interface Props { * provides a signal to the content that the user is in a hover state. */ export const WithHoverActions = React.memo( - ({ alwaysShow = false, hoverContent, render }) => { + ({ alwaysShow = false, closePopOverTrigger, hoverContent, render }) => { + const [isOpen, setIsOpen] = useState(hoverContent != null && alwaysShow); const [showHoverContent, setShowHoverContent] = useState(false); const onMouseEnter = useCallback(() => { // NOTE: the following read from the DOM is expensive, but not as @@ -64,10 +66,16 @@ export const WithHoverActions = React.memo( const content = useMemo(() => <>{render(showHoverContent)}, [render, showHoverContent]); - const isOpen = hoverContent != null && (showHoverContent || alwaysShow); + useEffect(() => { + setIsOpen(hoverContent != null && (showHoverContent || alwaysShow)); + }, [hoverContent, showHoverContent, alwaysShow]); - const popover = useMemo(() => { - return ( + useEffect(() => { + setShowHoverContent(false); + }, [closePopOverTrigger]); + + return ( +
( isOpen={isOpen} panelPaddingSize={!alwaysShow ? 's' : 'none'} > - {isOpen ? hoverContent : null} + {isOpen ? <>{hoverContent} : null} - ); - }, [content, onMouseLeave, isOpen, alwaysShow, hoverContent]); - - return ( -
- {popover}
); } diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 9aa3b007511a10..4f42f20c45ae16 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -97,7 +97,7 @@ export const useWithSource = ( const [configIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const defaultIndex = useMemo(() => { if (indexToAdd != null && !isEmpty(indexToAdd)) { - return [...(!onlyCheckIndexToAdd ? configIndex : []), ...indexToAdd]; + return onlyCheckIndexToAdd ? indexToAdd : [...configIndex, ...indexToAdd]; } return configIndex; }, [configIndex, indexToAdd, onlyCheckIndexToAdd]); @@ -135,41 +135,32 @@ export const useWithSource = ( }, }, }); - if (!isSubscribed) { - return setState((prevState) => ({ - ...prevState, + + if (isSubscribed) { + setState({ loading: false, - })); + indicesExist: indicesExistOrDataTemporarilyUnavailable( + get('data.source.status.indicesExist', result) + ), + browserFields: getBrowserFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + indexPattern: getIndexFields( + defaultIndex.join(), + get('data.source.status.indexFields', result) + ), + errorMessage: null, + }); } - - setState({ - loading: false, - indicesExist: indicesExistOrDataTemporarilyUnavailable( - get('data.source.status.indicesExist', result) - ), - browserFields: getBrowserFields( - defaultIndex.join(), - get('data.source.status.indexFields', result) - ), - indexPattern: getIndexFields( - defaultIndex.join(), - get('data.source.status.indexFields', result) - ), - errorMessage: null, - }); } catch (error) { - if (!isSubscribed) { - return setState((prevState) => ({ + if (isSubscribed) { + setState((prevState) => ({ ...prevState, loading: false, + errorMessage: error.message, })); } - - setState((prevState) => ({ - ...prevState, - loading: false, - errorMessage: error.message, - })); } } diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts index 4e2b11b24e5a98..e84438581fcdec 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.test.ts @@ -26,33 +26,33 @@ describe('Kuery escape', () => { expect(escapeKuery(value)).to.be(expected); }); - it('should escape keywords', () => { + it('should NOT escape keywords', () => { const value = 'foo and bar or baz not qux'; - const expected = 'foo \\and bar \\or baz \\not qux'; + const expected = 'foo and bar or baz not qux'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape keywords next to each other', () => { + it('should NOT escape keywords next to each other', () => { const value = 'foo and bar or not baz'; - const expected = 'foo \\and bar \\or \\not baz'; + const expected = 'foo and bar or not baz'; expect(escapeKuery(value)).to.be(expected); }); it('should not escape keywords without surrounding spaces', () => { const value = 'And this has keywords, or does it not?'; - const expected = 'And this has keywords, \\or does it not?'; + const expected = 'And this has keywords, or does it not?'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape uppercase keywords', () => { + it('should NOT escape uppercase keywords', () => { const value = 'foo AND bar'; - const expected = 'foo \\AND bar'; + const expected = 'foo AND bar'; expect(escapeKuery(value)).to.be(expected); }); - it('should escape both keywords and special characters', () => { + it('should escape special characters and NOT keywords', () => { const value = 'Hello, "world", and to meet you!'; - const expected = 'Hello, \\"world\\", \\and to meet you!'; + const expected = 'Hello, \\"world\\", and to meet you!'; expect(escapeKuery(value)).to.be(expected); }); diff --git a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts index bd4d96a98c8154..b06a6ec10f48ef 100644 --- a/x-pack/plugins/security_solution/public/common/lib/keury/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/keury/index.ts @@ -75,11 +75,12 @@ const escapeWhitespace = (val: string) => const escapeSpecialCharacters = (val: string) => val.replace(/["]/g, '\\$&'); // $& means the whole matched string // See the Keyword rule in kuery.peg -const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); +// I do not think that we need that anymore since we are doing a full match_phrase all the time now => return `"${escapeKuery(val)}"`; +// const escapeAndOr = (val: string) => val.replace(/(\s+)(and|or)(\s+)/gi, '$1\\$2$3'); -const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); +// const escapeNot = (val: string) => val.replace(/not(\s+)/gi, '\\$&'); -export const escapeKuery = flow(escapeSpecialCharacters, escapeAndOr, escapeNot, escapeWhitespace); +export const escapeKuery = flow(escapeSpecialCharacters, escapeWhitespace); export const convertToBuildEsQuery = ({ config, 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 fe3f9f8ecda332..7d42f744a2613e 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 @@ -60,6 +60,7 @@ interface Props { refetch: inputsModel.Refetch; }) => void; showSpacer?: boolean; + timelineId?: string; to: number; } @@ -81,6 +82,7 @@ const EventsByDatasetComponent: React.FC = ({ setAbsoluteRangeDatePickerTarget, setQuery, showSpacer = true, + timelineId, to, }) => { // create a unique, but stable (across re-renders) query id @@ -177,6 +179,7 @@ const EventsByDatasetComponent: React.FC = ({ showSpacer={showSpacer} sourceId="default" startDate={from} + timelineId={timelineId} type={HostsType.page} {...eventsByDatasetHistogramConfigs} title={onlyField != null ? i18n.TOP(onlyField) : eventsByDatasetHistogramConfigs.title} diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 5010fd9c06eb76..41152dabe2ad81 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -37,6 +37,7 @@ interface Props { loading: boolean; refetch: inputsModel.Refetch; }) => void; + timelineId?: string; to: number; } @@ -50,6 +51,7 @@ const SignalsByCategoryComponent: React.FC = ({ setAbsoluteRangeDatePicker, setAbsoluteRangeDatePickerTarget = 'global', setQuery, + timelineId, to, }) => { const { signalIndexName } = useSignalIndex(); @@ -83,6 +85,7 @@ const SignalsByCategoryComponent: React.FC = ({ showLinkToAlerts={onlyField == null ? true : false} stackByOptions={onlyField == null ? alertsHistogramOptions : undefined} legendPosition={'right'} + timelineId={timelineId} to={to} title={i18n.ALERT_COUNT} updateDateRange={updateDateRangeCallback} diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index aaad9cf145ab71..89224347462341 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -152,7 +152,6 @@ export const getFieldItems = ({ fieldId={field.name || ''} highlight={highlight} onUpdateColumns={onUpdateColumns} - timelineId={timelineId} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx index da0cbb99b86717..1f917c664e813a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx @@ -24,7 +24,6 @@ const defaultProps = { }), fieldId: timestampFieldId, onUpdateColumns: jest.fn(), - timelineId: 'timeline-id', }; describe('FieldName', () => { @@ -46,8 +45,7 @@ describe('FieldName', () => { ); - - wrapper.simulate('mouseenter'); + wrapper.find('div').at(1).simulate('mouseenter'); wrapper.update(); expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(true); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx index 985c8b35094ef9..62e41d967cb9ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.tsx @@ -5,13 +5,16 @@ */ import { EuiHighlight, EuiText } from '@elastic/eui'; -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo, useRef } from 'react'; import styled from 'styled-components'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../timeline/events'; import { WithHoverActions } from '../../../common/components/with_hover_actions'; -import { DraggableWrapperHoverContent } from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; +import { + DraggableWrapperHoverContent, + useGetTimelineId, +} from '../../../common/components/drag_and_drop/draggable_wrapper_hover_content'; /** * The name of a (draggable) field @@ -77,23 +80,34 @@ export const FieldName = React.memo<{ fieldId: string; highlight?: string; onUpdateColumns: OnUpdateColumns; - timelineId: string; -}>(({ fieldId, highlight = '', timelineId }) => { +}>(({ fieldId, highlight = '' }) => { + const containerRef = useRef(null); + const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); + const [goGetTimelineId, setGoGetTimelineId] = useState(false); + const timelineIdFind = useGetTimelineId(containerRef, goGetTimelineId); + const toggleTopN = useCallback(() => { - setShowTopN(!showTopN); - }, [setShowTopN, showTopN]); + setShowTopN((prevShowTopN) => !prevShowTopN); + }, []); + + const handleClosePopOverTrigger = useCallback( + () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), + [] + ); const hoverContent = useMemo( () => ( ), - [fieldId, showTopN, toggleTopN, timelineId] + [fieldId, handleClosePopOverTrigger, showTopN, timelineIdFind, toggleTopN] ); const render = useCallback( @@ -109,7 +123,16 @@ export const FieldName = React.memo<{ [fieldId, highlight] ); - return ; + return ( +
+ +
+ ); }); FieldName.displayName = 'FieldName'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index be655f7465a1b8..3b40c36fccd163 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -57,6 +57,11 @@ type ActionManageTimeline = id: string; payload: boolean; } + | { + type: 'SET_INDEX_TO_ADD'; + id: string; + payload: string[]; + } | { type: 'SET_TIMELINE_ACTIONS'; id: string; @@ -81,7 +86,10 @@ export const timelineDefaults = { title: i18n.EVENTS, unit: (n: number) => i18n.UNIT(n), }; -const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTimeline) => { +const reducerManageTimeline = ( + state: ManageTimelineById, + action: ActionManageTimeline +): ManageTimelineById => { switch (action.type) { case 'INITIALIZE_TIMELINE': return { @@ -91,7 +99,15 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], ...action.payload, }, - }; + } as ManageTimelineById; + case 'SET_INDEX_TO_ADD': + return { + ...state, + [action.id]: { + ...state[action.id], + indexToAdd: action.payload, + }, + } as ManageTimelineById; case 'SET_TIMELINE_ACTIONS': case 'SET_TIMELINE_FILTER_MANAGER': return { @@ -100,7 +116,7 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], ...action.payload, }, - }; + } as ManageTimelineById; case 'SET_IS_LOADING': return { ...state, @@ -108,7 +124,7 @@ const reducerManageTimeline = (state: ManageTimelineById, action: ActionManageTi ...state[action.id], isLoading: action.payload, }, - }; + } as ManageTimelineById; default: return state; } @@ -119,6 +135,7 @@ interface UseTimelineManager { getTimelineFilterManager: (id: string) => FilterManager | undefined; initializeTimeline: (newTimeline: ManageTimelineInit) => void; isManagedTimeline: (id: string) => boolean; + setIndexToAdd: (indexToAddArgs: { id: string; indexToAdd: string[] }) => void; setIsTimelineLoading: (isLoadingArgs: { id: string; isLoading: boolean }) => void; setTimelineRowActions: (actionsArgs: { id: string; @@ -129,10 +146,9 @@ interface UseTimelineManager { } const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseTimelineManager => { - const [state, dispatch] = useReducer( - reducerManageTimeline, - manageTimelineForTesting ?? initManageTimeline - ); + const [state, dispatch] = useReducer< + (state: ManageTimelineById, action: ActionManageTimeline) => ManageTimelineById + >(reducerManageTimeline, manageTimelineForTesting ?? initManageTimeline); const initializeTimeline = useCallback((newTimeline: ManageTimelineInit) => { dispatch({ @@ -183,8 +199,16 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT [] ); + const setIndexToAdd = useCallback(({ id, indexToAdd }: { id: string; indexToAdd: string[] }) => { + dispatch({ + type: 'SET_INDEX_TO_ADD', + id, + payload: indexToAdd, + }); + }, []); + const getTimelineFilterManager = useCallback( - (id: string): FilterManager | undefined => state[id].filterManager, + (id: string): FilterManager | undefined => state[id]?.filterManager, [state] ); const getManageTimelineById = useCallback( @@ -195,8 +219,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT initializeTimeline({ id }); return { ...timelineDefaults, id }; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [state] + [initializeTimeline, state] ); const isManagedTimeline = useCallback((id: string): boolean => state[id] != null, [state]); @@ -205,6 +228,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT getTimelineFilterManager, initializeTimeline, isManagedTimeline, + setIndexToAdd, setIsTimelineLoading, setTimelineRowActions, setTimelineFilterManager, @@ -214,6 +238,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT const init = { getManageTimelineById: (id: string) => ({ ...timelineDefaults, id }), getTimelineFilterManager: () => undefined, + setIndexToAdd: () => undefined, isManagedTimeline: () => false, initializeTimeline: () => noop, setIsTimelineLoading: () => noop, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 51bf883ed2d611..43ea5e905ca8be 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -17,6 +17,7 @@ import { columnRenderers, rowRenderers } from './renderers'; import { Sort } from './sort'; import { wait } from '../../../../common/lib/helpers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles'; const testBodyHeight = 700; const mockGetNotesByIds = (eventId: string[]) => []; @@ -133,6 +134,20 @@ describe('Body', () => { ).toEqual(true); }); }, 20000); + + test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_BODY_CLASS_NAME}`) + .first() + .exists() + ).toEqual(true); + }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 46895c86de084a..6a296170fffde7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -139,6 +139,7 @@ export const Body = React.memo( )} ( pinnedEventIds={pinnedEventIds} rowRenderers={showRowRenderers ? rowRenderers : [plainRowRenderer]} selectedEventIds={selectedEventIds} - show={id === ACTIVE_TIMELINE_REDUX_ID ? show : true} + show={id === TimelineId.active ? show : true} showCheckboxes={showCheckboxes} sort={sort} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index d1a58dafcb328f..47d848021ba434 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -14,16 +14,17 @@ import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '../../../common/component /** * TIMELINE BODY */ +export const SELECTOR_TIMELINE_BODY_CLASS_NAME = 'securitySolutionTimeline__body'; // SIDE EFFECT: the following creates a global class selector export const TimelineBodyGlobalStyle = createGlobalStyle` - body.${IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME} .siemTimeline__body { + body.${IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME} .${SELECTOR_TIMELINE_BODY_CLASS_NAME} { overflow: hidden; } `; export const TimelineBody = styled.div.attrs(({ className = '' }) => ({ - className: `siemTimeline__body ${className}`, + className: `${SELECTOR_TIMELINE_BODY_CLASS_NAME} ${className}`, }))<{ bodyHeight?: number; visible: boolean }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; overflow: auto; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 07d4b004d2eda7..18deaf01587239 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -174,6 +174,7 @@ export const TimelineComponent: React.FC = ({ const [isQueryLoading, setIsQueryLoading] = useState(false); const { initializeTimeline, + setIndexToAdd, setIsTimelineLoading, setTimelineFilterManager, setTimelineRowActions, @@ -188,12 +189,14 @@ export const TimelineComponent: React.FC = ({ }, []); useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading || loadingIndexName }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadingIndexName, isQueryLoading]); + }, [loadingIndexName, id, isQueryLoading, setIsTimelineLoading]); useEffect(() => { setTimelineFilterManager({ id, filterManager }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterManager]); + }, [filterManager, id, setTimelineFilterManager]); + + useEffect(() => { + setIndexToAdd({ id, indexToAdd }); + }, [id, indexToAdd, setIndexToAdd]); return ( diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts index 67a331f4ba6771..ace5aec77ed2c5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -57,6 +57,8 @@ export const getPackageConfigCreateCallback = ( try { return updatedPackageConfig; } finally { + // TODO: confirm creation of package config + // then commit. await manifestManager.commit(wrappedManifest); } }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 4c3153ca0ef116..b6a5bed9078ab3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -6,12 +6,12 @@ export const ArtifactConstants = { GLOBAL_ALLOWLIST_NAME: 'endpoint-exceptionlist', - SAVED_OBJECT_TYPE: 'endpoint:exceptions-artifact', + SAVED_OBJECT_TYPE: 'endpoint:user-artifact', SUPPORTED_OPERATING_SYSTEMS: ['linux', 'macos', 'windows'], SCHEMA_VERSION: '1.0.0', }; export const ManifestConstants = { - SAVED_OBJECT_TYPE: 'endpoint:exceptions-manifest', + SAVED_OBJECT_TYPE: 'endpoint:user-artifact-manifest', SCHEMA_VERSION: '1.0.0', }; 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 7fd057afdbd55f..2abb72234fecd0 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 @@ -7,22 +7,20 @@ import { createHash } from 'crypto'; import { validate } from '../../../../common/validate'; -import { - Entry, - EntryNested, - EntryMatch, - EntryMatchAny, -} from '../../../../../lists/common/schemas/types/entries'; +import { Entry, EntryNested } from '../../../../../lists/common/schemas/types/entries'; import { FoundExceptionListItemSchema } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema'; import { ExceptionListClient } from '../../../../../lists/server'; import { InternalArtifactSchema, TranslatedEntry, - TranslatedEntryMatch, - TranslatedEntryMatchAny, - TranslatedEntryNested, WrappedTranslatedExceptionList, wrappedExceptionList, + TranslatedEntryNestedEntry, + translatedEntryNestedEntry, + translatedEntry as translatedEntryType, + TranslatedEntryMatcher, + translatedEntryMatchMatcher, + translatedEntryMatchAnyMatcher, } from '../../schemas'; import { ArtifactConstants } from './common'; @@ -36,11 +34,14 @@ export async function buildArtifact( return { identifier: `${ArtifactConstants.GLOBAL_ALLOWLIST_NAME}-${os}-${schemaVersion}`, - sha256, - encoding: 'application/json', + compressionAlgorithm: 'none', + encryptionAlgorithm: 'none', + decompressedSha256: sha256, + compressedSha256: sha256, + decompressedSize: exceptionsBuffer.byteLength, + compressedSize: exceptionsBuffer.byteLength, created: Date.now(), body: exceptionsBuffer.toString('base64'), - size: exceptionsBuffer.byteLength, }; } @@ -92,66 +93,80 @@ export function translateToEndpointExceptions( exc: FoundExceptionListItemSchema, schemaVersion: string ): TranslatedEntry[] { - const translatedList: TranslatedEntry[] = []; - if (schemaVersion === '1.0.0') { - exc.data.forEach((list) => { - list.entries.forEach((entry) => { - const tEntry = translateEntry(schemaVersion, entry); - if (tEntry !== undefined) { - translatedList.push(tEntry); + return exc.data + .flatMap((list) => { + return list.entries; + }) + .reduce((entries: TranslatedEntry[], entry) => { + const translatedEntry = translateEntry(schemaVersion, entry); + if (translatedEntry !== undefined && translatedEntryType.is(translatedEntry)) { + entries.push(translatedEntry); } - }); - }); + return entries; + }, []); } else { throw new Error('unsupported schemaVersion'); } - return translatedList; +} + +function getMatcherFunction(field: string, matchAny?: boolean): TranslatedEntryMatcher { + return matchAny + ? field.endsWith('.text') + ? 'exact_caseless_any' + : 'exact_cased_any' + : field.endsWith('.text') + ? 'exact_caseless' + : 'exact_cased'; +} + +function normalizeFieldName(field: string): string { + return field.endsWith('.text') ? field.substring(0, field.length - 5) : field; } function translateEntry( schemaVersion: string, entry: Entry | EntryNested ): TranslatedEntry | undefined { - let translatedEntry; switch (entry.type) { case 'nested': { - const e = (entry as unknown) as EntryNested; - const nestedEntries: TranslatedEntry[] = []; - for (const nestedEntry of e.entries) { - const translation = translateEntry(schemaVersion, nestedEntry); - if (translation !== undefined) { - nestedEntries.push(translation); - } - } - translatedEntry = { + const nestedEntries = entry.entries.reduce( + (entries: TranslatedEntryNestedEntry[], nestedEntry) => { + const translatedEntry = translateEntry(schemaVersion, nestedEntry); + if (nestedEntry !== undefined && translatedEntryNestedEntry.is(translatedEntry)) { + entries.push(translatedEntry); + } + return entries; + }, + [] + ); + return { entries: nestedEntries, - field: e.field, + field: entry.field, type: 'nested', - } as TranslatedEntryNested; - break; + }; } case 'match': { - const e = (entry as unknown) as EntryMatch; - translatedEntry = { - field: e.field.endsWith('.text') ? e.field.substring(0, e.field.length - 5) : e.field, - operator: e.operator, - type: e.field.endsWith('.text') ? 'exact_caseless' : 'exact_cased', - value: e.value, - } as TranslatedEntryMatch; - break; + const matcher = getMatcherFunction(entry.field); + return translatedEntryMatchMatcher.is(matcher) + ? { + field: normalizeFieldName(entry.field), + operator: entry.operator, + type: matcher, + value: entry.value, + } + : undefined; + } + case 'match_any': { + const matcher = getMatcherFunction(entry.field, true); + return translatedEntryMatchAnyMatcher.is(matcher) + ? { + field: normalizeFieldName(entry.field), + operator: entry.operator, + type: matcher, + value: entry.value, + } + : undefined; } - case 'match_any': - { - const e = (entry as unknown) as EntryMatchAny; - translatedEntry = { - field: e.field.endsWith('.text') ? e.field.substring(0, e.field.length - 5) : e.field, - operator: e.operator, - type: e.field.endsWith('.text') ? 'exact_caseless_any' : 'exact_cased_any', - value: e.value, - } as TranslatedEntryMatchAny; - } - break; } - return translatedEntry || undefined; } diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts index 0434e3d8ffcb26..da8a449e1b026d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts @@ -55,21 +55,33 @@ describe('manifest', () => { expect(manifest1.toEndpointFormat()).toStrictEqual({ artifacts: { 'endpoint-exceptionlist-linux-1.0.0': { - sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', - size: 268, - url: + compression_algorithm: 'none', + encryption_algorithm: 'none', + precompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + postcompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + precompress_size: 268, + postcompress_size: 268, + relative_url: '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-1.0.0/70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', }, 'endpoint-exceptionlist-macos-1.0.0': { - sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', - size: 268, - url: + compression_algorithm: 'none', + encryption_algorithm: 'none', + precompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + postcompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + precompress_size: 268, + postcompress_size: 268, + relative_url: '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-1.0.0/70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', }, 'endpoint-exceptionlist-windows-1.0.0': { - sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', - size: 268, - url: + compression_algorithm: 'none', + encryption_algorithm: 'none', + precompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + postcompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + precompress_size: 268, + postcompress_size: 268, + relative_url: '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-1.0.0/70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', }, }, @@ -107,7 +119,7 @@ describe('manifest', () => { test('Manifest returns data for given artifact', async () => { const artifact = artifacts[0]; - const returned = manifest1.getArtifact(`${artifact.identifier}-${artifact.sha256}`); + const returned = manifest1.getArtifact(`${artifact.identifier}-${artifact.compressedSha256}`); expect(returned).toEqual(artifact); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.test.ts index 34bd2b0f388e1c..c8cbdfc2fc5f48 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.test.ts @@ -33,13 +33,17 @@ describe('manifest_entry', () => { }); test('Correct sha256 is returned', () => { - expect(manifestEntry.getSha256()).toEqual( + expect(manifestEntry.getCompressedSha256()).toEqual( + '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c' + ); + expect(manifestEntry.getDecompressedSha256()).toEqual( '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c' ); }); test('Correct size is returned', () => { - expect(manifestEntry.getSize()).toEqual(268); + expect(manifestEntry.getCompressedSize()).toEqual(268); + expect(manifestEntry.getDecompressedSize()).toEqual(268); }); test('Correct url is returned', () => { @@ -54,9 +58,13 @@ describe('manifest_entry', () => { test('Correct record is returned', () => { expect(manifestEntry.getRecord()).toEqual({ - sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', - size: 268, - url: + compression_algorithm: 'none', + encryption_algorithm: 'none', + precompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + postcompress_sha256: '70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', + precompress_size: 268, + postcompress_size: 268, + relative_url: '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-1.0.0/70d2e0ee5db0073b242df9af32e64447b932b73c3e66de3a922c61a4077b1a9c', }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.ts index 00fd446bf14b51..860c2d7d704b21 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest_entry.ts @@ -15,23 +15,31 @@ export class ManifestEntry { } public getDocId(): string { - return `${this.getIdentifier()}-${this.getSha256()}`; + return `${this.getIdentifier()}-${this.getCompressedSha256()}`; } public getIdentifier(): string { return this.artifact.identifier; } - public getSha256(): string { - return this.artifact.sha256; + public getCompressedSha256(): string { + return this.artifact.compressedSha256; } - public getSize(): number { - return this.artifact.size; + public getDecompressedSha256(): string { + return this.artifact.decompressedSha256; + } + + public getCompressedSize(): number { + return this.artifact.compressedSize; + } + + public getDecompressedSize(): number { + return this.artifact.decompressedSize; } public getUrl(): string { - return `/api/endpoint/artifacts/download/${this.getIdentifier()}/${this.getSha256()}`; + return `/api/endpoint/artifacts/download/${this.getIdentifier()}/${this.getCompressedSha256()}`; } public getArtifact(): InternalArtifactSchema { @@ -40,9 +48,13 @@ export class ManifestEntry { public getRecord(): ManifestEntrySchema { return { - sha256: this.getSha256(), - size: this.getSize(), - url: this.getUrl(), + compression_algorithm: 'none', + encryption_algorithm: 'none', + precompress_sha256: this.getDecompressedSha256(), + precompress_size: this.getDecompressedSize(), + postcompress_sha256: this.getCompressedSha256(), + postcompress_size: this.getCompressedSize(), + relative_url: this.getUrl(), }; } } diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index d38026fbcbbd90..5e61b278e87e48 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -16,11 +16,27 @@ export const exceptionsArtifactSavedObjectMappings: SavedObjectsType['mappings'] identifier: { type: 'keyword', }, - sha256: { + compressionAlgorithm: { type: 'keyword', + index: false, + }, + encryptionAlgorithm: { + type: 'keyword', + index: false, }, - encoding: { + compressedSha256: { type: 'keyword', + }, + compressedSize: { + type: 'long', + index: false, + }, + decompressedSha256: { + type: 'keyword', + index: false, + }, + decompressedSize: { + type: 'long', index: false, }, created: { @@ -31,10 +47,6 @@ export const exceptionsArtifactSavedObjectMappings: SavedObjectsType['mappings'] type: 'binary', index: false, }, - size: { - type: 'long', - index: false, - }, }, }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts index 08d02e70dac168..78b60e9e61f3ed 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts @@ -14,7 +14,7 @@ import { EndpointAppContext } from '../../types'; export const ManifestTaskConstants = { TIMEOUT: '1m', - TYPE: 'securitySolution:endpoint:exceptions-packager', + TYPE: 'endpoint:user-artifact-packager', VERSION: '1.0.0', }; diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts index 21d1105a313e78..d071896c537bfa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/lists.ts @@ -20,6 +20,9 @@ export const translatedEntryMatchAny = t.exact( ); export type TranslatedEntryMatchAny = t.TypeOf; +export const translatedEntryMatchAnyMatcher = translatedEntryMatchAny.type.props.type; +export type TranslatedEntryMatchAnyMatcher = t.TypeOf; + export const translatedEntryMatch = t.exact( t.type({ field: t.string, @@ -33,11 +36,23 @@ export const translatedEntryMatch = t.exact( ); export type TranslatedEntryMatch = t.TypeOf; +export const translatedEntryMatchMatcher = translatedEntryMatch.type.props.type; +export type TranslatedEntryMatchMatcher = t.TypeOf; + +export const translatedEntryMatcher = t.union([ + translatedEntryMatchMatcher, + translatedEntryMatchAnyMatcher, +]); +export type TranslatedEntryMatcher = t.TypeOf; + +export const translatedEntryNestedEntry = t.union([translatedEntryMatch, translatedEntryMatchAny]); +export type TranslatedEntryNestedEntry = t.TypeOf; + export const translatedEntryNested = t.exact( t.type({ field: t.string, type: t.keyof({ nested: null }), - entries: t.array(t.union([translatedEntryMatch, translatedEntryMatchAny])), + entries: t.array(translatedEntryNestedEntry), }) ); export type TranslatedEntryNested = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts index 2e71ef98387f1f..fe032586dda562 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts @@ -5,17 +5,26 @@ */ import * as t from 'io-ts'; -import { identifier, sha256, size } from '../../../../common/endpoint/schema/common'; -import { body, created, encoding } from './common'; +import { + compressionAlgorithm, + encryptionAlgorithm, + identifier, + sha256, + size, +} from '../../../../common/endpoint/schema/common'; +import { body, created } from './common'; export const internalArtifactSchema = t.exact( t.type({ identifier, - sha256, - encoding, + compressionAlgorithm, + encryptionAlgorithm, + decompressedSha256: sha256, + decompressedSize: size, + compressedSha256: sha256, + compressedSize: size, created, body, - size, }) ); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.ts index 4a3dcaae1bd3d7..00ae802ba6f321 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/artifact_client.ts @@ -16,7 +16,7 @@ export class ArtifactClient { } public getArtifactId(artifact: InternalArtifactSchema) { - return `${artifact.identifier}-${artifact.sha256}`; + return `${artifact.identifier}-${artifact.compressedSha256}`; } public async getArtifact(id: string): Promise> { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index bbb6fdfd508109..ef4f921cb537e0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -38,9 +38,13 @@ describe('manifest_manager', () => { schema_version: '1.0.0', artifacts: { [artifact.identifier]: { - sha256: artifact.sha256, - size: artifact.size, - url: `/api/endpoint/artifacts/download/${artifact.identifier}/${artifact.sha256}`, + compression_algorithm: 'none', + encryption_algorithm: 'none', + precompress_sha256: artifact.decompressedSha256, + postcompress_sha256: artifact.compressedSha256, + precompress_size: artifact.decompressedSize, + postcompress_size: artifact.compressedSize, + relative_url: `/api/endpoint/artifacts/download/${artifact.identifier}/${artifact.compressedSha256}`, }, }, }); 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 33b0d5db575c6c..e47a23b893b711 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 @@ -180,17 +180,15 @@ export class ManifestManager { this.logger.info(`Dispatching new manifest with diffs: ${showDiffs(wrappedManifest.diffs)}`); let paging = true; + let page = 1; let success = true; while (paging) { - const { items, total, page } = await this.packageConfigService.list( - this.savedObjectsClient, - { - page: 1, - perPage: 20, - kuery: 'ingest-package-configs.package.name:endpoint', - } - ); + const { items, total } = await this.packageConfigService.list(this.savedObjectsClient, { + page, + perPage: 20, + kuery: 'ingest-package-configs.package.name:endpoint', + }); for (const packageConfig of items) { const { id, revision, updated_at, updated_by, ...newPackageConfig } = packageConfig; @@ -222,6 +220,7 @@ export class ManifestManager { } paging = page * items.length < total; + page++; } return success ? wrappedManifest : null; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9a3f52a0ce47c8..683d83dde4e0fc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7473,12 +7473,9 @@ "xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription": "指定された条件と一致したログエントリ数", "xpack.infra.logs.alerting.threshold.fired": "実行", "xpack.infra.logs.analysis.analyzeInMlButtonLabel": "ML で分析", - "xpack.infra.logs.analysis.anomaliesExpandedRowNumberOfLogEntriesDescription": "ログエントリーの数です", "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "15 分ごとのログエントリー (平均)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "異常を読み込み中", "xpack.infra.logs.analysis.anomaliesSectionTitle": "異常", - "xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName": "最高異常スコア", - "xpack.infra.logs.analysis.anomaliesTablePartitionColumnName": "パーティション", "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "異常が検出されませんでした。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "表示するデータがありません。", @@ -7505,9 +7502,6 @@ "xpack.infra.logs.analysis.mlUnavailableTitle": "この機能には機械学習が必要です", "xpack.infra.logs.analysis.onboardingSuccessContent": "機械学習ロボットがデータの収集を開始するまでしばらくお待ちください。", "xpack.infra.logs.analysis.onboardingSuccessTitle": "成功!", - "xpack.infra.logs.analysis.overallAnomaliesNumberOfLogEntriesDescription": "ログエントリーの数です", - "xpack.infra.logs.analysis.overallAnomaliesTopAnomalyScoreDescription": "最高異常スコア", - "xpack.infra.logs.analysis.overallAnomalyChartMaxScoresLabel": "最高異常スコア", "xpack.infra.logs.analysis.partitionMaxAnomalyScoreAnnotationLabel": "最高異常スコア: {maxAnomalyScore}", "xpack.infra.logs.analysis.recreateJobButtonLabel": "ML ジョブを再作成", "xpack.infra.logs.analysis.setupStatusTryAgainButton": "再試行", @@ -7552,10 +7546,6 @@ "xpack.infra.logs.logEntryCategories.countColumnTitle": "メッセージ数", "xpack.infra.logs.logEntryCategories.datasetColumnTitle": "データセット", "xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder": "データセットでフィルター", - "xpack.infra.logs.logEntryCategories.exampleEmptyDescription": "選択した時間範囲内に例は見つかりませんでした。ログエントリー保持期間を長くするとメッセージサンプルの可用性が向上します。", - "xpack.infra.logs.logEntryCategories.exampleEmptyReloadButtonLabel": "再読み込み", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureDescription": "カテゴリーの例を読み込めませんでした。", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureRetryButtonLabel": "再試行", "xpack.infra.logs.logEntryCategories.jobStatusLoadingMessage": "分類ジョブのステータスを確認中...", "xpack.infra.logs.logEntryCategories.loadDataErrorTitle": "カテゴリーデータを読み込めませんでした", "xpack.infra.logs.logEntryCategories.manyCategoriesWarningReasonDescription": "分析されたドキュメントごとのカテゴリ比率が{categoriesDocumentRatio, number }で、非常に高い値です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7aebb59e0f8a29..ca065c9523637e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7477,12 +7477,9 @@ "xpack.infra.logs.alerting.threshold.documentCountActionVariableDescription": "匹配所提供条件的日志条目数", "xpack.infra.logs.alerting.threshold.fired": "已触发", "xpack.infra.logs.analysis.analyzeInMlButtonLabel": "在 ML 中分析", - "xpack.infra.logs.analysis.anomaliesExpandedRowNumberOfLogEntriesDescription": "日志条目数", "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "正在加载异常", "xpack.infra.logs.analysis.anomaliesSectionTitle": "异常", - "xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName": "最大异常分数", - "xpack.infra.logs.analysis.anomaliesTablePartitionColumnName": "分区", "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "未检测到任何异常。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "您可能想调整时间范围。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "没有可显示的数据。", @@ -7509,9 +7506,6 @@ "xpack.infra.logs.analysis.mlUnavailableTitle": "此功能需要 Machine Learning", "xpack.infra.logs.analysis.onboardingSuccessContent": "请注意,我们的 Machine Learning 机器人若干分钟后才会开始收集数据。", "xpack.infra.logs.analysis.onboardingSuccessTitle": "成功!", - "xpack.infra.logs.analysis.overallAnomaliesNumberOfLogEntriesDescription": "日志条目数", - "xpack.infra.logs.analysis.overallAnomaliesTopAnomalyScoreDescription": "最大异常分数", - "xpack.infra.logs.analysis.overallAnomalyChartMaxScoresLabel": "最大异常分数:", "xpack.infra.logs.analysis.partitionMaxAnomalyScoreAnnotationLabel": "最大异常分数:{maxAnomalyScore}", "xpack.infra.logs.analysis.recreateJobButtonLabel": "重新创建 ML 作业", "xpack.infra.logs.analysis.setupStatusTryAgainButton": "重试", @@ -7556,10 +7550,6 @@ "xpack.infra.logs.logEntryCategories.countColumnTitle": "消息计数", "xpack.infra.logs.logEntryCategories.datasetColumnTitle": "数据集", "xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder": "按数据集筛选", - "xpack.infra.logs.logEntryCategories.exampleEmptyDescription": "选定时间范围内未找到任何示例。增大日志条目保留期限以改善消息样例可用性。", - "xpack.infra.logs.logEntryCategories.exampleEmptyReloadButtonLabel": "重新加载", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureDescription": "无法加载类别示例。", - "xpack.infra.logs.logEntryCategories.exampleLoadingFailureRetryButtonLabel": "重试", "xpack.infra.logs.logEntryCategories.jobStatusLoadingMessage": "正在检查归类作业的状态......", "xpack.infra.logs.logEntryCategories.loadDataErrorTitle": "无法加载类别数据", "xpack.infra.logs.logEntryCategories.manyCategoriesWarningReasonDescription": "每个分析文档的类别比率非常高,达到 {categoriesDocumentRatio, number }。", diff --git a/x-pack/test/functional/apps/transform/index.ts b/x-pack/test/functional/apps/transform/index.ts index 04a751279bf3ca..6dd22a1f4a204d 100644 --- a/x-pack/test/functional/apps/transform/index.ts +++ b/x-pack/test/functional/apps/transform/index.ts @@ -5,9 +5,10 @@ */ import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, loadTestFile }: FtrProviderContext) { +export default function ({ getService, loadTestFile, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const transform = getService('transform'); + const PageObjects = getPageObjects(['security']); describe('transform', function () { this.tags(['ciGroup9', 'transform']); @@ -30,6 +31,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.unload('ml/ecommerce'); await transform.testResources.resetKibanaTimeZone(); + await PageObjects.security.logout(); }); loadTestFile(require.resolve('./creation_index_pattern')); diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index a886b60e7e0dcc..b156f2f6cc7bf9 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -1,20 +1,23 @@ { "type": "doc", "value": { - "id": "endpoint:exceptions-artifact:endpoint-exceptionlist-linux-1.0.0-a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d", + "id": "endpoint:user-artifact:endpoint-exceptionlist-linux-1.0.0-a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d", "index": ".kibana", "source": { "references": [ ], - "endpoint:exceptions-artifact": { + "endpoint:user-artifact": { "body": "eyJleGNlcHRpb25zX2xpc3QiOltdfQ==", "created": 1593016187465, - "encoding": "application/json", + "compressionAlgorithm": "none", + "encryptionAlgorithm": "none", "identifier": "endpoint-exceptionlist-linux-1.0.0", - "sha256": "a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d", - "size": 22 + "compressedSha256": "a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d", + "compressedSize": 22, + "decompressedSha256": "a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d", + "decompressedSize": 22 }, - "type": "endpoint:exceptions-artifact", + "type": "endpoint:user-artifact", "updated_at": "2020-06-24T16:29:47.584Z" } } @@ -23,12 +26,12 @@ { "type": "doc", "value": { - "id": "endpoint:exceptions-manifest:endpoint-manifest-1.0.0", + "id": "endpoint:user-artifact-manifest:endpoint-manifest-1.0.0", "index": ".kibana", "source": { "references": [ ], - "endpoint:exceptions-manifest": { + "endpoint:user-artifact-manifest": { "created": 1593183699663, "ids": [ "endpoint-exceptionlist-linux-1.0.0-a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d", @@ -36,7 +39,7 @@ "endpoint-exceptionlist-windows-1.0.0-a4e4586e895fcb46dd25a25358b446f9a425279452afa3ef9a98bca39c39122d" ] }, - "type": "endpoint:exceptions-manifest", + "type": "endpoint:user-artifact-manifest", "updated_at": "2020-06-26T15:01:39.704Z" } } diff --git a/yarn.lock b/yarn.lock index 02f7e90ab7d24b..7e44780389531c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2144,10 +2144,10 @@ dependencies: "@elastic/apm-rum-core" "^5.3.0" -"@elastic/charts@19.6.3": - version "19.6.3" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-19.6.3.tgz#c23a1d7a8e245b1a800a3a4ef5fc4378b0da5e74" - integrity sha512-lB+rOODUKYZvsWCAcCxtAu8UxdZ2yIjZs+cjXwO1SlngY+jo+gc6XoEZG4kAczRPcr6cMdHesZ8LmFr3Enle5Q== +"@elastic/charts@19.7.0": + version "19.7.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-19.7.0.tgz#86cdee072d70e641135de99646c90359992bfdf0" + integrity sha512-oNAPOpI9OkuX/pWL+SGShcmdAUB1mwbOyJnp9/PHFqXtARg3aaiTDD0olZUuynGKd6DWnN8mEAiwoe7nsWGP9g== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0"