diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fe89a23ee3573..51968181ef567 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,8 +15,6 @@ /test/functional/apps/discover/ @elastic/kibana-data-discovery /test/functional/apps/context/ @elastic/kibana-data-discovery /test/api_integration/apis/unified_field_list/ @elastic/kibana-data-discovery -/x-pack/plugins/graph/ @elastic/kibana-data-discovery -/x-pack/test/functional/apps/graph @elastic/kibana-data-discovery /src/plugins/unified_field_list/ @elastic/kibana-data-discovery /src/plugins/unified_histogram/ @elastic/kibana-data-discovery /src/plugins/saved_objects_finder/ @elastic/kibana-data-discovery @@ -49,6 +47,8 @@ /test/functional/apps/visualize/ @elastic/kibana-visualizations /src/plugins/expressions/ @elastic/kibana-visualizations /src/plugins/unified_search/ @elastic/kibana-visualizations +/x-pack/plugins/graph/ @elastic/kibana-visualizations +/x-pack/test/functional/apps/graph @elastic/kibana-visualizations # Application Services /examples/dashboard_embeddable_examples/ @elastic/kibana-app-services diff --git a/packages/core/apps/core-apps-browser-internal/src/status/lib/load_status.test.ts b/packages/core/apps/core-apps-browser-internal/src/status/lib/load_status.test.ts index dd750a56fbf2d..54fb3ed083105 100644 --- a/packages/core/apps/core-apps-browser-internal/src/status/lib/load_status.test.ts +++ b/packages/core/apps/core-apps-browser-internal/src/status/lib/load_status.test.ts @@ -62,17 +62,9 @@ const mockedResponse: StatusResponse = { }, }, elasticsearch_client: { - protocol: 'https', - connectedNodes: 3, - nodesWithActiveSockets: 3, - nodesWithIdleSockets: 1, totalActiveSockets: 25, totalIdleSockets: 2, totalQueuedRequests: 0, - mostActiveNodeSockets: 15, - averageActiveSocketsPerNode: 8, - mostIdleNodeSockets: 2, - averageIdleSocketsPerNode: 0.5, }, process: { pid: 1, diff --git a/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.test.ts b/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.test.ts index 513bf2caa8545..d72a33dd7a8a1 100644 --- a/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.test.ts +++ b/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.test.ts @@ -32,7 +32,7 @@ describe('getAgentsSocketsStats()', () => { }, }); - const agent2 = getHttpAgentMock({ + const agent2 = getHttpsAgentMock({ sockets: { node1: [mockSocket, mockSocket, mockSocket], node4: [mockSocket], @@ -47,101 +47,9 @@ describe('getAgentsSocketsStats()', () => { const stats = getAgentsSocketsStats(new Set([agent1, agent2])); expect(stats).toEqual({ - averageActiveSocketsPerNode: 2.6666666666666665, - averageIdleSocketsPerNode: 4.5, - connectedNodes: 4, - mostActiveNodeSockets: 6, - mostIdleNodeSockets: 8, - nodesWithActiveSockets: 3, - nodesWithIdleSockets: 2, - protocol: 'http', totalActiveSockets: 8, totalIdleSockets: 9, totalQueuedRequests: 6, }); }); - - it('takes into account Agent types to determine the `protocol`', () => { - const httpAgent = getHttpAgentMock({ - sockets: { node1: [mockSocket] }, - freeSockets: {}, - requests: {}, - }); - - const httpsAgent = getHttpsAgentMock({ - sockets: { node1: [mockSocket] }, - freeSockets: {}, - requests: {}, - }); - - const noAgents = new Set(); - const httpAgents = new Set([httpAgent, httpAgent]); - const httpsAgents = new Set([httpsAgent, httpsAgent]); - const mixedAgents = new Set([httpAgent, httpsAgent]); - - expect(getAgentsSocketsStats(noAgents).protocol).toEqual('none'); - expect(getAgentsSocketsStats(httpAgents).protocol).toEqual('http'); - expect(getAgentsSocketsStats(httpsAgents).protocol).toEqual('https'); - expect(getAgentsSocketsStats(mixedAgents).protocol).toEqual('mixed'); - }); - - it('does not take into account those Agents that have not had any connection to any node', () => { - const pristineAgentProps = { - sockets: {}, - freeSockets: {}, - requests: {}, - }; - const agent1 = getHttpAgentMock(pristineAgentProps); - const agent2 = getHttpAgentMock(pristineAgentProps); - const agent3 = getHttpAgentMock(pristineAgentProps); - - const stats = getAgentsSocketsStats(new Set([agent1, agent2, agent3])); - - expect(stats).toEqual({ - averageActiveSocketsPerNode: 0, - averageIdleSocketsPerNode: 0, - connectedNodes: 0, - mostActiveNodeSockets: 0, - mostIdleNodeSockets: 0, - nodesWithActiveSockets: 0, - nodesWithIdleSockets: 0, - protocol: 'none', - totalActiveSockets: 0, - totalIdleSockets: 0, - totalQueuedRequests: 0, - }); - }); - - it('takes into account those Agents that have hold mappings to one or more nodes, but that do not currently have any pending requests, active connections or idle connections', () => { - const emptyAgentProps = { - sockets: { - node1: [], - }, - freeSockets: { - node2: [], - }, - requests: { - node3: [], - }, - }; - - const agent1 = getHttpAgentMock(emptyAgentProps); - const agent2 = getHttpAgentMock(emptyAgentProps); - - const stats = getAgentsSocketsStats(new Set([agent1, agent2])); - - expect(stats).toEqual({ - averageActiveSocketsPerNode: 0, - averageIdleSocketsPerNode: 0, - connectedNodes: 3, - mostActiveNodeSockets: 0, - mostIdleNodeSockets: 0, - nodesWithActiveSockets: 0, - nodesWithIdleSockets: 0, - protocol: 'http', - totalActiveSockets: 0, - totalIdleSockets: 0, - totalQueuedRequests: 0, - }); - }); }); diff --git a/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.ts b/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.ts index e28c92a56a8a4..e513528c07697 100644 --- a/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.ts +++ b/packages/core/metrics/core-metrics-collectors-server-internal/src/get_agents_sockets_stats.ts @@ -7,23 +7,13 @@ */ import { NetworkAgent } from '@kbn/core-elasticsearch-client-server-internal'; -import { Agent as HttpsAgent } from 'https'; -import { mean } from 'lodash'; -import type { - ElasticsearchClientProtocol, - ElasticsearchClientsMetrics, -} from '@kbn/core-metrics-server'; +import type { ElasticsearchClientsMetrics } from '@kbn/core-metrics-server'; export const getAgentsSocketsStats = (agents: Set): ElasticsearchClientsMetrics => { const nodes = new Set(); let totalActiveSockets = 0; let totalIdleSockets = 0; let totalQueuedRequests = 0; - let http: boolean = false; - let https: boolean = false; - - const nodesWithActiveSockets: Record = {}; - const nodesWithIdleSockets: Record = {}; agents.forEach((agent) => { const agentRequests = Object.entries(agent.requests) ?? []; @@ -31,9 +21,6 @@ export const getAgentsSocketsStats = (agents: Set): ElasticsearchC const agentFreeSockets = Object.entries(agent.freeSockets) ?? []; if (agentRequests.length || agentSockets.length || agentFreeSockets.length) { - if (agent instanceof HttpsAgent) https = true; - else http = true; - agentRequests.forEach(([node, queue]) => { nodes.add(node); totalQueuedRequests += queue?.length ?? 0; @@ -43,39 +30,19 @@ export const getAgentsSocketsStats = (agents: Set): ElasticsearchC nodes.add(node); const activeSockets = sockets?.length ?? 0; totalActiveSockets += activeSockets; - nodesWithActiveSockets[node] = (nodesWithActiveSockets[node] ?? 0) + activeSockets; }); agentFreeSockets.forEach(([node, freeSockets]) => { nodes.add(node); const idleSockets = freeSockets?.length ?? 0; totalIdleSockets += idleSockets; - nodesWithIdleSockets[node] = (nodesWithIdleSockets[node] ?? 0) + idleSockets; }); } }); - const activeSocketCounters = Object.values(nodesWithActiveSockets); - const idleSocketCounters = Object.values(nodesWithIdleSockets); - const protocol: ElasticsearchClientProtocol = http - ? https - ? 'mixed' - : 'http' - : https - ? 'https' - : 'none'; - return { - protocol, - connectedNodes: nodes.size, - nodesWithActiveSockets: activeSocketCounters.filter(Boolean).length, - nodesWithIdleSockets: idleSocketCounters.filter(Boolean).length, totalActiveSockets, totalIdleSockets, totalQueuedRequests, - mostActiveNodeSockets: activeSocketCounters.length ? Math.max(...activeSocketCounters) : 0, - averageActiveSocketsPerNode: activeSocketCounters.length ? mean(activeSocketCounters) : 0, - mostIdleNodeSockets: idleSocketCounters.length ? Math.max(...idleSocketCounters) : 0, - averageIdleSocketsPerNode: idleSocketCounters.length ? mean(idleSocketCounters) : 0, }; }; diff --git a/packages/core/metrics/core-metrics-server-mocks/src/metrics_service.mock.ts b/packages/core/metrics/core-metrics-server-mocks/src/metrics_service.mock.ts index 44601caeaa85c..cd49d704d0afc 100644 --- a/packages/core/metrics/core-metrics-server-mocks/src/metrics_service.mock.ts +++ b/packages/core/metrics/core-metrics-server-mocks/src/metrics_service.mock.ts @@ -24,17 +24,9 @@ import type { } from '@kbn/core-metrics-server'; export const sampleEsClientMetrics: ElasticsearchClientsMetrics = { - protocol: 'https', - connectedNodes: 3, - nodesWithActiveSockets: 3, - nodesWithIdleSockets: 1, totalActiveSockets: 25, totalIdleSockets: 2, totalQueuedRequests: 0, - mostActiveNodeSockets: 15, - averageActiveSocketsPerNode: 8, - mostIdleNodeSockets: 2, - averageIdleSocketsPerNode: 0.5, }; const createInternalSetupContractMock = () => { diff --git a/packages/core/metrics/core-metrics-server/src/metrics.ts b/packages/core/metrics/core-metrics-server/src/metrics.ts index 958f6b75f55e4..c20b2eb78d5d3 100644 --- a/packages/core/metrics/core-metrics-server/src/metrics.ts +++ b/packages/core/metrics/core-metrics-server/src/metrics.ts @@ -52,30 +52,12 @@ export type ElasticsearchClientProtocol = 'none' | 'http' | 'https' | 'mixed'; * @public */ export interface ElasticsearchClientsMetrics { - /** The protocol (or protocols) that these Agents are using */ - protocol: ElasticsearchClientProtocol; - /** Number of ES nodes that ES-js client is connecting to */ - connectedNodes: number; - /** Number of nodes with active connections */ - nodesWithActiveSockets: number; - /** Number of nodes with available connections (alive but idle). - * Note that a node can have both active and idle connections at the same time - */ - nodesWithIdleSockets: number; /** Total number of active sockets (all nodes, all connections) */ totalActiveSockets: number; /** Total number of available sockets (alive but idle, all nodes, all connections) */ totalIdleSockets: number; /** Total number of queued requests (all nodes, all connections) */ totalQueuedRequests: number; - /** Number of active connections of the node with most active connections */ - mostActiveNodeSockets: number; - /** Average of active sockets per node (all connections) */ - averageActiveSocketsPerNode: number; - /** Number of idle connections of the node with most idle connections */ - mostIdleNodeSockets: number; - /** Average of available (idle) sockets per node (all connections) */ - averageIdleSocketsPerNode: number; } /** diff --git a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts index eb9d10f63ca17..1f0a043782311 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts @@ -130,7 +130,7 @@ describe('checking migration metadata changes on all registered SO types', () => "siem-ui-timeline-pinned-event": "e2697b38751506c7fce6e8b7207a830483dc4283", "space": "c4a0acce1bd4b9cce85154f2a350624a53111c59", "spaces-usage-stats": "922d3235bbf519e3fb3b260e27248b1df8249b79", - "synthetics-monitor": "30f1cd04016a37095de60554cbf7fff89aaad177", + "synthetics-monitor": "111811218f7e34f40980665a4eb99976f457bb23", "synthetics-privates-locations": "dd00385f4a27ef062c3e57312eeb3799872fa4af", "tag": "39413f4578cc2128c9a0fda97d0acd1c8862c47a", "task": "ef53d0f070bd54957b8fe22fae3b1ff208913f76", diff --git a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/__snapshots__/ops_stats_collector.test.ts.snap b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/__snapshots__/ops_stats_collector.test.ts.snap index d77d43293480b..9005e4ba24bf7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ops_stats/__snapshots__/ops_stats_collector.test.ts.snap +++ b/src/plugins/kibana_usage_collection/server/collectors/ops_stats/__snapshots__/ops_stats_collector.test.ts.snap @@ -4,14 +4,6 @@ exports[`telemetry_ops_stats should return something when there is a metric 1`] Object { "concurrent_connections": 1, "elasticsearch_client": Object { - "averageActiveSocketsPerNode": 8, - "averageIdleSocketsPerNode": 0.5, - "connectedNodes": 3, - "mostActiveNodeSockets": 15, - "mostIdleNodeSockets": 2, - "nodesWithActiveSockets": 3, - "nodesWithIdleSockets": 1, - "protocol": "https", "totalActiveSockets": 25, "totalIdleSockets": 2, "totalQueuedRequests": 0, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index a6509c6779197..698abdfb14a9a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -190,12 +190,13 @@ export class APMPlugin if (plugins.alerting) { registerApmRuleTypes({ - ruleDataClient, alerting: plugins.alerting, - ml: plugins.ml, + basePath: core.http.basePath, config$, logger: this.logger!.get('rule'), - basePath: core.http.basePath, + ml: plugins.ml, + observability: plugins.observability, + ruleDataClient, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/action_variables.ts b/x-pack/plugins/apm/server/routes/alerts/action_variables.ts index ce78dbc7bee6d..bf0453811b205 100644 --- a/x-pack/plugins/apm/server/routes/alerts/action_variables.ts +++ b/x-pack/plugins/apm/server/routes/alerts/action_variables.ts @@ -8,19 +8,15 @@ import { i18n } from '@kbn/i18n'; export const apmActionVariables = { - serviceName: { - description: i18n.translate( - 'xpack.apm.alerts.action_variables.serviceName', - { defaultMessage: 'The service the alert is created for' } - ), - name: 'serviceName' as const, - }, - transactionType: { + alertDetailsUrl: { description: i18n.translate( - 'xpack.apm.alerts.action_variables.transactionType', - { defaultMessage: 'The transaction type the alert is created for' } + 'xpack.apm.alerts.action_variables.alertDetailsUrl', + { + defaultMessage: + 'Link to the view within Elastic that shows further details and context surrounding this alert', + } ), - name: 'transactionType' as const, + name: 'alertDetailsUrl' as const, }, environment: { description: i18n.translate( @@ -29,23 +25,6 @@ export const apmActionVariables = { ), name: 'environment' as const, }, - threshold: { - description: i18n.translate('xpack.apm.alerts.action_variables.threshold', { - defaultMessage: - 'Any trigger value above this value will cause the alert to fire', - }), - name: 'threshold' as const, - }, - triggerValue: { - description: i18n.translate( - 'xpack.apm.alerts.action_variables.triggerValue', - { - defaultMessage: - 'The value that breached the threshold and triggered the alert', - } - ), - name: 'triggerValue' as const, - }, interval: { description: i18n.translate( 'xpack.apm.alerts.action_variables.intervalSize', @@ -65,6 +44,37 @@ export const apmActionVariables = { ), name: 'reason' as const, }, + serviceName: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.serviceName', + { defaultMessage: 'The service the alert is created for' } + ), + name: 'serviceName' as const, + }, + threshold: { + description: i18n.translate('xpack.apm.alerts.action_variables.threshold', { + defaultMessage: + 'Any trigger value above this value will cause the alert to fire', + }), + name: 'threshold' as const, + }, + transactionType: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.transactionType', + { defaultMessage: 'The transaction type the alert is created for' } + ), + name: 'transactionType' as const, + }, + triggerValue: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.triggerValue', + { + defaultMessage: + 'The value that breached the threshold and triggered the alert', + } + ), + name: 'triggerValue' as const, + }, viewInAppUrl: { description: i18n.translate( 'xpack.apm.alerts.action_variables.viewInAppUrl', diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts index e327970198f80..b2abf6b7ed126 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts @@ -8,6 +8,7 @@ import { Observable } from 'rxjs'; import { IBasePath, Logger } from '@kbn/core/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '@kbn/alerting-plugin/server'; +import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; import { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import { MlPluginSetup } from '@kbn/ml-plugin/server'; import { registerTransactionDurationRuleType } from './rule_types/transaction_duration/register_transaction_duration_rule_type'; @@ -17,12 +18,13 @@ import { APMConfig } from '../..'; import { registerTransactionErrorRateRuleType } from './rule_types/transaction_error_rate/register_transaction_error_rate_rule_type'; export interface RegisterRuleDependencies { - ruleDataClient: IRuleDataClient; - ml?: MlPluginSetup; alerting: AlertingPluginSetupContract; + basePath: IBasePath; config$: Observable; logger: Logger; - basePath: IBasePath; + ml?: MlPluginSetup; + observability: ObservabilityPluginSetup; + ruleDataClient: IRuleDataClient; } export function registerApmRuleTypes(dependencies: RegisterRuleDependencies) { diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts index 875dce26c40fc..ecc93362f400d 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts @@ -194,6 +194,9 @@ describe('Transaction duration anomaly alert', () => { ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), serviceName: 'foo', transactionType: 'type-foo', environment: 'development', diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts index d85b8df2798fe..c2e4191fc49f5 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts @@ -4,27 +4,23 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import datemath from '@kbn/datemath'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { schema } from '@kbn/config-schema'; +import { KibanaRequest } from '@kbn/core/server'; +import datemath from '@kbn/datemath'; +import type { ESSearchResponse } from '@kbn/es-types'; +import { getAlertDetailsUrl } from '@kbn/infra-plugin/server/lib/alerting/common/utils'; +import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { termQuery } from '@kbn/observability-plugin/server'; import { ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, ALERT_REASON, ALERT_SEVERITY, } from '@kbn/rule-data-utils'; -import { compact } from 'lodash'; -import type { ESSearchResponse } from '@kbn/es-types'; -import { KibanaRequest } from '@kbn/core/server'; -import { termQuery } from '@kbn/observability-plugin/server'; import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; -import { ProcessorEvent } from '@kbn/observability-plugin/common'; -import { - ApmRuleType, - RULE_TYPES_CONFIG, - ANOMALY_ALERT_SEVERITY_TYPES, - formatAnomalyReason, -} from '../../../../../common/rules/apm_rule_types'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; +import { compact } from 'lodash'; import { getSeverity } from '../../../../../common/anomaly_detection'; import { ApmMlDetectorType, @@ -41,6 +37,12 @@ import { getEnvironmentLabel, } from '../../../../../common/environment_filter_values'; import { ANOMALY_SEVERITY } from '../../../../../common/ml_constants'; +import { + ANOMALY_ALERT_SEVERITY_TYPES, + ApmRuleType, + formatAnomalyReason, + RULE_TYPES_CONFIG, +} from '../../../../../common/rules/apm_rule_types'; import { asMutableArray } from '../../../../../common/utils/as_mutable_array'; import { getAlertUrlTransaction } from '../../../../../common/utils/formatters'; import { getMLJobs } from '../../../service_map/get_service_anomalies'; @@ -65,12 +67,13 @@ const paramsSchema = schema.object({ const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.Anomaly]; export function registerAnomalyRuleType({ - logger, - ruleDataClient, - config$, alerting, - ml, basePath, + config$, + logger, + ml, + observability, + ruleDataClient, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ logger, @@ -88,32 +91,38 @@ export function registerAnomalyRuleType({ }, actionVariables: { context: [ - apmActionVariables.serviceName, - apmActionVariables.transactionType, + ...(observability.getAlertDetailsConfig()?.apm.enabled + ? [apmActionVariables.alertDetailsUrl] + : []), apmActionVariables.environment, + apmActionVariables.reason, + apmActionVariables.serviceName, apmActionVariables.threshold, + apmActionVariables.transactionType, apmActionVariables.triggerValue, - apmActionVariables.reason, apmActionVariables.viewInAppUrl, ], }, producer: 'apm', minimumLicenseRequired: 'basic', isExportable: true, - executor: async ({ services, params }) => { + executor: async ({ params, services, spaceId }) => { if (!ml) { return {}; } + const { savedObjectsClient, scopedClusterClient, getAlertUuid } = + services; + const ruleParams = params; const request = {} as KibanaRequest; const { mlAnomalySearch } = ml.mlSystemProvider( request, - services.savedObjectsClient + savedObjectsClient ); const anomalyDetectors = ml.anomalyDetectorsProvider( request, - services.savedObjectsClient + savedObjectsClient ); const mlJobs = await getMLJobs( @@ -254,8 +263,8 @@ export function registerAnomalyRuleType({ const eventSourceFields = await getServiceGroupFieldsForAnomaly({ config$, - scopedClusterClient: services.scopedClusterClient, - savedObjectsClient: services.savedObjectsClient, + scopedClusterClient, + savedObjectsClient, serviceName, environment, transactionType, @@ -272,28 +281,38 @@ export function registerAnomalyRuleType({ windowUnit: params.windowUnit, }); + const id = [ + ApmRuleType.Anomaly, + serviceName, + environment, + transactionType, + ] + .filter((name) => name) + .join('_'); + const relativeViewInAppUrl = getAlertUrlTransaction( serviceName, getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], transactionType ); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL( - basePath.prepend(relativeViewInAppUrl), - basePath.publicBaseUrl - ).toString() - : relativeViewInAppUrl; + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + relativeViewInAppUrl + ); + + const alertUuid = getAlertUuid(id); + + const alertDetailsUrl = getAlertDetailsUrl( + basePath, + spaceId, + alertUuid + ); + services .alertWithLifecycle({ - id: [ - ApmRuleType.Anomaly, - serviceName, - environment, - transactionType, - ] - .filter((name) => name) - .join('_'), + id, fields: { [SERVICE_NAME]: serviceName, ...getEnvironmentEsField(environment), @@ -307,12 +326,13 @@ export function registerAnomalyRuleType({ }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - serviceName, - transactionType, + alertDetailsUrl, environment: getEnvironmentLabel(environment), + reason: reasonMessage, + serviceName, threshold: selectedOption?.label, + transactionType, triggerValue: severityLevel, - reason: reasonMessage, viewInAppUrl, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts index 342832b7e4099..705804cfa74f4 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts @@ -138,6 +138,9 @@ describe('Error count alert', () => { expect(scheduleActions).toHaveBeenCalledTimes(3); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), serviceName: 'foo', environment: 'env-foo', threshold: 2, @@ -148,6 +151,9 @@ describe('Error count alert', () => { 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), serviceName: 'foo', environment: 'env-foo-2', threshold: 2, @@ -158,6 +164,9 @@ describe('Error count alert', () => { 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), serviceName: 'bar', environment: 'env-bar', reason: 'Error count is 3 in the last 5 mins for bar. Alert when > 2.', diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts index d9826aae392c8..648aa857870cc 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts @@ -15,6 +15,9 @@ import { import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; import { termQuery } from '@kbn/observability-plugin/server'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { getAlertDetailsUrl } from '@kbn/infra-plugin/server/lib/alerting/common/utils'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; + import { ENVIRONMENT_NOT_DEFINED, getEnvironmentEsField, @@ -54,10 +57,11 @@ const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.ErrorCount]; export function registerErrorCountRuleType({ alerting, + basePath, + config$, logger, + observability, ruleDataClient, - config$, - basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -75,24 +79,30 @@ export function registerErrorCountRuleType({ }, actionVariables: { context: [ - apmActionVariables.serviceName, + ...(observability.getAlertDetailsConfig()?.apm.enabled + ? [apmActionVariables.alertDetailsUrl] + : []), apmActionVariables.environment, - apmActionVariables.threshold, - apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.serviceName, + apmActionVariables.threshold, + apmActionVariables.triggerValue, apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, - executor: async ({ services, params: ruleParams }) => { + executor: async ({ params: ruleParams, services, spaceId }) => { const config = await firstValueFrom(config$); + const { getAlertUuid, savedObjectsClient, scopedClusterClient } = + services; + const indices = await getApmIndices({ config, - savedObjectsClient: services.savedObjectsClient, + savedObjectsClient, }); const searchParams = { @@ -138,7 +148,7 @@ export function registerErrorCountRuleType({ }; const response = await alertingEsClient({ - scopedClusterClient: services.scopedClusterClient, + scopedClusterClient, params: searchParams, }); @@ -166,23 +176,31 @@ export function registerErrorCountRuleType({ windowUnit: ruleParams.windowUnit, }); + const id = [ApmRuleType.ErrorCount, serviceName, environment] + .filter((name) => name) + .join('_'); + const relativeViewInAppUrl = getAlertUrlErrorCount( serviceName, getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT] ); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL( - basePath.prepend(relativeViewInAppUrl), - basePath.publicBaseUrl - ).toString() - : relativeViewInAppUrl; + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + relativeViewInAppUrl + ); + + const alertUuid = getAlertUuid(id); + const alertDetailsUrl = getAlertDetailsUrl( + basePath, + spaceId, + alertUuid + ); services .alertWithLifecycle({ - id: [ApmRuleType.ErrorCount, serviceName, environment] - .filter((name) => name) - .join('_'), + id, fields: { [SERVICE_NAME]: serviceName, ...getEnvironmentEsField(environment), @@ -194,12 +212,13 @@ export function registerErrorCountRuleType({ }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - serviceName, + alertDetailsUrl, environment: getEnvironmentLabel(environment), - threshold: ruleParams.threshold, - triggerValue: errorCount, interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: alertReason, + serviceName, + threshold: ruleParams.threshold, + triggerValue: errorCount, viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts index 2b159e7acc0d2..1066eb8a987d8 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts @@ -9,7 +9,7 @@ import { registerTransactionDurationRuleType } from './register_transaction_dura import { createRuleTypeMocks } from '../../test_utils'; describe('registerTransactionDurationRuleType', () => { - it('sends alert when value is greater than threashold', async () => { + it('sends alert when value is greater than threshold', async () => { const { services, dependencies, executor, scheduleActions } = createRuleTypeMocks(); @@ -56,14 +56,17 @@ describe('registerTransactionDurationRuleType', () => { await executor({ params }); expect(scheduleActions).toHaveBeenCalledTimes(1); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - transactionType: 'request', - serviceName: 'opbeans-java', + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), environment: 'Not defined', - threshold: 3000000, - triggerValue: '5,500 ms', interval: `5m`, reason: 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000000, + triggerValue: '5,500 ms', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts index b4c7a6212b62d..304a752079e12 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts @@ -17,6 +17,8 @@ import { asDuration } from '@kbn/observability-plugin/common/utils/formatters'; import { termQuery } from '@kbn/observability-plugin/server'; import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { getAlertDetailsUrl } from '@kbn/infra-plugin/server/lib/alerting/common/utils'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { getAlertUrlTransaction } from '../../../../../common/utils/formatters'; import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions'; import { @@ -77,6 +79,7 @@ export function registerTransactionDurationRuleType({ ruleDataClient, config$, logger, + observability, basePath, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ @@ -94,25 +97,31 @@ export function registerTransactionDurationRuleType({ }, actionVariables: { context: [ + ...(observability.getAlertDetailsConfig()?.apm.enabled + ? [apmActionVariables.alertDetailsUrl] + : []), + apmActionVariables.environment, + apmActionVariables.interval, + apmActionVariables.reason, apmActionVariables.serviceName, apmActionVariables.transactionType, - apmActionVariables.environment, apmActionVariables.threshold, apmActionVariables.triggerValue, - apmActionVariables.interval, - apmActionVariables.reason, apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, - executor: async ({ services, params }) => { + executor: async ({ params: ruleParams, services, spaceId }) => { const config = await firstValueFrom(config$); - const ruleParams = params; + + const { getAlertUuid, savedObjectsClient, scopedClusterClient } = + services; + const indices = await getApmIndices({ config, - savedObjectsClient: services.savedObjectsClient, + savedObjectsClient, }); // only query transaction events when set to 'never', @@ -185,7 +194,7 @@ export function registerTransactionDurationRuleType({ }; const response = await alertingEsClient({ - scopedClusterClient: services.scopedClusterClient, + scopedClusterClient, params: searchParams, }); @@ -197,22 +206,25 @@ export function registerTransactionDurationRuleType({ const thresholdMicroseconds = ruleParams.threshold * 1000; const triggeredBuckets = []; + for (const bucket of response.aggregations.series.buckets) { const [serviceName, environment, transactionType] = bucket.key; + const transactionDuration = 'avgLatency' in bucket // only true if ruleParams.aggregationType === 'avg' ? bucket.avgLatency.value : bucket.pctLatency.values[0].value; + if ( transactionDuration !== null && transactionDuration > thresholdMicroseconds ) { triggeredBuckets.push({ - serviceName, environment, + serviceName, + sourceFields: getServiceGroupFields(bucket), transactionType, transactionDuration, - sourceFields: getServiceGroupFields(bucket), }); } } @@ -224,36 +236,45 @@ export function registerTransactionDurationRuleType({ transactionDuration, sourceFields, } of triggeredBuckets) { + const environmentLabel = getEnvironmentLabel(environment); + const durationFormatter = getDurationFormatter(transactionDuration); const transactionDurationFormatted = durationFormatter(transactionDuration).formatted; - const reasonMessage = formatTransactionDurationReason({ + + const reason = formatTransactionDurationReason({ + aggregationType: String(ruleParams.aggregationType), + asDuration, measured: transactionDuration, serviceName, threshold: thresholdMicroseconds, - asDuration, - aggregationType: String(ruleParams.aggregationType), windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, }); - const relativeViewInAppUrl = getAlertUrlTransaction( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], - transactionType + const id = `${ApmRuleType.TransactionDuration}_${environmentLabel}`; + + const alertUuid = getAlertUuid(id); + + const alertDetailsUrl = getAlertDetailsUrl( + basePath, + spaceId, + alertUuid + ); + + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + getAlertUrlTransaction( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], + transactionType + ) ); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL( - basePath.prepend(relativeViewInAppUrl), - basePath.publicBaseUrl - ).toString() - : relativeViewInAppUrl; services .alertWithLifecycle({ - id: `${ApmRuleType.TransactionDuration}_${getEnvironmentLabel( - environment - )}`, + id, fields: { [SERVICE_NAME]: serviceName, ...getEnvironmentEsField(environment), @@ -261,18 +282,19 @@ export function registerTransactionDurationRuleType({ [PROCESSOR_EVENT]: ProcessorEvent.transaction, [ALERT_EVALUATION_VALUE]: transactionDuration, [ALERT_EVALUATION_THRESHOLD]: thresholdMicroseconds, - [ALERT_REASON]: reasonMessage, + [ALERT_REASON]: reason, ...sourceFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - transactionType, + alertDetailsUrl, + environment: environmentLabel, + interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, + reason, serviceName, - environment: getEnvironmentLabel(environment), threshold: thresholdMicroseconds, + transactionType, triggerValue: transactionDurationFormatted, - interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, - reason: reasonMessage, viewInAppUrl, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts index f28493338ad0d..38de7d48cce4c 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts @@ -121,6 +121,9 @@ describe('Transaction error rate alert', () => { ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), serviceName: 'foo', transactionType: 'type-foo', environment: 'env-foo', diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts index 73f7ccda26401..97f07e32566fd 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts @@ -15,7 +15,9 @@ import { import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; import { asPercent } from '@kbn/observability-plugin/common/utils/formatters'; import { termQuery } from '@kbn/observability-plugin/server'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { getAlertDetailsUrl } from '@kbn/infra-plugin/server/lib/alerting/common/utils'; import { ENVIRONMENT_NOT_DEFINED, getEnvironmentEsField, @@ -62,10 +64,11 @@ const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.TransactionErrorRate]; export function registerTransactionErrorRateRuleType({ alerting, - ruleDataClient, - logger, - config$, basePath, + config$, + logger, + observability, + ruleDataClient, }: RegisterRuleDependencies) { const createLifecycleRuleType = createLifecycleRuleTypeFactory({ ruleDataClient, @@ -83,24 +86,31 @@ export function registerTransactionErrorRateRuleType({ }, actionVariables: { context: [ - apmActionVariables.transactionType, - apmActionVariables.serviceName, + ...(observability.getAlertDetailsConfig()?.apm.enabled + ? [apmActionVariables.alertDetailsUrl] + : []), apmActionVariables.environment, - apmActionVariables.threshold, - apmActionVariables.triggerValue, apmActionVariables.interval, apmActionVariables.reason, + apmActionVariables.serviceName, + apmActionVariables.threshold, + apmActionVariables.transactionType, + apmActionVariables.triggerValue, apmActionVariables.viewInAppUrl, ], }, producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, - executor: async ({ services, params: ruleParams }) => { + executor: async ({ services, spaceId, params: ruleParams }) => { const config = await firstValueFrom(config$); + + const { getAlertUuid, savedObjectsClient, scopedClusterClient } = + services; + const indices = await getApmIndices({ config, - savedObjectsClient: services.savedObjectsClient, + savedObjectsClient, }); // only query transaction events when set to 'never', @@ -178,7 +188,7 @@ export function registerTransactionErrorRateRuleType({ }; const response = await alertingEsClient({ - scopedClusterClient: services.scopedClusterClient, + scopedClusterClient, params: searchParams, }); @@ -219,6 +229,7 @@ export function registerTransactionErrorRateRuleType({ errorRate, sourceFields, } = result; + const reasonMessage = formatTransactionErrorRateReason({ threshold: ruleParams.threshold, measured: errorRate, @@ -228,27 +239,38 @@ export function registerTransactionErrorRateRuleType({ windowUnit: ruleParams.windowUnit, }); + const id = [ + ApmRuleType.TransactionErrorRate, + serviceName, + transactionType, + environment, + ] + .filter((name) => name) + .join('_'); + + const alertUuid = getAlertUuid(id); + + const alertDetailsUrl = getAlertDetailsUrl( + basePath, + spaceId, + alertUuid + ); + const relativeViewInAppUrl = getAlertUrlTransaction( serviceName, getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], transactionType ); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL( - basePath.prepend(relativeViewInAppUrl), - basePath.publicBaseUrl - ).toString() - : relativeViewInAppUrl; + + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + relativeViewInAppUrl + ); + services .alertWithLifecycle({ - id: [ - ApmRuleType.TransactionErrorRate, - serviceName, - transactionType, - environment, - ] - .filter((name) => name) - .join('_'), + id, fields: { [SERVICE_NAME]: serviceName, ...getEnvironmentEsField(environment), @@ -261,13 +283,14 @@ export function registerTransactionErrorRateRuleType({ }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - serviceName, - transactionType, + alertDetailsUrl, environment: getEnvironmentLabel(environment), - threshold: ruleParams.threshold, - triggerValue: asDecimalOrInteger(errorRate), interval: `${ruleParams.windowSize}${ruleParams.windowUnit}`, reason: reasonMessage, + serviceName, + threshold: ruleParams.threshold, + transactionType, + triggerValue: asDecimalOrInteger(errorRate), viewInAppUrl, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts index 5302f632781f0..41d7385a2c3da 100644 --- a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts @@ -11,6 +11,7 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import { ruleRegistryMocks } from '@kbn/rule-registry-plugin/server/mocks'; import { PluginSetupContract as AlertingPluginSetupContract } from '@kbn/alerting-plugin/server'; +import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; import { APMConfig, APM_SERVER_FEATURE_ID } from '../../..'; export const createRuleTypeMocks = () => { @@ -51,16 +52,19 @@ export const createRuleTypeMocks = () => { return { dependencies: { alerting, + basePath: { + prepend: (path: string) => `http://localhost:5601/eyr${path}`, + publicBaseUrl: 'http://localhost:5601/eyr', + serverBasePath: '/eyr', + } as IBasePath, config$: mockedConfig$, + observability: { + getAlertDetailsConfig: jest.fn().mockReturnValue({ apm: true }), + } as unknown as ObservabilityPluginSetup, logger: loggerMock, ruleDataClient: ruleRegistryMocks.createRuleDataClient( '.alerts-observability.apm.alerts' ) as IRuleDataClient, - basePath: { - serverBasePath: '/eyr', - publicBaseUrl: 'http://localhost:5601/eyr', - prepend: (path: string) => `http://localhost:5601/eyr${path}`, - } as IBasePath, }, services, scheduleActions, diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 11498efbb7fc6..052f3cb743d37 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -40,7 +40,6 @@ export const INTERNAL_FEATURE_FLAGS = { showManageRulesMock: false, showFindingFlyoutEvidence: false, showFindingsGroupBy: true, - showNewDashboard: false, } as const; export const CSP_RULE_SAVED_OBJECT_TYPE = 'csp_rule'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_cis_kubernetes_integration.tsx b/x-pack/plugins/cloud_security_posture/public/common/api/use_cis_kubernetes_integration.tsx index 26b885ad44344..0ee08d915c0d1 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_cis_kubernetes_integration.tsx +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_cis_kubernetes_integration.tsx @@ -21,8 +21,6 @@ export const useCisKubernetesIntegration = () => { const { http } = useKibana().services; return useQuery(['integrations'], () => - http.get(epmRouteService.getInfoPath(CLOUD_SECURITY_POSTURE_PACKAGE_NAME), { - query: { experimental: true }, - }) + http.get(epmRouteService.getInfoPath(CLOUD_SECURITY_POSTURE_PACKAGE_NAME)) ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx index 409460be59287..01291b287293a 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx @@ -60,7 +60,7 @@ export const ChartPanel: React.FC = ({ return ( - + {title && ( diff --git a/x-pack/plugins/cloud_security_posture/public/components/cis_benchmark_icon.tsx b/x-pack/plugins/cloud_security_posture/public/components/cis_benchmark_icon.tsx index a5cc0d34466e2..f72dff1206cce 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cis_benchmark_icon.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cis_benchmark_icon.tsx @@ -30,6 +30,6 @@ const getBenchmarkIdIconType = (props: Props): string => { export const CISBenchmarkIcon = (props: Props) => ( - + ); diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx index dbe54990be505..4b953d7030251 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_counter_card.tsx @@ -5,32 +5,31 @@ * 2.0. */ -import React from 'react'; +import React, { MouseEventHandler } from 'react'; import { css } from '@emotion/react'; -import { EuiCard, EuiIcon, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; -import type { EuiTextProps, EuiCardProps } from '@elastic/eui'; +import { EuiIcon, EuiPanel, EuiStat, useEuiTheme } from '@elastic/eui'; +import type { EuiStatProps } from '@elastic/eui'; -export type CspCounterCardProps = Pick & { - descriptionColor?: EuiTextProps['color']; -}; +export interface CspCounterCardProps { + id: string; + onClick?: MouseEventHandler; + title: EuiStatProps['title']; + titleColor?: EuiStatProps['titleColor']; + description: EuiStatProps['description']; +} export const CspCounterCard = (counter: CspCounterCardProps) => { const { euiTheme } = useEuiTheme(); return ( - -
{counter.title}
-
- } + { `} data-test-subj={counter.id} > - - -

{counter.description}

-
-
+ {counter.onClick && ( )} - +
); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx deleted file mode 100644 index f978f19bc9a29..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { EuiIcon } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -export const CasesTable = () => { - return ( - - - - - - - - - - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx index 98a3b140aa233..20ebd4b893f20 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx @@ -10,100 +10,51 @@ import { AreaSeries, Axis, Chart, - ElementClickListener, niceTimeFormatByDay, - Partition, - PartitionElementEvent, - PartitionLayout, Settings, timeFormatter, } from '@elastic/charts'; -import { EuiFlexGroup, EuiText, EuiHorizontalRule, EuiFlexItem } from '@elastic/eui'; +import { + useEuiTheme, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + EuiTitle, + type EuiLinkButtonProps, + type EuiTextProps, + EuiToolTip, + EuiToolTipProps, +} from '@elastic/eui'; import { FormattedDate, FormattedTime } from '@kbn/i18n-react'; import moment from 'moment'; -import { statusColors } from '../../../common/constants'; -import type { PostureTrend, Stats } from '../../../../common/types'; -import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; +import { i18n } from '@kbn/i18n'; import { RULE_FAILED, RULE_PASSED } from '../../../../common/constants'; +import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; +import type { Evaluation, PostureTrend, Stats } from '../../../../common/types'; import { useKibana } from '../../../common/hooks/use_kibana'; interface CloudPostureScoreChartProps { + compact?: boolean; trend: PostureTrend[]; data: Stats; id: string; - partitionOnElementClick: (elements: PartitionElementEvent[]) => void; + onEvalCounterClick: (evaluation: Evaluation) => void; } const getPostureScorePercentage = (postureScore: number): string => `${Math.round(postureScore)}%`; -const ScoreChart = ({ - data: { totalPassed, totalFailed }, - id, - partitionOnElementClick, -}: Omit) => { - const data = [ - { label: RULE_PASSED, value: totalPassed }, - { label: RULE_FAILED, value: totalFailed }, - ]; - const { - services: { charts }, - } = useKibana(); - - return ( - - - d.value} - layout={PartitionLayout.sunburst} - layers={[ - { - groupByRollup: (d: { label: string }) => d.label, - shape: { - fillColor: (d, index) => - d.dataName === RULE_PASSED ? statusColors.success : statusColors.danger, - }, - }, - ]} - /> - - ); -}; - const PercentageInfo = ({ + compact, postureScore, - totalPassed, - totalFindings, -}: CloudPostureScoreChartProps['data']) => { +}: CloudPostureScoreChartProps['data'] & { compact?: CloudPostureScoreChartProps['compact'] }) => { + const { euiTheme } = useEuiTheme(); const percentage = getPostureScorePercentage(postureScore); return ( - - {percentage} - - - {'/'} - - {' Findings passed'} - - + +

{percentage}

+
); }; @@ -149,38 +100,94 @@ const ComplianceTrendChart = ({ trend }: { trend: PostureTrend[] }) => { tickFormat={timeFormatter(niceTimeFormatByDay(2))} ticks={4} /> - getPostureScorePercentage(rawScore)} - /> + ); }; +const CounterLink = ({ + text, + count, + color, + onClick, + tooltipContent, +}: { + count: number; + text: string; + color: EuiTextProps['color']; + onClick: EuiLinkButtonProps['onClick']; + tooltipContent: EuiToolTipProps['content']; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + +   + + {text} + + + ); +}; + export const CloudPostureScoreChart = ({ data, trend, - id, - partitionOnElementClick, -}: CloudPostureScoreChartProps) => ( - - - - - - - - - - - - - - - - -); + onEvalCounterClick, + compact, +}: CloudPostureScoreChartProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + + + onEvalCounterClick(RULE_PASSED)} + tooltipContent={i18n.translate( + 'xpack.csp.cloudPostureScoreChart.counterLink.passedFindingsTooltip', + { defaultMessage: 'Passed findings' } + )} + /> +  {`-`}  + onEvalCounterClick(RULE_FAILED)} + tooltipContent={i18n.translate( + 'xpack.csp.cloudPostureScoreChart.counterLink.failedFindingsTooltip', + { defaultMessage: 'Failed findings' } + )} + /> + + + + + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx index b911688dc0cf1..a1850a793ab3e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx @@ -25,6 +25,7 @@ export interface RisksTableProps { maxItems: number; onCellClick: (name: string) => void; onViewAllClick: () => void; + compact?: boolean; } export const getTopRisks = ( @@ -42,26 +43,31 @@ export const RisksTable = ({ maxItems, onCellClick, onViewAllClick, + compact, }: RisksTableProps) => { const columns: Array> = useMemo( () => [ { field: 'name', truncateText: true, - name: i18n.translate('xpack.csp.dashboard.risksTable.cisSectionColumnLabel', { - defaultMessage: 'CIS Section', - }), + name: compact + ? '' + : i18n.translate('xpack.csp.dashboard.risksTable.cisSectionColumnLabel', { + defaultMessage: 'CIS Section', + }), render: (name: GroupedFindingsEvaluation['name']) => ( - onCellClick(name)} className="eui-textTruncate"> + onCellClick(name)} className="eui-textTruncate" color="text"> {name} ), }, { field: 'totalFailed', - name: i18n.translate('xpack.csp.dashboard.risksTable.findingsColumnLabel', { - defaultMessage: 'Findings', - }), + name: compact + ? '' + : i18n.translate('xpack.csp.dashboard.risksTable.findingsColumnLabel', { + defaultMessage: 'Findings', + }), render: ( totalFailed: GroupedFindingsEvaluation['totalFailed'], resource: GroupedFindingsEvaluation @@ -78,13 +84,13 @@ export const RisksTable = ({ ), }, ], - [onCellClick] + [compact, onCellClick] ); const items = useMemo(() => getTopRisks(resourcesTypes, maxItems), [resourcesTypes, maxItems]); return ( - + rowHeader="name" @@ -93,16 +99,14 @@ export const RisksTable = ({ /> - - - - - - - +
+ + + +
); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx index c72bc286f7d0d..33fa8756c631d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { EuiSpacer, EuiPageHeader } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants'; import { CloudSummarySection } from './dashboard_sections/cloud_summary_section'; import { CloudPosturePageTitle } from '../../components/cloud_posture_page_title'; import { CloudPosturePage } from '../../components/cloud_posture_page'; import { DASHBOARD_CONTAINER } from './test_subjects'; -import { SummarySection } from './dashboard_sections/summary_section'; -import { BenchmarksSection } from './dashboard_sections/benchmarks_section'; import { useComplianceDashboardDataApi } from '../../common/api'; import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; import { NoFindingsStates } from '../../components/no_findings_states'; @@ -51,21 +48,12 @@ export const ComplianceDashboard = () => { margin-right: auto; `} > - {INTERNAL_FEATURE_FLAGS.showNewDashboard ? ( - <> - - - - - - ) : ( - <> - - - - - - )} + <> + + + + + ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx deleted file mode 100644 index cbdbbba49d4fd..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexItem, EuiPanel, EuiSpacer, EuiFlexGroup, useEuiTheme } from '@elastic/eui'; -import { PartitionElementEvent } from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; -import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { ChartPanel } from '../../../components/chart_panel'; -import type { ComplianceDashboardData, Evaluation } from '../../../../common/types'; -import { RisksTable } from '../compliance_charts/risks_table'; -import { RULE_FAILED } from '../../../../common/constants'; -import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; -import { ClusterDetailsBox } from './cluster_details_box'; - -const cardHeight = 300; - -export const BenchmarksSection = ({ - complianceData, -}: { - complianceData: ComplianceDashboardData; -}) => { - const { euiTheme } = useEuiTheme(); - const navToFindings = useNavigateFindings(); - - const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => { - const [element] = elements; - const [layerValue] = element; - const evaluation = layerValue[0].groupByRollup as Evaluation; - - navToFindings({ cluster_id: clusterId, 'result.evaluation': evaluation }); - }; - - const handleCellClick = (clusterId: string, ruleSection: string) => { - navToFindings({ - cluster_id: clusterId, - 'rule.section': ruleSection, - 'result.evaluation': RULE_FAILED, - }); - }; - - const handleViewAllClick = (clusterId: string) => { - navToFindings({ cluster_id: clusterId, 'result.evaluation': RULE_FAILED }); - }; - - return ( - <> - {complianceData.clusters.map((cluster) => ( - - - - - - - - - - handleElementClick(cluster.meta.clusterId, elements) - } - /> - - - - - - handleCellClick(cluster.meta.clusterId, resourceTypeName) - } - onViewAllClick={() => handleViewAllClick(cluster.meta.clusterId)} - /> - - - - - - - ))} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx index 11c836080a23c..26bce94cd49a3 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_benchmarks_section.tsx @@ -7,9 +7,7 @@ import React from 'react'; import { EuiFlexItem, EuiFlexGroup, useEuiTheme, EuiTitle } from '@elastic/eui'; -import { PartitionElementEvent } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ChartPanel } from '../../../components/chart_panel'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; import type { ComplianceDashboardData, Evaluation } from '../../../../common/types'; import { RisksTable } from '../compliance_charts/risks_table'; @@ -26,11 +24,7 @@ export const CloudBenchmarksSection = ({ const { euiTheme } = useEuiTheme(); const navToFindings = useNavigateFindings(); - const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => { - const [element] = elements; - const [layerValue] = element; - const evaluation = layerValue[0].groupByRollup as Evaluation; - + const handleEvalCounterClick = (clusterId: string, evaluation: Evaluation) => { navToFindings({ cluster_id: clusterId, 'result.evaluation': evaluation }); }; @@ -54,7 +48,7 @@ export const CloudBenchmarksSection = ({ style={{ borderBottom: euiTheme.border.thick, borderBottomColor: euiTheme.colors.text, - marginBottom: euiTheme.size.l, + marginBottom: euiTheme.size.m, paddingBottom: euiTheme.size.s, }} > @@ -105,20 +99,28 @@ export const CloudBenchmarksSection = ({
- +
- handleElementClick(cluster.meta.clusterId, elements) + onEvalCounterClick={(evaluation) => + handleEvalCounterClick(cluster.meta.clusterId, evaluation) } /> - +
- +
@@ -126,7 +128,7 @@ export const CloudBenchmarksSection = ({ } onViewAllClick={() => handleViewAllClick(cluster.meta.clusterId)} /> - +
))} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx index 02cf6124280b9..005d174079f02 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/cloud_summary_section.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { PartitionElementEvent } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; import { DASHBOARD_COUNTER_CARDS } from '../test_subjects'; @@ -23,13 +22,6 @@ import { } from '../../../common/hooks/use_navigate_findings'; import { RULE_FAILED } from '../../../../common/constants'; -const defaultHeight = 360; - -// TODO: limit this to desktop media queries only -const summarySectionWrapperStyle = { - height: defaultHeight, -}; - export const dashboardColumnsGrow: Record = { first: 3, second: 8, @@ -44,11 +36,7 @@ export const CloudSummarySection = ({ const navToFindings = useNavigateFindings(); const navToFindingsByResource = useNavigateFindingsByResource(); - const handleElementClick = (elements: PartitionElementEvent[]) => { - const [element] = elements; - const [layerValue] = element; - const evaluation = layerValue[0].groupByRollup as Evaluation; - + const handleEvalCounterClick = (evaluation: Evaluation) => { navToFindings({ 'result.evaluation': evaluation }); }; @@ -67,33 +55,31 @@ export const CloudSummarySection = ({ () => [ { id: DASHBOARD_COUNTER_CARDS.CLUSTERS_EVALUATED, - title: i18n.translate( + description: i18n.translate( 'xpack.csp.dashboard.summarySection.counterCard.clustersEvaluatedDescription', { defaultMessage: 'Clusters Evaluated' } ), - description: , + title: , }, { id: DASHBOARD_COUNTER_CARDS.RESOURCES_EVALUATED, - title: i18n.translate( + description: i18n.translate( 'xpack.csp.dashboard.summarySection.counterCard.resourcesEvaluatedDescription', { defaultMessage: 'Resources Evaluated' } ), - description: ( - - ), + title: , onClick: () => { navToFindingsByResource(); }, }, { id: DASHBOARD_COUNTER_CARDS.FAILING_FINDINGS, - title: i18n.translate( + description: i18n.translate( 'xpack.csp.dashboard.summarySection.counterCard.failingFindingsDescription', { defaultMessage: 'Failing Findings' } ), - description: , - descriptionColor: complianceData.stats.totalFailed > 0 ? 'danger' : 'text', + title: , + titleColor: complianceData.stats.totalFailed > 0 ? 'danger' : 'text', onClick: () => { navToFindings({ 'result.evaluation': RULE_FAILED }); }, @@ -109,7 +95,7 @@ export const CloudSummarySection = ({ ); return ( - + {counters.map((counter) => ( @@ -129,15 +115,16 @@ export const CloudSummarySection = ({ id="cloud_posture_score_chart" data={complianceData.stats} trend={complianceData.trend} - partitionOnElementClick={handleElementClick} + onEvalCounterClick={handleEvalCounterClick} /> { + const { euiTheme } = useEuiTheme(); const navToFindings = useNavigateFindings(); const shortId = cluster.meta.clusterId.slice(0, 6); @@ -40,7 +41,7 @@ export const ClusterDetailsBox = ({ cluster }: { cluster: Cluster }) => { }; return ( - + { } > handleClusterTitleClick(cluster.meta.clusterId)} color="text"> - -

+ +
{ shortId, }} /> -
- +

+
- -
- - + + {INTERNAL_FEATURE_FLAGS.showManageRulesMock && ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx deleted file mode 100644 index e05a844777d22..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; -import { PartitionElementEvent } from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; -import { ChartPanel } from '../../../components/chart_panel'; -import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import type { ComplianceDashboardData, Evaluation } from '../../../../common/types'; -import { RisksTable } from '../compliance_charts/risks_table'; -import { CasesTable } from '../compliance_charts/cases_table'; -import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; -import { RULE_FAILED } from '../../../../common/constants'; - -const defaultHeight = 360; - -// TODO: limit this to desktop media queries only -const summarySectionWrapperStyle = { - height: defaultHeight, -}; - -export const SummarySection = ({ complianceData }: { complianceData: ComplianceDashboardData }) => { - const navToFindings = useNavigateFindings(); - - const handleElementClick = (elements: PartitionElementEvent[]) => { - const [element] = elements; - const [layerValue] = element; - const evaluation = layerValue[0].groupByRollup as Evaluation; - - navToFindings({ 'result.evaluation': evaluation }); - }; - - const handleCellClick = (ruleSection: string) => { - navToFindings({ - 'rule.section': ruleSection, - 'result.evaluation': RULE_FAILED, - }); - }; - - const handleViewAllClick = () => { - navToFindings({ 'result.evaluation': RULE_FAILED }); - }; - - return ( - - - - - - - - - - - - - - - - - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/plugin.tsx b/x-pack/plugins/cloud_security_posture/public/plugin.tsx index cf281884f9d91..3644bd19d49b7 100755 --- a/x-pack/plugins/cloud_security_posture/public/plugin.tsx +++ b/x-pack/plugins/cloud_security_posture/public/plugin.tsx @@ -78,9 +78,11 @@ export class CspPlugin ( - - - +
+ + + +
), diff --git a/x-pack/plugins/enterprise_search/server/index.ts b/x-pack/plugins/enterprise_search/server/index.ts index c91789eadac35..b8f65c23ab674 100644 --- a/x-pack/plugins/enterprise_search/server/index.ts +++ b/x-pack/plugins/enterprise_search/server/index.ts @@ -42,6 +42,6 @@ export const CONNECTORS_INDEX = '.elastic-connectors'; export const CURRENT_CONNECTORS_INDEX = '.elastic-connectors-v1'; export const CONNECTORS_JOBS_INDEX = '.elastic-connectors-sync-jobs'; export const CONNECTORS_VERSION = 1; -export const CRAWLERS_INDEX = '.ent-search-actastic-crawler2_configurations'; +export const CRAWLERS_INDEX = '.ent-search-actastic-crawler2_configurations_v2'; export const ANALYTICS_COLLECTIONS_INDEX = '.elastic-analytics-collections'; export const ANALYTICS_VERSION = '1'; diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawlers.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawlers.ts index c0d3c7344d1a8..7cab68248734c 100644 --- a/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawlers.ts +++ b/x-pack/plugins/enterprise_search/server/lib/crawler/fetch_crawlers.ts @@ -11,8 +11,8 @@ import { IScopedClusterClient } from '@kbn/core/server'; import { Crawler, CrawlRequest } from '../../../common/types/crawler'; import { fetchAll } from '../fetch_all'; -const CRAWLER_CONFIGURATIONS_INDEX = '.ent-search-actastic-crawler2_configurations'; -const CRAWLER_CRAWL_REQUESTS_INDEX = '.ent-search-actastic-crawler2_crawl_requests'; +const CRAWLER_CONFIGURATIONS_INDEX = '.ent-search-actastic-crawler2_configurations_v2'; +const CRAWLER_CRAWL_REQUESTS_INDEX = '.ent-search-actastic-crawler2_crawl_requests_v2'; export const fetchMostRecentCrawlerRequestByConfigurationId = async ( client: IScopedClusterClient, diff --git a/x-pack/plugins/infra/common/formatters/bytes.ts b/x-pack/plugins/infra/common/formatters/bytes.ts index 64cd075efce6a..797168c300f64 100644 --- a/x-pack/plugins/infra/common/formatters/bytes.ts +++ b/x-pack/plugins/infra/common/formatters/bytes.ts @@ -43,7 +43,7 @@ export const createBytesFormatter = (format: InfraWaffleMapDataFormat) => (bytes const labels = LABELS[format]; const base = BASES[format]; const value = format === InfraWaffleMapDataFormat.bitsDecimal ? bytes * 8 : bytes; - // Use an exponetial equation to get the power to determine which label to use. If the power + // Use an exponential equation to get the power to determine which label to use. If the power // is greater then the max label then use the max label. const power = Math.min(Math.floor(Math.log(Math.abs(value)) / Math.log(base)), labels.length - 1); if (power < 0) { diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory_total.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory_total.ts index c3d0a8063b93f..e1990555ad550 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory_total.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory_total.ts @@ -19,7 +19,7 @@ export const memoryTotal: MetricsUIAggregation = { memoryTotal: 'memory_total', }, script: { - source: 'params.memoryTotal / 1000000', // Convert to MB + source: 'params.memoryTotal', lang: 'painless', }, gap_policy: 'skip', diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/experimental_badge.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/experimental_badge.tsx new file mode 100644 index 0000000000000..13d9583631ef2 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/experimental_badge.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBetaBadge } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const ExperimentalBadge = () => ( + +); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx index 3d50abeb5c212..14d229b838e56 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table.tsx @@ -29,7 +29,7 @@ const HOST_METRICS: Array<{ type: SnapshotMetricType }> = [ export const HostsTable = () => { const { sourceId } = useSourceContext(); - const { esQuery, dateRangeTimestamp } = useUnifiedSearchContext(); + const { buildQuery, dateRangeTimestamp } = useUnifiedSearchContext(); const timeRange: InfraTimerangeInput = { from: dateRangeTimestamp.from, @@ -38,6 +38,8 @@ export const HostsTable = () => { ignoreLookback: true, }; + const esQuery = buildQuery(); + // Snapshot endpoint internally uses the indices stored in source.configuration.metricAlias. // For the Unified Search, we create a data view, which for now will be built off of source.configuration.metricAlias too // if we introduce data view selection, we'll have to change this hook and the endpoint to accept a new parameter for the indices diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table_columns.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table_columns.tsx index 1bf4aab9ce054..03f0f243445ce 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table_columns.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/hosts_table_columns.tsx @@ -9,9 +9,8 @@ import { EuiBasicTableColumn } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiText } from '@elastic/eui'; -import type { SnapshotNodeMetric } from '../../../../../common/http_api'; -import { scaleUpPercentage } from '../../../../components/infrastructure_node_metrics_tables/shared/hooks'; -import { NumberCell } from '../../../../components/infrastructure_node_metrics_tables/shared/components'; +import type { SnapshotMetricInput, SnapshotNodeMetric } from '../../../../../common/http_api'; +import { createInventoryMetricFormatter } from '../../inventory_view/lib/create_inventory_metric_formatter'; interface HostNodeRow extends HostMetics { os?: string | null; @@ -28,6 +27,9 @@ export interface HostMetics { memoryTotal: SnapshotNodeMetric; } +const formatMetric = (type: SnapshotMetricInput['type'], value: number | undefined | null) => + value || value === 0 ? createInventoryMetricFormatter({ type })(value) : 'N/A'; + export const HostsTableColumns: Array> = [ { name: i18n.translate('xpack.infra.hostsTable.nameColumnHeader', { @@ -50,9 +52,11 @@ export const HostsTableColumns: Array> = [ name: i18n.translate('xpack.infra.hostsTable.numberOfCpusColumnHeader', { defaultMessage: '# of CPUs', }), - field: 'cpuCores.value', + field: 'cpuCores', sortable: true, - render: (value: number) => , + render: (cpuCores: SnapshotNodeMetric) => ( + <>{formatMetric('cpuCores', cpuCores?.value ?? cpuCores?.max)} + ), }, { name: i18n.translate('xpack.infra.hostsTable.diskLatencyColumnHeader', { @@ -60,7 +64,7 @@ export const HostsTableColumns: Array> = [ }), field: 'diskLatency.avg', sortable: true, - render: (avg: number) => , + render: (avg: number) => <>{formatMetric('diskLatency', avg)}, }, { name: i18n.translate('xpack.infra.hostsTable.averageTxColumnHeader', { @@ -68,7 +72,7 @@ export const HostsTableColumns: Array> = [ }), field: 'tx.avg', sortable: true, - render: (avg: number) => , + render: (avg: number) => <>{formatMetric('tx', avg)}, }, { name: i18n.translate('xpack.infra.hostsTable.averageRxColumnHeader', { @@ -76,7 +80,7 @@ export const HostsTableColumns: Array> = [ }), field: 'rx.avg', sortable: true, - render: (avg: number) => , + render: (avg: number) => <>{formatMetric('rx', avg)}, }, { name: i18n.translate('xpack.infra.hostsTable.averageMemoryTotalColumnHeader', { @@ -84,7 +88,7 @@ export const HostsTableColumns: Array> = [ }), field: 'memoryTotal.avg', sortable: true, - render: (avg: number) => , + render: (avg: number) => <>{formatMetric('memoryTotal', avg)}, }, { name: i18n.translate('xpack.infra.hostsTable.servicesOnHostColumnHeader', { @@ -92,7 +96,7 @@ export const HostsTableColumns: Array> = [ }), field: 'servicesOnHost', sortable: true, - render: (servicesOnHost: number) => , + render: (servicesOnHost: number) => <>{formatMetric('cpuCores', servicesOnHost)}, }, { name: i18n.translate('xpack.infra.hostsTable.averageMemoryUsageColumnHeader', { @@ -100,6 +104,6 @@ export const HostsTableColumns: Array> = [ }), field: 'memory.avg', sortable: true, - render: (avg: number) => , + render: (avg: number) => <>{formatMetric('memory', avg)}, }, ]; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx index f596300092dfe..87715dabe9604 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/unified_search_bar.tsx @@ -24,9 +24,10 @@ export const UnifiedSearchBar = ({ dataView }: Props) => { const { unifiedSearchDateRange, unifiedSearchQuery, - submitFilterChange, + unifiedSearchFilters, + onSubmit, saveQuery, - clearSavedQUery, + clearSavedQuery, } = useUnifiedSearchContext(); const { SearchBar } = unifiedSearch.ui; @@ -40,7 +41,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => { }; const onClearSavedQuery = () => { - clearSavedQUery(); + clearSavedQuery(); }; const onQuerySave = (savedQuery: SavedQuery) => { @@ -54,7 +55,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => { payload?: { dateRange: TimeRange; query?: Query }; filters?: Filter[]; }) => { - submitFilterChange(payload?.query, payload?.dateRange, filters); + onSubmit(payload?.query, payload?.dateRange, filters); }; return ( @@ -64,6 +65,7 @@ export const UnifiedSearchBar = ({ dataView }: Props) => { query={unifiedSearchQuery} dateRangeFrom={unifiedSearchDateRange.from} dateRangeTo={unifiedSearchDateRange.to} + filters={unifiedSearchFilters} onQuerySubmit={onQuerySubmit} onSaved={onQuerySave} onSavedQueryUpdated={onQuerySave} diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_url_state.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_url_state.ts new file mode 100644 index 0000000000000..49042fbd9bd7c --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_hosts_url_state.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useReducer } from 'react'; +import { TimeRange } from '@kbn/es-query'; +import DateMath from '@kbn/datemath'; +import deepEqual from 'fast-deep-equal'; +import * as rt from 'io-ts'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import { enumeration } from '@kbn/securitysolution-io-ts-types'; +import { FilterStateStore } from '@kbn/es-query'; +import { useUrlState } from '../../../../utils/use_url_state'; +import { useKibanaTimefilterTime } from '../../../../hooks/use_kibana_timefilter_time'; + +const DEFAULT_QUERY = { + language: 'kuery', + query: '', +}; +const DEFAULT_FROM_MINUTES_VALUE = 15; +const INITIAL_DATE = new Date(); +export const INITIAL_DATE_RANGE = { from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`, to: 'now' }; +const CALCULATED_DATE_RANGE_FROM = new Date( + INITIAL_DATE.getMinutes() - DEFAULT_FROM_MINUTES_VALUE +).getTime(); +const CALCULATED_DATE_RANGE_TO = INITIAL_DATE.getTime(); + +const INITIAL_HOSTS_STATE: HostsState = { + query: DEFAULT_QUERY, + filters: [], + // for unified search + dateRange: { ...INITIAL_DATE_RANGE }, + // for useSnapshot + dateRangeTimestamp: { + from: CALCULATED_DATE_RANGE_FROM, + to: CALCULATED_DATE_RANGE_TO, + }, +}; + +type Action = + | { + type: 'setQuery'; + payload: rt.TypeOf; + } + | { type: 'setFilter'; payload: rt.TypeOf }; + +const reducer = (state: HostsState, action: Action): HostsState => { + switch (action.type) { + case 'setFilter': + return { ...state, filters: [...action.payload] }; + case 'setQuery': + const { filters, query, ...payload } = action.payload; + const newFilters = !filters ? state.filters : filters; + const newQuery = !query ? state.query : query; + return { + ...state, + ...payload, + filters: [...newFilters], + query: { ...newQuery }, + }; + default: + throw new Error(); + } +}; + +export const useHostsUrlState = () => { + const [urlState, setUrlState] = useUrlState({ + defaultState: INITIAL_HOSTS_STATE, + decodeUrlState, + encodeUrlState, + urlStateKey: '_a', + }); + + const [state, dispatch] = useReducer(reducer, urlState); + + const [getTime] = useKibanaTimefilterTime(INITIAL_DATE_RANGE); + + const getRangeInTimestamp = useCallback(({ from, to }: TimeRange) => { + const fromTS = DateMath.parse(from)?.valueOf() ?? CALCULATED_DATE_RANGE_FROM; + const toTS = DateMath.parse(to)?.valueOf() ?? CALCULATED_DATE_RANGE_TO; + + return { + from: fromTS, + to: toTS, + }; + }, []); + + useEffect(() => { + if (!deepEqual(state, urlState)) { + setUrlState(state); + } + }, [setUrlState, state, urlState]); + + return { + state, + dispatch, + getRangeInTimestamp, + getTime, + }; +}; + +const HostsFilterRT = rt.intersection([ + rt.partial({ + $state: rt.type({ + store: enumeration('FilterStateStore', FilterStateStore), + }), + }), + rt.type({ + meta: rt.partial({ + alias: rt.union([rt.null, rt.string]), + disabled: rt.boolean, + negate: rt.boolean, + controlledBy: rt.string, + group: rt.string, + index: rt.string, + isMultiIndex: rt.boolean, + type: rt.string, + key: rt.string, + params: rt.any, + value: rt.any, + }), + }), + rt.partial({ + query: rt.record(rt.string, rt.any), + }), +]); + +const HostsFiltersRT = rt.array(HostsFilterRT); + +export const HostsQueryStateRT = rt.type({ + language: rt.string, + query: rt.any, +}); + +export const StringDateRangeRT = rt.type({ + from: rt.string, + to: rt.string, +}); + +export const DateRangeRT = rt.type({ + from: rt.number, + to: rt.number, +}); + +export const HostsStateRT = rt.type({ + filters: HostsFiltersRT, + query: HostsQueryStateRT, + dateRange: StringDateRangeRT, + dateRangeTimestamp: DateRangeRT, +}); + +export type HostsState = rt.TypeOf; + +const SetQueryType = rt.partial({ + query: HostsQueryStateRT, + dateRange: StringDateRangeRT, + filters: HostsFiltersRT, + dateRangeTimestamp: DateRangeRT, +}); + +const encodeUrlState = HostsStateRT.encode; +const decodeUrlState = (value: unknown) => { + return pipe(HostsStateRT.decode(value), fold(constant(undefined), identity)); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts index 4b3d4e7a47df6..0bd7211cf1dc2 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_unified_search.ts @@ -6,98 +6,99 @@ */ import { useKibana } from '@kbn/kibana-react-plugin/public'; import createContainer from 'constate'; -import { useCallback, useReducer } from 'react'; +import { useCallback } from 'react'; import { buildEsQuery, Filter, Query, TimeRange } from '@kbn/es-query'; -import DateMath from '@kbn/datemath'; import type { SavedQuery } from '@kbn/data-plugin/public'; +import { debounce } from 'lodash'; import type { InfraClientStartDeps } from '../../../../types'; import { useMetricsDataViewContext } from './use_data_view'; -import { useKibanaTimefilterTime } from '../../../../hooks/use_kibana_timefilter_time'; - -const DEFAULT_FROM_MINUTES_VALUE = 15; +import { useSyncKibanaTimeFilterTime } from '../../../../hooks/use_kibana_timefilter_time'; +import { useHostsUrlState, INITIAL_DATE_RANGE } from './use_hosts_url_state'; export const useUnifiedSearch = () => { - const [, forceUpdate] = useReducer((x: number) => x + 1, 0); - + const { state, dispatch, getRangeInTimestamp, getTime } = useHostsUrlState(); const { metricsDataView } = useMetricsDataViewContext(); const { services } = useKibana(); const { data: { query: queryManager }, } = services; - const [getTime, setTime] = useKibanaTimefilterTime({ - from: `now-${DEFAULT_FROM_MINUTES_VALUE}m`, - to: 'now', + useSyncKibanaTimeFilterTime(INITIAL_DATE_RANGE, { + from: state.dateRange.from, + to: state.dateRange.to, }); - const { queryString, filterManager } = queryManager; - - const currentDate = new Date(); - const fromTS = - DateMath.parse(getTime().from)?.valueOf() ?? - new Date(currentDate.getMinutes() - DEFAULT_FROM_MINUTES_VALUE).getTime(); - const toTS = DateMath.parse(getTime().to)?.valueOf() ?? currentDate.getTime(); - const currentTimeRange = { - from: fromTS, - to: toTS, - }; + const { filterManager } = queryManager; - const submitFilterChange = useCallback( + const onSubmit = useCallback( (query?: Query, dateRange?: TimeRange, filters?: Filter[]) => { - if (filters) { - filterManager.setFilters(filters); + if (query || dateRange || filters) { + const newDateRange = dateRange ?? getTime(); + + if (filters) { + filterManager.setFilters(filters); + } + dispatch({ + type: 'setQuery', + payload: { + query, + filters: filters ? filterManager.getFilters() : undefined, + dateRange: newDateRange, + dateRangeTimestamp: getRangeInTimestamp(newDateRange), + }, + }); } - - setTime({ - ...getTime(), - ...dateRange, - }); - - queryString.setQuery({ ...queryString.getQuery(), ...query }); - // Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values - // This can be removed once we get the state from the URL - forceUpdate(); }, - [filterManager, queryString, getTime, setTime] + [filterManager, getRangeInTimestamp, getTime, dispatch] ); + // This won't prevent onSubmit from being fired twice when `clear filters` is clicked, + // that happens because both onQuerySubmit and onFiltersUpdated are internally triggered on same event by SearchBar. + // This just delays potential duplicate onSubmit calls + // eslint-disable-next-line react-hooks/exhaustive-deps + const debounceOnSubmit = useCallback(debounce(onSubmit, 100), [onSubmit]); + const saveQuery = useCallback( (newSavedQuery: SavedQuery) => { const savedQueryFilters = newSavedQuery.attributes.filters ?? []; const globalFilters = filterManager.getGlobalFilters(); - filterManager.setFilters([...savedQueryFilters, ...globalFilters]); - // Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values - // This can be removed once we get the state from the URL - forceUpdate(); + const query = newSavedQuery.attributes.query; + + dispatch({ + type: 'setQuery', + payload: { + query, + filters: [...savedQueryFilters, ...globalFilters], + }, + }); }, - [filterManager] + [filterManager, dispatch] ); - const clearSavedQUery = useCallback(() => { - filterManager.setFilters(filterManager.getGlobalFilters()); - - // Unified search holds the all state, we need to force the hook to rerender so that it can return the most recent values - // This can be removed once we get the state from the URL - forceUpdate(); - }, [filterManager]); + const clearSavedQuery = useCallback(() => { + dispatch({ + type: 'setFilter', + payload: filterManager.getGlobalFilters(), + }); + }, [filterManager, dispatch]); const buildQuery = useCallback(() => { if (!metricsDataView) { return null; } - return buildEsQuery(metricsDataView, queryString.getQuery(), filterManager.getFilters()); - }, [filterManager, metricsDataView, queryString]); + return buildEsQuery(metricsDataView, state.query, state.filters); + }, [metricsDataView, state.filters, state.query]); return { - dateRangeTimestamp: currentTimeRange, - esQuery: buildQuery(), - submitFilterChange, + dateRangeTimestamp: state.dateRangeTimestamp, + buildQuery, + onSubmit: debounceOnSubmit, saveQuery, - clearSavedQUery, - unifiedSearchQuery: queryString.getQuery() as Query, + clearSavedQuery, + unifiedSearchQuery: state.query, unifiedSearchDateRange: getTime(), - unifiedSearchFilters: filterManager.getFilters(), + unifiedSearchFilters: state.filters, }; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx index 3321be0af193c..506e8bff6bd33 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/index.tsx @@ -9,6 +9,9 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '@kbn/observability-plugin/public'; import { APP_WRAPPER_CLASS } from '@kbn/core/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import { EuiLink } from '@elastic/eui'; import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useSourceContext } from '../../../containers/metrics_source'; @@ -19,8 +22,11 @@ import { MetricsDataViewProvider } from './hooks/use_data_view'; import { fullHeightContentStyles } from '../../../page_template.styles'; import { UnifiedSearchProvider } from './hooks/use_unified_search'; import { HostContainer } from './components/hosts_container'; +import { ExperimentalBadge } from './components/experimental_badge'; export const HostsPage = () => { + const HOSTS_FEEDBACK_LINK = 'https://ela.st/feedback-host-observability'; + const { hasFailedLoadingSource, isLoading, @@ -47,7 +53,27 @@ export const HostsPage = () => { +

{hostsTitle}

+ + + ), + rightSideItems: [ + + + , + ], }} pageSectionProps={{ contentProps: { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts index ca09762442d20..40aa9cd693e7f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts @@ -30,10 +30,22 @@ const METRIC_FORMATTERS: MetricFormatters = { formatter: InfraFormatterType.percent, template: '{{value}}', }, + ['cpuCores']: { + formatter: InfraFormatterType.number, + template: '{{value}}', + }, ['memory']: { formatter: InfraFormatterType.percent, template: '{{value}}', }, + ['memoryTotal']: { + formatter: InfraFormatterType.bytes, + template: '{{value}}', + }, + ['diskLatency']: { + formatter: InfraFormatterType.number, + template: '{{value}} ms', + }, ['rx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, ['tx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, ['logRate']: { diff --git a/x-pack/plugins/infra/server/routes/overview/lib/create_top_nodes_query.ts b/x-pack/plugins/infra/server/routes/overview/lib/create_top_nodes_query.ts index 20bf2324c29d1..1419971db175b 100644 --- a/x-pack/plugins/infra/server/routes/overview/lib/create_top_nodes_query.ts +++ b/x-pack/plugins/infra/server/routes/overview/lib/create_top_nodes_query.ts @@ -142,7 +142,7 @@ export const createTopNodesQuery = ( timeseries: { date_histogram: { field: '@timestamp', - fixed_interval: '1m', + fixed_interval: options.bucketSize, extended_bounds: { min: options.timerange.from, max: options.timerange.to, diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts index 6c36f94db1a7c..7db7502640054 100644 --- a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.test.ts @@ -79,15 +79,16 @@ describe('ConnectorsEmailServiceProvider', () => { expect(serviceProvider['setupSuccessful']).toEqual(false); }); - it('should log a warning if no default email connector has been defined', () => { + it('should log an info message if no default email connector has been defined', () => { const serviceProvider = new EmailServiceProvider(missingConnectorConfig, logger); serviceProvider.setup({ actions: actionsSetup, licensing: licensingMock.createSetup(), }); - expect(logger.warn).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( `Email Service Error: Email connector not specified.` ); // eslint-disable-next-line dot-notation diff --git a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts index f034116eb701c..b3364f31d3689 100755 --- a/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts +++ b/x-pack/plugins/notifications/server/services/connectors_email_service_provider.ts @@ -41,16 +41,18 @@ export class EmailServiceProvider const { actions, licensing } = plugins; if (!actions || !licensing) { - return this._registerServiceError(`Error: 'actions' and 'licensing' plugins are required.`); + return this._registerInitializationError( + `Error: 'actions' and 'licensing' plugins are required.` + ); } const emailConnector = this.config.connectors?.default?.email; if (!emailConnector) { - return this._registerServiceError('Error: Email connector not specified.'); + return this._registerInitializationError('Error: Email connector not specified.', 'info'); } if (!actions.isPreconfiguredConnector(emailConnector)) { - return this._registerServiceError( + return this._registerInitializationError( `Error: Unexisting email connector '${emailConnector}' specified.` ); } @@ -75,7 +77,7 @@ export class EmailServiceProvider this.logger ); } catch (err) { - this._registerServiceError(err); + this._registerInitializationError(err); } } @@ -90,9 +92,13 @@ export class EmailServiceProvider }; } - private _registerServiceError(error: string) { + private _registerInitializationError(error: string, level: 'info' | 'warn' = 'warn') { const message = `Email Service ${error}`; this.setupError = message; - this.logger.warn(message); + if (level === 'info') { + this.logger.info(message); + } else { + this.logger.warn(message); + } } } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index aaef957422172..bce05a998fafe 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -108,7 +108,7 @@ describe('Lens Attribute', () => { to: 'now', }, dataView: mockDataView, - name: 'ux-series-1', + name: 'Page load time', breakdown: 'percentile', reportDefinitions: {}, selectedMetricField: 'transaction.duration.us', @@ -139,7 +139,7 @@ describe('Lens Attribute', () => { query: 'transaction.type: page-load and processor.event: transaction', }, isBucketed: false, - label: `${rank} percentile of page load time`, + label: 'Page load time', operationType: 'percentile', params: { percentile: Number(rank.slice(0, 2)), diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index e029e8479fde5..c93b6ef2cadd0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -331,7 +331,6 @@ export class LensAttributes { columnType, columnFilter, operationType, - shortLabel, }: { sourceField: string; columnType?: string; @@ -339,7 +338,6 @@ export class LensAttributes { operationType?: SupportedOperations | 'last_value'; label?: string; seriesConfig: SeriesConfig; - shortLabel?: boolean; }) { if (columnType === 'operation' || operationType) { if ( @@ -352,7 +350,6 @@ export class LensAttributes { label, seriesConfig, columnFilter, - shortLabel, }); } if (operationType === 'last_value') { @@ -365,7 +362,7 @@ export class LensAttributes { }); } if (operationType?.includes('th')) { - return this.getPercentileNumberColumn(sourceField, operationType, seriesConfig!); + return this.getPercentileNumberColumn(sourceField, operationType, seriesConfig!, label); } } return this.getNumberRangeColumn(sourceField, seriesConfig!, label); @@ -402,14 +399,12 @@ export class LensAttributes { seriesConfig, operationType, columnFilter, - shortLabel, }: { sourceField: string; operationType: SupportedOperations; label?: string; seriesConfig: SeriesConfig; columnFilter?: ColumnFilter; - shortLabel?: boolean; }): | MinIndexPatternColumn | MaxIndexPatternColumn @@ -469,14 +464,17 @@ export class LensAttributes { getPercentileNumberColumn( sourceField: string, percentileValue: string, - seriesConfig: SeriesConfig + seriesConfig: SeriesConfig, + label?: string ): PercentileIndexPatternColumn { return { ...buildNumberColumn(sourceField), - label: i18n.translate('xpack.observability.expView.columns.label', { - defaultMessage: '{percentileValue} percentile of {sourceField}', - values: { sourceField: seriesConfig.labels[sourceField]?.toLowerCase(), percentileValue }, - }), + label: + label ?? + i18n.translate('xpack.observability.expView.columns.label', { + defaultMessage: '{percentileValue} percentile of {sourceField}', + values: { sourceField: seriesConfig.labels[sourceField]?.toLowerCase(), percentileValue }, + }), operationType: 'percentile', params: getPercentileParam(percentileValue), customLabel: true, @@ -552,7 +550,6 @@ export class LensAttributes { colIndex, layerId, metricOption, - shortLabel, }: { sourceField: string; metricOption?: MetricOption; @@ -561,7 +558,6 @@ export class LensAttributes { layerId: string; layerConfig: LayerConfig; colIndex?: number; - shortLabel?: boolean; }) { const { breakdown, seriesConfig } = layerConfig; const fieldMetaInfo = this.getFieldMeta(sourceField, layerConfig, metricOption); @@ -614,7 +610,8 @@ export class LensAttributes { ...this.getPercentileNumberColumn( fieldName, operationType || PERCENTILE_RANKS[0], - seriesConfig! + seriesConfig!, + label || columnLabel ), filter: colIndex !== undefined ? columnFilters?.[colIndex] : undefined, }; @@ -628,7 +625,6 @@ export class LensAttributes { operationType, label: label || columnLabel, seriesConfig: layerConfig.seriesConfig, - shortLabel, }); } if (operationType === 'unique_count' || fieldType === 'string') { @@ -745,7 +741,6 @@ export class LensAttributes { return this.getColumnBasedOnType({ layerConfig, layerId, - shortLabel: true, label: item.label, sourceField: REPORT_METRIC_FIELD, metricOption: item, diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 52d7bb1e66a96..fbb719a6ed50e 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -272,7 +272,12 @@ export const uiSettings: Record = { }), value: false, description: i18n.translate('xpack.observability.enableInfrastructureHostsViewDescription', { - defaultMessage: 'Enable the Hosts view in the Infrastructure app', + defaultMessage: + '{technicalPreviewLabel} Enable the Hosts view in the Infrastructure app. {feedbackLink}.', + values: { + technicalPreviewLabel: `[${technicalPreviewLabel}]`, + feedbackLink: feedbackLink({ href: 'https://ela.st/feedback-host-observability' }), + }, }), schema: schema.boolean(), }, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index cf4b012633f96..131eb538f28aa 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -71,7 +71,7 @@ export interface LifecycleAlertServices< > { alertWithLifecycle: LifecycleAlertService; getAlertStartedDate: (alertInstanceId: string) => string | null; - getAlertUuid: (alertInstanceId: string) => string | null; + getAlertUuid: (alertInstanceId: string) => string; getAlertByAlertUuid: (alertUuid: string) => { [x: string]: any } | null; } @@ -176,13 +176,14 @@ export const createLifecycleExecutor = }, getAlertStartedDate: (alertId: string) => state.trackedAlerts[alertId]?.started ?? null, getAlertUuid: (alertId: string) => { - if (!state.trackedAlerts[alertId]) { - const alertUuid = v4(); - newAlertUuids[alertId] = alertUuid; - return alertUuid; + let existingUuid = state.trackedAlerts[alertId]?.alertUuid || newAlertUuids[alertId]; + + if (!existingUuid) { + existingUuid = v4(); + newAlertUuids[alertId] = existingUuid; } - return state.trackedAlerts[alertId].alertUuid; + return existingUuid; }, getAlertByAlertUuid: async (alertUuid: string) => { try { @@ -251,7 +252,7 @@ export const createLifecycleExecutor = const { alertUuid, started } = !isNew ? state.trackedAlerts[alertId] : { - alertUuid: newAlertUuids[alertId] || v4(), + alertUuid: lifecycleAlertServices.getAlertUuid(alertId), started: commonRuleFields[TIMESTAMP], }; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index 650b9563ec82e..b50912eda89b0 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -13,20 +13,24 @@ import { } from '@kbn/alerting-plugin/common'; import { IRuleDataClient } from '../rule_data_client'; import { AlertTypeWithExecutor } from '../types'; -import { LifecycleAlertService, createLifecycleExecutor } from './create_lifecycle_executor'; +import { createLifecycleExecutor, LifecycleAlertServices } from './create_lifecycle_executor'; import { createGetSummarizedAlertsFn } from './create_get_summarized_alerts_fn'; export const createLifecycleRuleTypeFactory = ({ logger, ruleDataClient }: { logger: Logger; ruleDataClient: IRuleDataClient }) => < TParams extends RuleTypeParams, + TAlertInstanceState extends AlertInstanceState, TAlertInstanceContext extends AlertInstanceContext, - TServices extends { - alertWithLifecycle: LifecycleAlertService, TAlertInstanceContext, string>; - } + TActionGroupIds extends string, + TServices extends LifecycleAlertServices< + TAlertInstanceState, + TAlertInstanceContext, + TActionGroupIds + > >( - type: AlertTypeWithExecutor, TParams, TAlertInstanceContext, TServices> - ): AlertTypeWithExecutor, TParams, TAlertInstanceContext, any> => { + type: AlertTypeWithExecutor + ): AlertTypeWithExecutor => { const createBoundLifecycleExecutor = createLifecycleExecutor(logger, ruleDataClient); const createGetSummarizedAlerts = createGetSummarizedAlertsFn({ ruleDataClient, diff --git a/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts b/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts index ab0628f738286..721110db4d6af 100644 --- a/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts +++ b/x-pack/plugins/rule_registry/server/utils/lifecycle_alert_services.mock.ts @@ -36,6 +36,6 @@ export const createLifecycleAlertServicesMock = < ): LifecycleAlertServices => ({ alertWithLifecycle: ({ id }) => alertServices.alertFactory.create(id), getAlertStartedDate: jest.fn((id: string) => null), - getAlertUuid: jest.fn((id: string) => null), + getAlertUuid: jest.fn((id: string) => 'mock-alert-uuid'), getAlertByAlertUuid: jest.fn((id: string) => null), }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx index f93095a72ed6d..4e851cc4ea556 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx @@ -17,10 +17,12 @@ import { useHtmlId } from '../../../components/use_html_id'; export interface ApiKeysEmptyPromptProps { error?: Error; + readOnly?: boolean; } export const ApiKeysEmptyPrompt: FunctionComponent = ({ error, + readOnly, children, }) => { const accordionId = useHtmlId('apiKeysEmptyPrompt', 'accordion'); @@ -115,6 +117,30 @@ export const ApiKeysEmptyPrompt: FunctionComponent = ({ ); } + if (readOnly) { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } + return ( { // since we are using EuiErrorBoundary and react will console.error any errors const consoleWarnMock = jest.spyOn(console, 'error').mockImplementation(); - const coreStart = coreMock.createStart(); + let coreStart: ReturnType; const theme$ = themeServiceMock.createTheme$(); const apiClientMock = apiKeysAPIClientMock.create(); const { authc } = securityMock.createSetup(); beforeEach(() => { + coreStart = coreMock.createStart(); apiClientMock.checkPrivileges.mockClear(); apiClientMock.getApiKeys.mockClear(); coreStart.http.get.mockClear(); @@ -50,6 +51,7 @@ describe('APIKeysGridPage', () => { canManage: true, isAdmin: true, }); + apiClientMock.getApiKeys.mockResolvedValue({ apiKeys: [ { @@ -83,19 +85,29 @@ describe('APIKeysGridPage', () => { }) ); }); + it('loads and displays API keys', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); - const { findByText } = render( + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + api_keys: { + save: true, + }, + }; + + const { findByText, queryByTestId } = render( ); + expect(await queryByTestId('apiKeysCreateTableButton')).toBeNull(); expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); await findByText(/first-api-key/); await findByText(/second-api-key/); @@ -114,12 +126,20 @@ describe('APIKeysGridPage', () => { isAdmin: true, }); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + api_keys: { + save: true, + }, + }; + const { findByText } = render( ); @@ -136,12 +156,20 @@ describe('APIKeysGridPage', () => { isAdmin: false, }); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + api_keys: { + save: true, + }, + }; + const { findByText } = render( ); @@ -160,12 +188,20 @@ describe('APIKeysGridPage', () => { }); const history = createMemoryHistory({ initialEntries: ['/'] }); + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + api_keys: { + save: true, + }, + }; + const { findByText } = render( ); @@ -173,4 +209,75 @@ describe('APIKeysGridPage', () => { expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); await findByText(/Could not load API keys/); }); + + describe('Read Only View', () => { + beforeEach(() => { + apiClientMock.checkPrivileges.mockResolvedValueOnce({ + areApiKeysEnabled: true, + canManage: false, + isAdmin: false, + }); + }); + + it('should not display prompt `Create Button` when no API keys are shown', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + + apiClientMock.getApiKeys.mockResolvedValue({ + apiKeys: [], + }); + + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + api_keys: { + save: false, + }, + }; + + const { findByText, queryByText } = render( + + + + ); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + expect(await findByText('You do not have permission to create API keys')).toBeInTheDocument(); + expect(queryByText('Create API key')).toBeNull(); + }); + + it('should not display table `Create Button` nor `Delete` icons column', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }); + + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + api_keys: { + save: false, + }, + }; + + const { findByText, queryByText, queryAllByText } = await render( + + + + ); + + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + expect( + await findByText('You only have permission to view your own API keys.') + ).toBeInTheDocument(); + expect( + await findByText('View your API keys. An API key sends requests on your behalf.') + ).toBeInTheDocument(); + expect(queryByText('Create API key')).toBeNull(); + expect(queryAllByText('Delete').length).toBe(0); + }); + }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index 04ae1e7bae91e..aa94f4f147789 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx @@ -48,6 +48,7 @@ interface Props { history: History; notifications: NotificationsStart; apiKeysAPIClient: PublicMethodsOf; + readOnly?: boolean; } interface State { @@ -65,6 +66,10 @@ interface State { const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; export class APIKeysGridPage extends Component { + static defaultProps: Partial = { + readOnly: false, + }; + constructor(props: any) { super(props); this.state = { @@ -147,25 +152,31 @@ export class APIKeysGridPage extends Component { } if (!isLoadingTable && apiKeys && apiKeys.length === 0) { - return ( - - - - - - ); + if (this.props.readOnly) { + return ; + } else { + return ( + + + + + + ); + } } const concatenated = `${this.state.createdApiKey?.id}:${this.state.createdApiKey?.api_key}`; + const description = this.determineDescription(isAdmin, this.props.readOnly ?? false); + return ( <> { defaultMessage="API Keys" /> } - description={ - <> - {isAdmin ? ( - - ) : ( - - )} - + description={description} + rightSideItems={ + this.props.readOnly + ? undefined + : [ + + + , + ] } - rightSideItems={[ - - - , - ]} /> {this.state.createdApiKey && !this.state.isLoadingTable && ( @@ -421,20 +422,13 @@ export class APIKeysGridPage extends Component { : undefined, }; + const callOutTitle = this.determineCallOutTitle(this.props.readOnly ?? false); + return ( <> {!isAdmin ? ( <> - - } - color="primary" - iconType="user" - /> + ) : undefined} @@ -451,7 +445,7 @@ export class APIKeysGridPage extends Component { columns={this.getColumnConfig(invalidateApiKeyPrompt)} search={search} sorting={sorting} - selection={selection} + selection={this.props.readOnly ? undefined : selection} pagination={pagination} loading={isLoadingTable} error={ @@ -580,7 +574,10 @@ export class APIKeysGridPage extends Component { ); }, }, - { + ]); + + if (!this.props.readOnly) { + config.push({ actions: [ { name: i18n.translate('xpack.security.management.apiKeys.table.deleteAction', { @@ -600,8 +597,8 @@ export class APIKeysGridPage extends Component { 'data-test-subj': 'apiKeysTableDeleteAction', }, ], - }, - ]); + }); + } return config; }; @@ -618,7 +615,7 @@ export class APIKeysGridPage extends Component { await this.props.apiKeysAPIClient.checkPrivileges(); this.setState({ isAdmin, canManage, areApiKeysEnabled }); - if (!canManage || !areApiKeysEnabled) { + if ((!canManage && !this.props.readOnly) || !areApiKeysEnabled) { this.setState({ isLoadingApp: false }); } else { this.loadApiKeys(); @@ -654,4 +651,47 @@ export class APIKeysGridPage extends Component { this.setState({ isLoadingApp: false, isLoadingTable: false }); }; + + private determineDescription(isAdmin: boolean, readOnly: boolean) { + if (isAdmin) { + return ( + + ); + } else if (readOnly) { + return ( + + ); + } else { + return ( + + ); + } + } + + private determineCallOutTitle(readOnly: boolean) { + if (readOnly) { + return ( + + ); + } else { + return ( + + ); + } + } } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index c4f3000277af7..bcde6bbb619b7 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -34,16 +34,27 @@ describe('apiKeysManagementApp', () => { }); it('mount() works for the `grid` page', async () => { - const { getStartServices } = coreMock.createSetup(); + const coreStart = coreMock.createSetup(); const { authc } = securityMock.createSetup(); - const startServices = await getStartServices(); + const startServices = await coreStart.getStartServices(); + + const [{ application }] = startServices; + application.capabilities = { + ...application.capabilities, + api_keys: { + save: true, + }, + }; + const docTitle = startServices[0].chrome.docTitle; const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); + let unmount: Unmount; + await act(async () => { unmount = await apiKeysManagementApp .create({ authc, getStartServices: () => Promise.resolve(startServices) as any }) @@ -84,7 +95,8 @@ describe('apiKeysManagementApp', () => { "anonymousPaths": {}, "externalUrl": {} } - } + }, + "readOnly": false } `); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index 8145a31f3f6f4..071e1ed42f231 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -27,6 +27,7 @@ import { } from '../../components/breadcrumb'; import { AuthenticationProvider } from '../../components/use_current_user'; import type { PluginStartDependencies } from '../../plugin'; +import { ReadonlyBadge } from '../badges/readonly_badge'; interface CreateParams { authc: AuthenticationServiceSetup; @@ -67,6 +68,7 @@ export const apiKeysManagementApp = Object.freeze({ history={history} notifications={coreStart.notifications} apiKeysAPIClient={new APIKeysAPIClient(coreStart.http)} + readOnly={!coreStart.application.capabilities.api_keys.save} /> , @@ -102,6 +104,12 @@ export const Providers: FunctionComponent = ({ + {children} diff --git a/x-pack/plugins/security/server/features/security_features.ts b/x-pack/plugins/security/server/features/security_features.ts index 46184a845b66c..c566399548648 100644 --- a/x-pack/plugins/security/server/features/security_features.ts +++ b/x-pack/plugins/security/server/features/security_features.ts @@ -52,10 +52,14 @@ const apiKeysManagementFeature: ElasticsearchFeatureConfig = { privileges: [ { requiredClusterPrivileges: ['manage_api_key'], - ui: [], + ui: ['save'], }, { requiredClusterPrivileges: ['manage_own_api_key'], + ui: ['save'], + }, + { + requiredClusterPrivileges: ['read_security'], ui: [], }, ], diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index e88580e1a338f..1566723f9c6c5 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -469,4 +469,6 @@ export const RISKY_HOSTS_DOC_LINK = export const RISKY_USERS_DOC_LINK = 'https://www.elastic.co/guide/en/security/current/user-risk-score.html'; +export const MAX_NUMBER_OF_NEW_TERMS_FIELDS = 3; + export const BULK_ADD_TO_TIMELINE_LIMIT = 2000; diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/new_terms_attributes.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/new_terms_attributes.ts index 15bf73ba150e5..6d9f39011b675 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/new_terms_attributes.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_schema/model/specific_attributes/new_terms_attributes.ts @@ -7,14 +7,19 @@ import * as t from 'io-ts'; import { LimitedSizeArray, NonEmptyString } from '@kbn/securitysolution-io-ts-types'; +import { MAX_NUMBER_OF_NEW_TERMS_FIELDS } from '../../../../constants'; // Attributes specific to New Terms rules /** - * New terms rule type currently only supports a single term, but should support more in the future + * New terms rule type supports a limited number of fields. Max number of fields is 3 and defined in common constants as MAX_NUMBER_OF_NEW_TERMS_FIELDS */ export type NewTermsFields = t.TypeOf; -export const NewTermsFields = LimitedSizeArray({ codec: t.string, minSize: 1, maxSize: 1 }); +export const NewTermsFields = LimitedSizeArray({ + codec: t.string, + minSize: 1, + maxSize: MAX_NUMBER_OF_NEW_TERMS_FIELDS, +}); export type HistoryWindowStart = t.TypeOf; export const HistoryWindowStart = NonEmptyString; diff --git a/x-pack/plugins/security_solution/common/field_maps/field_names.ts b/x-pack/plugins/security_solution/common/field_maps/field_names.ts index 6a7b4efff8a7c..53ebfc5c188d1 100644 --- a/x-pack/plugins/security_solution/common/field_maps/field_names.ts +++ b/x-pack/plugins/security_solution/common/field_maps/field_names.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_NAMESPACE, ALERT_RULE_NAMESPACE } from '@kbn/rule-data-utils'; +import { ALERT_NAMESPACE, ALERT_RULE_NAMESPACE, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; export const ALERT_ANCESTORS = `${ALERT_NAMESPACE}.ancestors` as const; export const ALERT_BUILDING_BLOCK_TYPE = `${ALERT_NAMESPACE}.building_block_type` as const; @@ -16,6 +16,7 @@ export const ALERT_ORIGINAL_TIME = `${ALERT_NAMESPACE}.original_time` as const; export const ALERT_THRESHOLD_RESULT = `${ALERT_NAMESPACE}.threshold_result` as const; export const ALERT_THRESHOLD_RESULT_COUNT = `${ALERT_THRESHOLD_RESULT}.count` as const; export const ALERT_NEW_TERMS = `${ALERT_NAMESPACE}.new_terms` as const; +export const ALERT_NEW_TERMS_FIELDS = `${ALERT_RULE_PARAMETERS}.new_terms_fields` as const; export const ALERT_ORIGINAL_EVENT = `${ALERT_NAMESPACE}.original_event` as const; export const ALERT_ORIGINAL_EVENT_ACTION = `${ALERT_ORIGINAL_EVENT}.action` as const; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index 662e1983680bd..beda9c0aa4d13 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -703,18 +703,25 @@ describe('AlertSummaryView', () => { values: ['127.0.0.1'], originalValue: ['127.0.0.1'], }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.new_terms_fields', + values: ['host.ip'], + originalValue: ['host.ip'], + }, ] as TimelineEventsDetailsItem[]; const renderProps = { ...props, data: enhancedData, }; + const { getByText } = render( ); - ['New Terms'].forEach((fieldId) => { + ['New Terms', '127.0.0.1', 'New Terms fields', 'host.ip'].forEach((fieldId) => { expect(getByText(fieldId)); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 5caca1fcd7253..96bc3e030f7a2 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -16,8 +16,13 @@ import { ALERTS_HEADERS_THRESHOLD_TERMS, ALERTS_HEADERS_RULE_DESCRIPTION, ALERTS_HEADERS_NEW_TERMS, + ALERTS_HEADERS_NEW_TERMS_FIELDS, } from '../../../detections/components/alerts_table/translations'; -import { ALERT_NEW_TERMS, ALERT_THRESHOLD_RESULT } from '../../../../common/field_maps/field_names'; +import { + ALERT_NEW_TERMS_FIELDS, + ALERT_NEW_TERMS, + ALERT_THRESHOLD_RESULT, +} from '../../../../common/field_maps/field_names'; import { AGENT_STATUS_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; import type { AlertSummaryRow } from './helpers'; import { getEnrichedFieldInfo } from './helpers'; @@ -172,6 +177,10 @@ function getFieldsByRuleType(ruleType?: string): EventSummaryField[] { ]; case 'new_terms': return [ + { + id: ALERT_NEW_TERMS_FIELDS, + label: ALERTS_HEADERS_NEW_TERMS_FIELDS, + }, { id: ALERT_NEW_TERMS, label: ALERTS_HEADERS_NEW_TERMS, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 3f2f3ca8c5a15..21e80a6770c1f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -123,6 +123,13 @@ export const ALERTS_HEADERS_NEW_TERMS = i18n.translate( } ); +export const ALERTS_HEADERS_NEW_TERMS_FIELDS = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.newTermsFields', + { + defaultMessage: 'New Terms fields', + } +); + export const ACTION_INVESTIGATE_IN_TIMELINE = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.actions.investigateInTimelineTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index dc926fd4f74e2..9cbe2362acb6d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -59,6 +59,7 @@ import { useFormData, } from '../../../../shared_imports'; import { schema } from './schema'; +import { getTermsAggregationFields } from './utils'; import * as i18n from './translations'; import { isEqlRule, @@ -297,6 +298,11 @@ const StepDefineRuleComponent: FC = ({ setAggregatableFields(aggregatableFields(fields as BrowserField[])); }, [indexPattern]); + const termsAggregationFields: BrowserField[] = useMemo( + () => getTermsAggregationFields(aggFields), + [aggFields] + ); + const [ threatIndexPatternsLoading, { browserFields: threatBrowserFields, indexPatterns: threatIndexPatterns }, @@ -836,7 +842,7 @@ const StepDefineRuleComponent: FC = ({ path="newTermsFields" component={NewTermsFields} componentProps={{ - browserFields: aggFields, + browserFields: termsAggregationFields, }} /> = { i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.newTermsFieldsMin', { - defaultMessage: 'Number of fields must be 1.', + defaultMessage: 'A minimum of one field is required.', } ) )(...args); @@ -601,11 +602,11 @@ export const schema: FormSchema = { return; } return fieldValidators.maxLengthField({ - length: 1, + length: MAX_NUMBER_OF_NEW_TERMS_FIELDS, message: i18n.translate( 'xpack.securitySolution.detectionEngine.validations.stepDefineRule.newTermsFieldsMax', { - defaultMessage: 'Number of fields must be 1.', + defaultMessage: 'Number of fields must be 3 or less.', } ), })(...args); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/utils.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/utils.ts new file mode 100644 index 0000000000000..d8b63f5801159 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/utils.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { BrowserField } from '../../../../common/containers/source'; + +/** + * Filters out fields, that are not supported in terms aggregation. + * Terms aggregation supports limited number of types: + * Keyword, Numeric, ip, boolean, or binary. + * https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html + */ +export const getTermsAggregationFields = (fields: BrowserField[]): BrowserField[] => { + // binary types is excluded, as binary field has property aggregatable === false + const allowedTypesSet = new Set(['string', 'number', 'ip', 'boolean']); + + return fields.filter((field) => field.aggregatable === true && allowedTypesSet.has(field.type)); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/README.md index 50ec5e7682a28..694fdd53fe2f4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/README.md @@ -2,7 +2,7 @@ The rule accepts 2 new parameters that are unique to the new_terms rule type, in addition to common Security rule parameters such as query, index, and filters, to, from, etc. The new parameters are: -- `new_terms_fields`: an array of field names, currently limited to an array of size 1. In the future we will likely allow multiple field names to be specified here. +- `new_terms_fields`: an array of field names, currently limited to an array of size 3. Example: ['host.ip'] - `history_window_start`: defines the additional time range to search over when determining if a term is "new". If a term is found between the times `history_window_start` and from then it will not be classified as a new term. Example: now-30d @@ -12,6 +12,7 @@ Each page is evaluated in 3 phases. Phase 1: Collect "recent" terms - terms that have appeared in the last rule interval, without regard to whether or not they have appeared in historical data. This is done using a composite aggregation to ensure we can iterate over every term. Phase 2: Check if the page of terms contains any new terms. This uses a regular terms agg with the include parameter - every term is added to the array of include values, so the terms agg is limited to only aggregating on the terms of interest from phase 1. This avoids issues with the terms agg providing approximate results due to getting different terms from different shards. +For multiple new terms fields(['source.host', 'source.ip']), in terms aggregation uses a runtime field. Which is created by joining values from new terms fields into one single keyword value. Fields values encoded in base64 and joined with configured a delimiter symbol, which is not part of base64 symbols(a–Z, 0–9, +, /, =) to avoid a situation when delimiter can be part of field value. Include parameter consists of encoded in base64 results from Phase 1. Phase 3: Any new terms from phase 2 are processed and the first document to contain that term is retrieved. The document becomes the basis of the generated alert. This is done with an aggregation query that is very similar to the agg used in phase 2, except it also includes a top_hits agg. top_hits is moved to a separate, later phase for efficiency - top_hits is slow and most terms will not be new in phase 2. This means we only execute the top_hits agg on the terms that are actually new which is faster. @@ -26,4 +27,4 @@ The new terms rule type reuses the singleSearchAfter function which implements t ## Limitations and future enhancements - Value list exceptions are not supported at the moment. Commit ead04ce removes an experimental method I tried for evaluating value list exceptions. -- In the future we may want to support searching for new sets of terms, e.g. a pair of `host.ip` and `host.id` that has never been seen together before. +- Runtime field supports only 100 emitted values. So for large arrays or combination of values greater than 100, results may not be exhaustive. This applies only to new terms with multiple fields diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/__snapshots__/build_new_terms_aggregation.test.ts.snap b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/__snapshots__/build_new_terms_aggregation.test.ts.snap index 72df8a34cfa18..022f4eba9365c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/__snapshots__/build_new_terms_aggregation.test.ts.snap +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/__snapshots__/build_new_terms_aggregation.test.ts.snap @@ -135,3 +135,37 @@ Object { }, } `; + +exports[`aggregations buildRecentTermsAgg builds a correct composite aggregation with multiple fields 1`] = ` +Object { + "new_terms": Object { + "composite": Object { + "after": undefined, + "size": 10000, + "sources": Array [ + Object { + "host.name": Object { + "terms": Object { + "field": "host.name", + }, + }, + }, + Object { + "host.port": Object { + "terms": Object { + "field": "host.port", + }, + }, + }, + Object { + "host.url": Object { + "terms": Object { + "field": "host.url", + }, + }, + }, + ], + }, + }, +} +`; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.test.ts index 9b853a730ba4c..ec81c06b92837 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.test.ts @@ -16,7 +16,7 @@ describe('aggregations', () => { describe('buildRecentTermsAgg', () => { test('builds a correct composite agg without `after`', () => { const aggregation = buildRecentTermsAgg({ - field: 'host.name', + fields: ['host.name'], after: undefined, }); @@ -25,12 +25,21 @@ describe('aggregations', () => { test('builds a correct composite aggregation with `after`', () => { const aggregation = buildRecentTermsAgg({ - field: 'host.name', + fields: ['host.name'], after: { 'host.name': 'myHost' }, }); expect(aggregation).toMatchSnapshot(); }); + + test('builds a correct composite aggregation with multiple fields', () => { + const aggregation = buildRecentTermsAgg({ + fields: ['host.name', 'host.port', 'host.url'], + after: undefined, + }); + + expect(aggregation).toMatchSnapshot(); + }); }); describe('buildNewTermsAggregation', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.ts index 41f2f7c6dc0ab..e9bf89554941f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/build_new_terms_aggregation.ts @@ -31,24 +31,24 @@ const PAGE_SIZE = 10000; * without regard to whether or not they're actually new. */ export const buildRecentTermsAgg = ({ - field, + fields, after, }: { - field: string; + fields: string[]; after: Record | undefined; }) => { + const sources = fields.map((field) => ({ + [field]: { + terms: { + field, + }, + }, + })); + return { new_terms: { composite: { - sources: [ - { - [field]: { - terms: { - field, - }, - }, - }, - ], + sources, size: PAGE_SIZE, after, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index 8fce33897bd0f..bc2746ddf7888 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { validateNonExact } from '@kbn/securitysolution-io-ts-utils'; import { NEW_TERMS_RULE_TYPE_ID } from '@kbn/securitysolution-rules'; import { SERVER_APP_ID } from '../../../../../common/constants'; @@ -16,6 +15,7 @@ import type { CreateRuleOptions, SecurityAlertType } from '../types'; import { singleSearchAfter } from '../../signals/single_search_after'; import { getFilter } from '../../signals/get_filter'; import { wrapNewTermsAlerts } from '../factories/utils/wrap_new_terms_alerts'; +import type { EventsAndTerms } from '../factories/utils/wrap_new_terms_alerts'; import type { DocFetchAggResult, RecentTermsAggResult, @@ -26,9 +26,15 @@ import { buildRecentTermsAgg, buildNewTermsAgg, } from './build_new_terms_aggregation'; -import type { SignalSource } from '../../signals/types'; import { validateIndexPatterns } from '../utils'; -import { parseDateString, validateHistoryWindowStart } from './utils'; +import { + parseDateString, + validateHistoryWindowStart, + transformBucketsToValues, + getNewTermsRuntimeMappings, + getAggregationField, + decodeMatchedValues, +} from './utils'; import { addToSearchAfterReturn, createSearchAfterReturnType, @@ -154,7 +160,7 @@ export const createNewTermsAlertType = ( // ones are new. const { searchResult, searchDuration, searchErrors } = await singleSearchAfter({ aggregations: buildRecentTermsAgg({ - field: params.newTermsFields[0], + fields: params.newTermsFields, after: afterKey, }), searchAfterSortIds: undefined, @@ -187,10 +193,7 @@ export const createNewTermsAlertType = ( break; } const bucketsForField = searchResultWithAggs.aggregations.new_terms.buckets; - const includeValues = bucketsForField - .map((bucket) => Object.values(bucket.key)[0]) - .filter((value): value is string | number => value != null); - + const includeValues = transformBucketsToValues(params.newTermsFields, bucketsForField); // PHASE 2: Take the page of results from Phase 1 and determine if each term exists in the history window. // The aggregation filters out buckets for terms that exist prior to `tuple.from`, so the buckets in the // response correspond to each new term. @@ -202,10 +205,13 @@ export const createNewTermsAlertType = ( aggregations: buildNewTermsAgg({ newValueWindowStart: tuple.from, timestampField: aggregatableTimestampField, - field: params.newTermsFields[0], + field: getAggregationField(params.newTermsFields), include: includeValues, }), - runtimeMappings, + runtimeMappings: { + ...runtimeMappings, + ...getNewTermsRuntimeMappings(params.newTermsFields), + }, searchAfterSortIds: undefined, index: inputIndex, // For Phase 2, we expand the time range to aggregate over the history window @@ -245,10 +251,13 @@ export const createNewTermsAlertType = ( } = await singleSearchAfter({ aggregations: buildDocFetchAgg({ timestampField: aggregatableTimestampField, - field: params.newTermsFields[0], + field: getAggregationField(params.newTermsFields), include: actualNewTerms, }), - runtimeMappings, + runtimeMappings: { + ...runtimeMappings, + ...getNewTermsRuntimeMappings(params.newTermsFields), + }, searchAfterSortIds: undefined, index: inputIndex, // For phase 3, we go back to aggregating only over the rule interval - excluding the history window @@ -270,13 +279,14 @@ export const createNewTermsAlertType = ( throw new Error('Aggregations were missing on document fetch search result'); } - const eventsAndTerms: Array<{ - event: estypes.SearchHit; - newTerms: Array; - }> = docFetchResultWithAggs.aggregations.new_terms.buckets.map((bucket) => ({ - event: bucket.docs.hits.hits[0], - newTerms: [bucket.key], - })); + const eventsAndTerms: EventsAndTerms[] = + docFetchResultWithAggs.aggregations.new_terms.buckets.map((bucket) => { + const newTerms = decodeMatchedValues(params.newTermsFields, bucket.key); + return { + event: bucket.docs.hits.hits[0], + newTerms, + }; + }); const alertTimestampOverride = isPreview ? startedAt : undefined; const wrappedAlerts = wrapNewTermsAlerts({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.test.ts index e1207eccf82b0..2b04b617ba9ba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.test.ts @@ -5,7 +5,15 @@ * 2.0. */ -import { parseDateString, validateHistoryWindowStart } from './utils'; +import { + parseDateString, + validateHistoryWindowStart, + transformBucketsToValues, + getAggregationField, + decodeMatchedValues, + getNewTermsRuntimeMappings, + AGG_FIELD_NAME, +} from './utils'; describe('new terms utils', () => { describe('parseDateString', () => { @@ -64,4 +72,140 @@ describe('new terms utils', () => { ); }); }); + + describe('transformBucketsToValues', () => { + it('should return correct value for a single new terms field', () => { + expect( + transformBucketsToValues( + ['source.host'], + [ + { + key: { + 'source.host': 'host-0', + }, + doc_count: 1, + }, + { + key: { + 'source.host': 'host-1', + }, + doc_count: 3, + }, + ] + ) + ).toEqual(['host-0', 'host-1']); + }); + + it('should filter null values for a single new terms field', () => { + expect( + transformBucketsToValues( + ['source.host'], + [ + { + key: { + 'source.host': 'host-0', + }, + doc_count: 1, + }, + { + key: { + 'source.host': null, + }, + doc_count: 3, + }, + ] + ) + ).toEqual(['host-0']); + }); + + it('should return correct value for multiple new terms fields', () => { + expect( + transformBucketsToValues( + ['source.host', 'source.ip'], + [ + { + key: { + 'source.host': 'host-0', + 'source.ip': '127.0.0.1', + }, + doc_count: 1, + }, + { + key: { + 'source.host': 'host-1', + 'source.ip': '127.0.0.1', + }, + doc_count: 1, + }, + ] + ) + ).toEqual(['aG9zdC0w_MTI3LjAuMC4x', 'aG9zdC0x_MTI3LjAuMC4x']); + }); + + it('should filter null values for multiple new terms fields', () => { + expect( + transformBucketsToValues( + ['source.host', 'source.ip'], + [ + { + key: { + 'source.host': 'host-0', + 'source.ip': '127.0.0.1', + }, + doc_count: 1, + }, + { + key: { + 'source.host': 'host-1', + 'source.ip': null, + }, + doc_count: 1, + }, + ] + ) + ).toEqual(['aG9zdC0w_MTI3LjAuMC4x']); + }); + }); + + describe('getAggregationField', () => { + it('should return correct value for a single new terms field', () => { + expect(getAggregationField(['source.ip'])).toBe('source.ip'); + }); + it('should return correct value for multiple new terms fields', () => { + expect(getAggregationField(['source.host', 'source.ip'])).toBe(AGG_FIELD_NAME); + }); + }); + + describe('decodeMatchedValues', () => { + it('should return correct value for a single new terms field', () => { + expect(decodeMatchedValues(['source.ip'], '127.0.0.1')).toEqual(['127.0.0.1']); + }); + it('should return correct value for multiple new terms fields', () => { + expect(decodeMatchedValues(['source.host', 'source.ip'], 'aG9zdC0w_MTI3LjAuMC4x')).toEqual([ + 'host-0', + '127.0.0.1', + ]); + }); + }); + + describe('getNewTermsRuntimeMappings', () => { + it('should not return runtime field if new terms fields is empty', () => { + expect(getNewTermsRuntimeMappings([])).toBeUndefined(); + }); + it('should not return runtime field if new terms fields has only one field', () => { + expect(getNewTermsRuntimeMappings(['host.name'])).toBeUndefined(); + }); + + it('should return runtime field if new terms fields has more than one field', () => { + const runtimeMappings = getNewTermsRuntimeMappings(['host.name', 'host.ip']); + + expect(runtimeMappings?.[AGG_FIELD_NAME]).toMatchObject({ + type: 'keyword', + script: { + params: { fields: ['host.name', 'host.ip'] }, + source: expect.any(String), + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.ts index 4a87ec9edbbda..cebd63f17e663 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/utils.ts @@ -7,6 +7,10 @@ import dateMath from '@elastic/datemath'; import moment from 'moment'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +export const AGG_FIELD_NAME = 'new_terms_values'; +const DELIMITER = '_'; export const parseDateString = ({ date, @@ -46,3 +50,106 @@ export const validateHistoryWindowStart = ({ ); } }; + +/** + * Takes a list of buckets and creates value from them to be used in 'include' clause of terms aggregation. + * For a single new terms field, value equals to bucket name + * For multiple new terms fields and buckets, value equals to concatenated base64 encoded bucket names + * @returns for buckets('host-0', 'test'), resulted value equals to: 'aG9zdC0w_dGVzdA==' + */ +export const transformBucketsToValues = ( + newTermsFields: string[], + buckets: estypes.AggregationsCompositeBucket[] +): Array => { + // if new terms include only one field we don't use runtime mappings and don't stich fields buckets together + if (newTermsFields.length === 1) { + return buckets + .map((bucket) => Object.values(bucket.key)[0]) + .filter((value): value is string | number => value != null); + } + + return buckets + .map((bucket) => Object.values(bucket.key)) + .filter((values) => !values.some((value) => value == null)) + .map((values) => + values + .map((value) => + Buffer.from(typeof value !== 'string' ? value.toString() : value).toString('base64') + ) + .join(DELIMITER) + ); +}; + +export const getNewTermsRuntimeMappings = ( + newTermsFields: string[] +): undefined | { [AGG_FIELD_NAME]: estypes.MappingRuntimeField } => { + // if new terms include only one field we don't use runtime mappings and don't stich fields buckets together + if (newTermsFields.length <= 1) { + return undefined; + } + + return { + [AGG_FIELD_NAME]: { + type: 'keyword', + script: { + params: { fields: newTermsFields }, + source: ` + def stack = new Stack(); + // ES has limit in 100 values for runtime field, after this query will fail + int emitLimit = 100; + stack.add([0, '']); + + while (stack.length > 0) { + if (emitLimit == 0) { + break; + } + def tuple = stack.pop(); + def index = tuple[0]; + def line = tuple[1]; + if (index === params['fields'].length) { + emit(line); + emitLimit = emitLimit - 1; + } else { + for (field in doc[params['fields'][index]]) { + def delimiter = index === 0 ? '' : '${DELIMITER}'; + def nextLine = line + delimiter + String.valueOf(field).encodeBase64(); + + stack.add([index + 1, nextLine]) + } + } + } + `, + }, + }, + }; +}; + +/** + * For a single new terms field, aggregation field equals to new terms field + * For multiple new terms fields, aggregation field equals to defined AGG_FIELD_NAME, which is runtime field + */ +export const getAggregationField = (newTermsFields: string[]): string => { + // if new terms include only one field we don't use runtime mappings and don't stich fields buckets together + if (newTermsFields.length === 1) { + return newTermsFields[0]; + } + + return AGG_FIELD_NAME; +}; + +const decodeBucketKey = (bucketKey: string): string[] => { + return bucketKey + .split(DELIMITER) + .map((encodedValue) => Buffer.from(encodedValue, 'base64').toString()); +}; + +/** + * decodes matched values(bucket keys) from terms aggregation and returns fields as array + * @returns 'aG9zdC0w_dGVzdA==' bucket key will result in ['host-0', 'test'] + */ +export const decodeMatchedValues = (newTermsFields: string[], bucketKey: string | number) => { + // if newTermsFields has length greater than 1, bucketKey can't be number, so casting is safe here + const values = newTermsFields.length === 1 ? [bucketKey] : decodeBucketKey(bucketKey as string); + + return values; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx index 651d751902777..e1a7dced64928 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_add_page.tsx @@ -9,14 +9,17 @@ import React, { useEffect } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { useTrackPageview } from '@kbn/observability-plugin/public'; -import { useKibanaSpace } from './hooks'; + import { getServiceLocations } from '../../state'; +import { ServiceAllowedWrapper } from '../common/wrappers/service_allowed_wrapper'; + +import { useKibanaSpace } from './hooks'; import { MonitorSteps } from './steps'; import { MonitorForm } from './form'; import { ADD_MONITOR_STEPS } from './steps/step_config'; import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs'; -export const MonitorAddPage = () => { +const MonitorAddPage = () => { useTrackPageview({ app: 'synthetics', path: 'add-monitor' }); const { space, loading, error } = useKibanaSpace(); useTrackPageview({ app: 'synthetics', path: 'add-monitor', delay: 15000 }); @@ -35,3 +38,9 @@ export const MonitorAddPage = () => { ); }; + +export const MonitorAddPageWithServiceAllowed = React.memo(() => ( + + + +)); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.tsx index a1aba64f43f4c..53057a9dbba5c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/monitor_edit_page.tsx @@ -11,6 +11,7 @@ import { useParams } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useTrackPageview, useFetcher } from '@kbn/observability-plugin/public'; import { getServiceLocations } from '../../state'; +import { ServiceAllowedWrapper } from '../common/wrappers/service_allowed_wrapper'; import { MonitorSteps } from './steps'; import { MonitorForm } from './form'; import { MonitorDetailsLinkPortal } from './monitor_details_portal'; @@ -18,7 +19,7 @@ import { useMonitorAddEditBreadcrumbs } from './use_breadcrumbs'; import { getMonitorAPI } from '../../state/monitor_management/api'; import { EDIT_MONITOR_STEPS } from './steps/step_config'; -export const MonitorEditPage: React.FC = () => { +const MonitorEditPage: React.FC = () => { useTrackPageview({ app: 'synthetics', path: 'edit-monitor' }); useTrackPageview({ app: 'synthetics', path: 'edit-monitor', delay: 15000 }); const { monitorId } = useParams<{ monitorId: string }>(); @@ -42,3 +43,9 @@ export const MonitorEditPage: React.FC = () => { ); }; + +export const MonitorEditPageWithServiceAllowed = React.memo(() => ( + + + +)); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx index e104eeb87abdd..000706140d267 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { i18n } from '@kbn/i18n'; import { ClientPluginsStart } from '../../../../../plugin'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; import { useSelectedLocation } from '../hooks/use_selected_location'; @@ -24,8 +25,6 @@ export const MonitorDurationTrend = (props: MonitorDurationTrendProps) => { const monitorId = useMonitorQueryId(); const selectedLocation = useSelectedLocation(); - const metricsToShow = ['min', 'max', 'median', '25th', '75th']; - if (!selectedLocation) { return null; } @@ -34,10 +33,10 @@ export const MonitorDurationTrend = (props: MonitorDurationTrendProps) => { ({ + attributes={Object.keys(metricsToShow).map((metric) => ({ dataType: 'synthetics', time: props, - name: metric + ' Series', + name: metricsToShow[metric], selectedMetricField: 'monitor.duration.us', reportDefinitions: { 'monitor.id': [monitorId], @@ -49,3 +48,31 @@ export const MonitorDurationTrend = (props: MonitorDurationTrendProps) => { /> ); }; + +const MIN_LABEL = i18n.translate('xpack.synthetics.durationTrend.min', { + defaultMessage: 'Min', +}); + +const MAX_LABEL = i18n.translate('xpack.synthetics.durationTrend.max', { + defaultMessage: 'Max', +}); + +const MEDIAN_LABEL = i18n.translate('xpack.synthetics.durationTrend.median', { + defaultMessage: 'Median', +}); + +const PERCENTILE_25_LABEL = i18n.translate('xpack.synthetics.durationTrend.percentile25', { + defaultMessage: '25th', +}); + +const PERCENTILE_75_LABEL = i18n.translate('xpack.synthetics.durationTrend.percentile75', { + defaultMessage: '75th', +}); + +const metricsToShow: Record = { + max: MAX_LABEL, + '75th': PERCENTILE_75_LABEL, + median: MEDIAN_LABEL, + '25th': PERCENTILE_25_LABEL, + min: MIN_LABEL, +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_ilst.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_ilst.test.tsx new file mode 100644 index 0000000000000..19204efe110df --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_ilst.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import * as redux from 'react-redux'; +import { MONITOR_ROUTE } from '../../../../../../common/constants'; +import { mockState } from '../../../utils/testing/__mocks__/synthetics_store.mock'; +import { WrappedHelper } from '../../../utils/testing'; +import { SyntheticsAppState } from '../../../state/root_reducer'; +import { + selectEncryptedSyntheticsSavedMonitors, + fetchMonitorListAction, + MonitorListPageState, +} from '../../../state'; + +import { useMonitorList } from './use_monitor_list'; + +describe('useMonitorList', () => { + let state: SyntheticsAppState; + let initialState: Omit, 'loadPage' | 'reloadPage'>; + let defaultPageState: MonitorListPageState; + const dispatchMockFn = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(redux, 'useDispatch').mockReturnValue(dispatchMockFn); + + state = mockState; + initialState = { + loading: false, + loaded: false, + total: state.monitorList.data.total ?? 0, + error: state.monitorList.error, + absoluteTotal: state.monitorList.data.absoluteTotal ?? 0, + pageState: state.monitorList.pageState, + isDataQueried: false, + syntheticsMonitors: selectEncryptedSyntheticsSavedMonitors.resultFunc(state.monitorList), + }; + + defaultPageState = { + ...state.monitorList.pageState, + query: '', + locations: [], + monitorType: [], + tags: [], + }; + }); + + it('returns expected initial state', () => { + const { + result: { current: hookResult }, + } = renderHook(() => useMonitorList(), { wrapper: WrappedHelper }); + + expect(hookResult).toMatchObject(initialState); + }); + + it('dispatches correct action for query url param', async () => { + const query = 'xyz'; + const url = `/monitor/1?query=${query}`; + const WrapperWithState = ({ children }: { children: React.ReactElement }) => { + return ( + + {children} + + ); + }; + + renderHook(() => useMonitorList(), { wrapper: WrapperWithState }); + + expect(dispatchMockFn).toHaveBeenCalledWith( + fetchMonitorListAction.get({ ...defaultPageState, query }) + ); + }); + + it('dispatches correct action for filter url param', async () => { + const tags = ['abc', 'xyz']; + const locations = ['loc1', 'loc1']; + const monitorType = ['browser']; + + const url = `/monitor/1?tags=${JSON.stringify(tags)}&locations=${JSON.stringify( + locations + )}&monitorType=${JSON.stringify(monitorType)}`; + const WrapperWithState = ({ children }: { children: React.ReactElement }) => { + return ( + + {children} + + ); + }; + + renderHook(() => useMonitorList(), { wrapper: WrapperWithState }); + + expect(dispatchMockFn).toHaveBeenCalledWith( + fetchMonitorListAction.get({ ...defaultPageState, tags, locations, monitorType }) + ); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts index ba8b43677809a..9b30c5590f950 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useEffect, useCallback, useState } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { useGetUrlParams } from '../../../hooks'; @@ -18,7 +18,7 @@ import { export function useMonitorList() { const dispatch = useDispatch(); - const [isDataQueried, setIsDataQueried] = useState(false); + const isDataQueriedRef = useRef(false); const { pageState, loading, loaded, error, data } = useSelector(selectMonitorListState); const syntheticsMonitors = useSelector(selectEncryptedSyntheticsSavedMonitors); @@ -50,14 +50,15 @@ export function useMonitorList() { // Initial loading useEffect(() => { - if (!loading && !isDataQueried) { + if (!loading && !isDataQueriedRef.current) { + isDataQueriedRef.current = true; reloadPage(); } if (loading) { - setIsDataQueried(true); + isDataQueriedRef.current = true; } - }, [reloadPage, isDataQueried, syntheticsMonitors, loading]); + }, [reloadPage, syntheticsMonitors, loading]); return { loading, @@ -68,7 +69,7 @@ export function useMonitorList() { total: data?.total ?? 0, loadPage, reloadPage, - isDataQueried, + isDataQueried: isDataQueriedRef.current, absoluteTotal: data.absoluteTotal ?? 0, }; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/list_filters/filter_button.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/list_filters/filter_button.tsx index 3338a42e1e0ab..463c4bbeba003 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/list_filters/filter_button.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/list_filters/filter_button.tsx @@ -15,7 +15,7 @@ export const FilterButton = ({ filter }: { filter: FilterItem }) => { const [query, setQuery] = useState(''); - const updateUrlParams = useUrlParams()[1]; + const [, updateUrlParams] = useUrlParams(); const urlParams = useGetUrlParams(); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_container.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_container.tsx index 86663b21a5ea9..cd299b4f66708 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_container.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_container.tsx @@ -5,14 +5,22 @@ * 2.0. */ +import { EuiSpacer } from '@elastic/eui'; import React from 'react'; -import { useMonitorList } from '../hooks/use_monitor_list'; -import { MonitorList } from './monitor_list_table/monitor_list'; +import type { useMonitorList } from '../hooks/use_monitor_list'; import { MonitorAsyncError } from './monitor_errors/monitor_async_error'; import { useInlineErrors } from '../hooks/use_inline_errors'; +import { ListFilters } from './list_filters/list_filters'; +import { MonitorList } from './monitor_list_table/monitor_list'; -export const MonitorListContainer = ({ isEnabled }: { isEnabled?: boolean }) => { +export const MonitorListContainer = ({ + isEnabled, + monitorListProps, +}: { + isEnabled?: boolean; + monitorListProps: ReturnType; +}) => { const { pageState, error, @@ -22,7 +30,7 @@ export const MonitorListContainer = ({ isEnabled }: { isEnabled?: boolean }) => absoluteTotal, loadPage, reloadPage, - } = useMonitorList(); + } = monitorListProps; const { errorSummaries, loading: errorsLoading } = useInlineErrors({ onlyInvalidMonitors: false, @@ -37,6 +45,8 @@ export const MonitorListContainer = ({ isEnabled }: { isEnabled?: boolean }) => return ( <> + + - - {recordRangeLabel}
diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/monitor_page.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/monitor_page.tsx index 3bbb64418f580..6bd9578fa5374 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/monitor_page.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/monitor_page.tsx @@ -12,6 +12,7 @@ import { useTrackPageview } from '@kbn/observability-plugin/public'; import { GETTING_STARTED_ROUTE } from '../../../../../common/constants'; +import { ServiceAllowedWrapper } from '../common/wrappers/service_allowed_wrapper'; import { useLocations } from '../../hooks'; import { Loader } from './management/loader/loader'; @@ -23,18 +24,19 @@ import { useMonitorListBreadcrumbs } from './hooks/use_breadcrumbs'; import { useMonitorList } from './hooks/use_monitor_list'; import * as labels from './management/labels'; -export const MonitorPage: React.FC = () => { +const MonitorPage: React.FC = () => { useTrackPageview({ app: 'synthetics', path: 'monitors' }); useTrackPageview({ app: 'synthetics', path: 'monitors', delay: 15000 }); useMonitorListBreadcrumbs(); + const monitorListProps = useMonitorList(); const { syntheticsMonitors, loading: monitorsLoading, isDataQueried, absoluteTotal, - } = useMonitorList(); + } = monitorListProps; const { error: enablementError, @@ -85,9 +87,15 @@ export const MonitorPage: React.FC = () => { ) : null} - + {showEmptyState && } ); }; + +export const MonitorsPageWithServiceAllowed = React.memo(() => ( + + + +)); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx index fb28f293ae6ae..5f03369c5f382 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/routes.tsx @@ -20,8 +20,8 @@ import { getSettingsRouteConfig } from './components/settings/route_config'; import { TestRunDetails } from './components/test_run_details/test_run_details'; import { ErrorDetailsPage } from './components/error_details/error_details_page'; import { StepTitle } from './components/step_details_page/step_title'; -import { MonitorAddPage } from './components/monitor_add_edit/monitor_add_page'; -import { MonitorEditPage } from './components/monitor_add_edit/monitor_edit_page'; +import { MonitorAddPageWithServiceAllowed } from './components/monitor_add_edit/monitor_add_page'; +import { MonitorEditPageWithServiceAllowed } from './components/monitor_add_edit/monitor_edit_page'; import { MonitorDetailsPageTitle } from './components/monitor_details/monitor_details_page_title'; import { MonitorDetailsPage } from './components/monitor_details/monitor_details_page'; import { GettingStartedPage } from './components/getting_started/getting_started_page'; @@ -30,7 +30,6 @@ import { CreateMonitorButton } from './components/monitors_page/create_monitor_b import { OverviewPage } from './components/monitors_page/overview/overview_page'; import { SyntheticsPageTemplateComponent } from './components/common/pages/synthetics_page_template'; import { NotFoundPage } from './components/common/pages/not_found'; -import { ServiceAllowedWrapper } from './components/common/wrappers/service_allowed_wrapper'; import { MonitorTypePortalNode, MonitorDetailsLinkPortalNode, @@ -49,7 +48,7 @@ import { TEST_RUN_DETAILS_ROUTE, } from '../../../common/constants'; import { PLUGIN } from '../../../common/constants/plugin'; -import { MonitorPage } from './components/monitors_page/monitor_page'; +import { MonitorsPageWithServiceAllowed } from './components/monitors_page/monitor_page'; import { apiService } from '../../utils/api_service'; import { RunTestManually } from './components/monitor_details/run_test_manually'; import { MonitorDetailsStatus } from './components/monitor_details/monitor_details_status'; @@ -179,13 +178,7 @@ const getRoutes = ( values: { baseTitle }, }), path: MONITORS_ROUTE, - component: () => ( - <> - - - - - ), + component: MonitorsPageWithServiceAllowed, dataTestSubj: 'syntheticsMonitorManagementPage', pageHeader: { pageTitle: , @@ -218,11 +211,7 @@ const getRoutes = ( values: { baseTitle }, }), path: MONITOR_ADD_ROUTE, - component: () => ( - - - - ), + component: MonitorAddPageWithServiceAllowed, dataTestSubj: 'syntheticsMonitorAddPage', restrictWidth: true, pageHeader: { @@ -256,11 +245,7 @@ const getRoutes = ( values: { baseTitle }, }), path: MONITOR_EDIT_ROUTE, - component: () => ( - - - - ), + component: MonitorEditPageWithServiceAllowed, dataTestSubj: 'syntheticsMonitorEditPage', restrictWidth: true, pageHeader: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts index f86efc8bc0349..b43de3a1914d0 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/models.ts @@ -10,7 +10,7 @@ import { FetchMonitorManagementListQueryArgs, } from '../../../../../common/runtime_types'; -export type MonitorListSortField = `${keyof EncryptedSyntheticsSavedMonitor}.keyword`; +export type MonitorListSortField = `${keyof EncryptedSyntheticsSavedMonitor}.keyword` | 'enabled'; export interface MonitorListPageState { query?: string; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index 2c9953a51a26e..3a62b6df37df3 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -66,10 +66,14 @@ export const mockState: SyntheticsAppState = { }, monitorList: { pageState: { + query: undefined, pageIndex: 0, pageSize: 10, - sortOrder: 'asc', sortField: `${ConfigKey.NAME}.keyword`, + sortOrder: 'asc', + tags: undefined, + monitorType: undefined, + locations: undefined, }, monitorUpsertStatuses: {}, data: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/get_supported_url_params.test.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/get_supported_url_params.test.ts deleted file mode 100644 index 0b1c82aebe06b..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/get_supported_url_params.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import DateMath from '@kbn/datemath'; -import { getSupportedUrlParams } from '../url_params'; -import { CLIENT_DEFAULTS } from '../../../../../common/constants'; - -describe('getSupportedUrlParams', () => { - let dateMathSpy: any; - const MOCK_DATE_VALUE = 20; - - beforeEach(() => { - dateMathSpy = jest.spyOn(DateMath, 'parse'); - dateMathSpy.mockReturnValue(MOCK_DATE_VALUE); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns custom values', () => { - const customValues = { - autorefreshInterval: '23', - autorefreshIsPaused: 'false', - dateRangeStart: 'now-15m', - dateRangeEnd: 'now', - monitorListPageIndex: '23', - monitorListPageSize: '50', - monitorListSortDirection: 'desc', - monitorListSortField: 'monitor.status', - search: 'monitor.status: down', - selectedPingStatus: 'up', - }; - - const expected = { - absoluteDateRangeEnd: 20, - absoluteDateRangeStart: 20, - autorefreshInterval: 23, - autorefreshIsPaused: false, - dateRangeEnd: 'now', - dateRangeStart: 'now-15m', - search: 'monitor.status: down', - }; - - const result = getSupportedUrlParams(customValues); - expect(result).toMatchObject(expected); - }); - - it('returns default values', () => { - const { - AUTOREFRESH_INTERVAL, - AUTOREFRESH_IS_PAUSED, - DATE_RANGE_START, - DATE_RANGE_END, - FILTERS, - SEARCH, - STATUS_FILTER, - } = CLIENT_DEFAULTS; - const result = getSupportedUrlParams({}); - expect(result).toEqual({ - absoluteDateRangeStart: MOCK_DATE_VALUE, - absoluteDateRangeEnd: MOCK_DATE_VALUE, - autorefreshInterval: AUTOREFRESH_INTERVAL, - autorefreshIsPaused: AUTOREFRESH_IS_PAUSED, - dateRangeStart: DATE_RANGE_START, - dateRangeEnd: DATE_RANGE_END, - excludedFilters: '', - filters: FILTERS, - focusConnectorField: false, - pagination: undefined, - search: SEARCH, - statusFilter: STATUS_FILTER, - query: '', - locations: [], - monitorType: [], - tags: [], - }); - }); - - it('returns the first item for string arrays', () => { - const result = getSupportedUrlParams({ - dateRangeStart: ['now-18d', 'now-11d', 'now-5m'], - }); - - const expected = { - absoluteDateRangeEnd: 20, - absoluteDateRangeStart: 20, - autorefreshInterval: 60000, - }; - - expect(result).toMatchObject(expected); - }); - - it('provides defaults for undefined values', () => { - const result = getSupportedUrlParams({ - dateRangeStart: undefined, - }); - - const expected = { - absoluteDateRangeStart: 20, - }; - - expect(result).toMatchObject(expected); - }); - - it('provides defaults for empty string array values', () => { - const result = getSupportedUrlParams({ - dateRangeStart: [], - }); - - const expected = { - absoluteDateRangeStart: 20, - }; - - expect(result).toMatchObject(expected); - }); -}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/get_supported_url_params.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/get_supported_url_params.ts deleted file mode 100644 index c9badbcf7f728..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/get_supported_url_params.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { parseIsPaused } from '../url_params/parse_is_paused'; -import { parseUrlInt } from '../url_params/parse_url_int'; -import { CLIENT_DEFAULTS } from '../../../../../common/constants'; -import { parseAbsoluteDate } from '../url_params/parse_absolute_date'; - -export interface SyntheticsUrlParams { - absoluteDateRangeStart: number; - absoluteDateRangeEnd: number; - autorefreshInterval: number; - autorefreshIsPaused: boolean; - dateRangeStart: string; - dateRangeEnd: string; - pagination?: string; - filters: string; - excludedFilters: string; - search: string; - statusFilter: string; - focusConnectorField?: boolean; - query?: string; -} - -const { - ABSOLUTE_DATE_RANGE_START, - ABSOLUTE_DATE_RANGE_END, - AUTOREFRESH_INTERVAL, - AUTOREFRESH_IS_PAUSED, - DATE_RANGE_START, - DATE_RANGE_END, - SEARCH, - FILTERS, - STATUS_FILTER, -} = CLIENT_DEFAULTS; - -/** - * Gets the current URL values for the application. If no item is present - * for the URL, a default value is supplied. - * - * @param params A set of key-value pairs where the value is either - * undefined or a string/string array. If a string array is passed, - * only the first item is chosen. Support for lists in the URL will - * require further development. - */ -export const getSupportedUrlParams = (params: { - [key: string]: string | string[] | undefined | null; -}): SyntheticsUrlParams => { - const filteredParams: { [key: string]: string | undefined } = {}; - Object.keys(params).forEach((key) => { - let value: string | undefined; - if (params[key] === undefined) { - value = undefined; - } else if (Array.isArray(params[key])) { - // @ts-ignore this must be an array, and it's ok if the - // 0th element is undefined - value = params[key][0]; - } else { - // @ts-ignore this will not be an array because the preceding - // block tests for that - value = params[key]; - } - filteredParams[key] = value; - }); - - const { - autorefreshInterval, - autorefreshIsPaused, - dateRangeStart, - dateRangeEnd, - filters, - excludedFilters, - search, - statusFilter, - pagination, - focusConnectorField, - query, - } = filteredParams; - - return { - pagination, - absoluteDateRangeStart: parseAbsoluteDate( - dateRangeStart || DATE_RANGE_START, - ABSOLUTE_DATE_RANGE_START - ), - absoluteDateRangeEnd: parseAbsoluteDate( - dateRangeEnd || DATE_RANGE_END, - ABSOLUTE_DATE_RANGE_END, - { roundUp: true } - ), - autorefreshInterval: parseUrlInt(autorefreshInterval, AUTOREFRESH_INTERVAL), - autorefreshIsPaused: parseIsPaused(autorefreshIsPaused, AUTOREFRESH_IS_PAUSED), - dateRangeStart: dateRangeStart || DATE_RANGE_START, - dateRangeEnd: dateRangeEnd || DATE_RANGE_END, - filters: filters || FILTERS, - excludedFilters: excludedFilters || '', - search: search || SEARCH, - statusFilter: statusFilter || STATUS_FILTER, - focusConnectorField: !!focusConnectorField, - query: query || '', - }; -}; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/__snapshots__/down_number_select.test.tsx.snap b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/__snapshots__/down_number_select.test.tsx.snap index eb417a875de9e..527247ec9f072 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/__snapshots__/down_number_select.test.tsx.snap +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/monitor_expressions/__snapshots__/down_number_select.test.tsx.snap @@ -16,7 +16,7 @@ exports[`DownNoExpressionSelect component should renders against props 1`] = ` - matching monitors are down > + matching monitors are down >= } data-test-subj="xpack.synthetics.alerts.monitorStatus.numTimesExpression" - description="matching monitors are down >" + description="matching monitors are down >=" id="ping-count" value="5 times" /> diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts index 0580528b6b38c..de9da94460338 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/overview/alerts/translations.ts @@ -117,14 +117,14 @@ export const ENTER_NUMBER_OF_DOWN_COUNTS = i18n.translate( export const MATCHING_MONITORS_DOWN = i18n.translate( 'xpack.synthetics.alerts.monitorStatus.numTimesExpression.matchingMonitors.description', { - defaultMessage: 'matching monitors are down >', + defaultMessage: 'matching monitors are down >=', } ); export const ANY_MONITOR_DOWN = i18n.translate( 'xpack.synthetics.alerts.monitorStatus.numTimesExpression.anyMonitors.description', { - defaultMessage: 'any monitor is down >', + defaultMessage: 'any monitor is down >=', } ); diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/types.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/types.ts index c57b0924ac373..c57f435b29a8a 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/types.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/alerts/types.ts @@ -5,8 +5,8 @@ * 2.0. */ import { AlertTypeWithExecutor } from '@kbn/rule-registry-plugin/server'; -import { AlertInstanceContext, RuleTypeState } from '@kbn/alerting-plugin/common'; -import { LifecycleAlertService } from '@kbn/rule-registry-plugin/server'; +import { AlertInstanceContext } from '@kbn/alerting-plugin/common'; +import { LifecycleAlertServices } from '@kbn/rule-registry-plugin/server'; import { UMServerLibs } from '../lib'; import { UptimeCorePluginsSetup, UptimeServerSetup } from '../adapters'; @@ -20,11 +20,7 @@ export type DefaultUptimeAlertInstance = AlertTy Record, Record, AlertInstanceContext, - { - alertWithLifecycle: LifecycleAlertService; - getAlertStartedDate: (alertId: string) => string | null; - getAlertUuid: (alertId: string) => string | null; - } + LifecycleAlertServices, AlertInstanceContext, TActionGroupIds> >; export type UptimeAlertTypeFactory = ( diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts index 4967ebb5af446..ab25be0f3f3d6 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/synthetics_monitor.ts @@ -124,6 +124,9 @@ export const getSyntheticsMonitorSavedObjectType = ( }, }, }, + enabled: { + type: 'boolean', + }, }, }, management: { diff --git a/x-pack/plugins/synthetics/server/routes/common.ts b/x-pack/plugins/synthetics/server/routes/common.ts index 48f528030616d..5c49308469b27 100644 --- a/x-pack/plugins/synthetics/server/routes/common.ts +++ b/x-pack/plugins/synthetics/server/routes/common.ts @@ -59,10 +59,14 @@ export const getMonitors = ( const locationFilter = parseLocationFilter(syntheticsService.locations, locations); - const filters = - getKqlFilter({ field: 'tags', values: tags }) + - getKqlFilter({ field: 'type', values: monitorType }) + - getKqlFilter({ field: 'locations.id', values: locationFilter }); + const filterStr = [ + filter, + getKqlFilter({ field: 'tags', values: tags }), + getKqlFilter({ field: 'type', values: monitorType }), + getKqlFilter({ field: 'locations.id', values: locationFilter }), + ] + .filter((f) => !!f) + .join(' AND '); return savedObjectsClient.find({ type: syntheticsMonitorType, @@ -72,7 +76,7 @@ export const getMonitors = ( sortOrder, searchFields: ['name', 'tags.text', 'locations.id.text', 'urls', 'project_id.text'], search: query ? `${query}*` : undefined, - filter: filters + filter, + filter: filterStr, fields, searchAfter, }); @@ -123,3 +127,21 @@ const parseLocationFilter = (serviceLocations: ServiceLocations, locations?: str export const findLocationItem = (query: string, locations: ServiceLocations) => { return locations.find(({ id, label }) => query === id || label === query); }; + +/** + * Returns whether the query is likely to return a subset of monitor objects. + * Useful where `absoluteTotal` needs to be determined with a separate call + * @param monitorQuery { MonitorsQuery } + */ +export const isMonitorsQueryFiltered = (monitorQuery: MonitorsQuery) => { + const { query, tags, monitorType, locations, status, filter } = monitorQuery; + + return ( + !!query || + !!filter || + !!locations?.length || + !!monitorType?.length || + !!tags?.length || + !!status?.length + ); +}; diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts index c3c2b1829ceea..54b3b0bda0217 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/get_monitor.ts @@ -12,7 +12,7 @@ import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types' import { API_URLS, SYNTHETICS_API_URLS } from '../../../common/constants'; import { syntheticsMonitorType } from '../../legacy_uptime/lib/saved_objects/synthetics_monitor'; import { getMonitorNotFoundResponse } from '../synthetics_service/service_errors'; -import { getMonitors, QuerySchema, SEARCH_FIELDS } from '../common'; +import { getMonitors, isMonitorsQueryFiltered, QuerySchema, SEARCH_FIELDS } from '../common'; export const getSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', @@ -53,40 +53,27 @@ export const getAllSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => query: QuerySchema, }, handler: async ({ request, savedObjectsClient, syntheticsMonitorClient }): Promise => { - const { filters, query } = request.query; - const monitorsPromise = getMonitors( + const queryResult = await getMonitors( request.query, syntheticsMonitorClient.syntheticsService, savedObjectsClient ); - if (filters || query) { - const totalMonitorsPromise = savedObjectsClient.find({ - type: syntheticsMonitorType, - perPage: 0, - page: 1, - }); - - const allResolved = await Promise.all([monitorsPromise, totalMonitorsPromise]); - const { saved_objects: monitors, per_page: perPageT, ...rest } = allResolved[0]; - const { total } = allResolved[1]; - - return { - ...rest, - monitors, - perPage: perPageT, - absoluteTotal: total, - syncErrors: syntheticsMonitorClient.syntheticsService.syncErrors, - }; - } + const countResult = isMonitorsQueryFiltered(request.query) + ? await savedObjectsClient.find({ + type: syntheticsMonitorType, + perPage: 0, + page: 1, + }) + : queryResult; - const { saved_objects: monitors, per_page: perPageT, ...rest } = await monitorsPromise; + const { saved_objects: monitors, per_page: perPageT, ...rest } = queryResult; return { ...rest, monitors, perPage: perPageT, - absoluteTotal: rest.total, + absoluteTotal: countResult.total, syncErrors: syntheticsMonitorClient.syntheticsService.syncErrors, }; }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c8cbb70072d23..fb5af35a6fb33 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -9853,16 +9853,11 @@ "xpack.csp.cspEvaluationBadge.failLabel": "Échec", "xpack.csp.cspEvaluationBadge.passLabel": "Réussite", "xpack.csp.cspSettings.rules": "Règles de sécurité du CSP - ", - "xpack.csp.dashboard.benchmarkSection.complianceScorePanelTitle": "Score de conformité", - "xpack.csp.dashboard.benchmarkSection.failedFindingsPanelTitle": "Échec des résultats", - "xpack.csp.dashboard.casesTable.placeholderTitle": "Bientôt disponible", "xpack.csp.dashboard.cspPageTemplate.pageTitle": "Niveau du cloud", "xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "Section CIS", "xpack.csp.dashboard.risksTable.findingsColumnLabel": "Résultats", "xpack.csp.dashboard.risksTable.viewAllButtonTitle": "Afficher tous les échecs des résultats", "xpack.csp.dashboard.summarySection.cloudPostureScorePanelTitle": "Score du niveau du cloud", - "xpack.csp.dashboard.summarySection.failedFindingsPanelTitle": "Échec des résultats", - "xpack.csp.dashboard.summarySection.openCasesPanelTitle": "Cas ouverts", "xpack.csp.expandColumnDescriptionLabel": "Développer", "xpack.csp.expandColumnNameLabel": "Développer", "xpack.csp.findings.distributionBar.totalFailedLabel": "Échec des résultats", @@ -15572,6 +15567,9 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "Voir les instructions de configuration", "xpack.infra.homePage.settingsTabTitle": "Paramètres", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "Rechercher des données d'infrastructure… (par exemple host.name:host-1)", + "xpack.infra.hostsPage.experimentalBadgeDescription": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.", + "xpack.infra.hostsPage.experimentalBadgeLabel": "Version d'évaluation technique", + "xpack.infra.hostsPage.giveFeedbackLink": "Donner un retour", "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "Total de la mémoire (moy.)", "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "Utilisation de la mémoire (moy.)", "xpack.infra.hostsTable.averageRxColumnHeader": "", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4d7f13bdd9aaa..b792335f133ef 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9840,16 +9840,11 @@ "xpack.csp.cspEvaluationBadge.failLabel": "失敗", "xpack.csp.cspEvaluationBadge.passLabel": "合格", "xpack.csp.cspSettings.rules": "CSPセキュリティルール - ", - "xpack.csp.dashboard.benchmarkSection.complianceScorePanelTitle": "コンプライアンススコア", - "xpack.csp.dashboard.benchmarkSection.failedFindingsPanelTitle": "失敗した調査結果", - "xpack.csp.dashboard.casesTable.placeholderTitle": "まもなくリリース", "xpack.csp.dashboard.cspPageTemplate.pageTitle": "クラウド態勢", "xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "CISセクション", "xpack.csp.dashboard.risksTable.findingsColumnLabel": "調査結果", "xpack.csp.dashboard.risksTable.viewAllButtonTitle": "すべてのフィールド調査結果を表示", "xpack.csp.dashboard.summarySection.cloudPostureScorePanelTitle": "クラウド態勢スコア", - "xpack.csp.dashboard.summarySection.failedFindingsPanelTitle": "失敗した調査結果", - "xpack.csp.dashboard.summarySection.openCasesPanelTitle": "ケースを開く", "xpack.csp.expandColumnDescriptionLabel": "拡張", "xpack.csp.expandColumnNameLabel": "拡張", "xpack.csp.findings.distributionBar.totalFailedLabel": "失敗した調査結果", @@ -15557,6 +15552,9 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "セットアップの手順を表示", "xpack.infra.homePage.settingsTabTitle": "設定", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "インフラストラクチャデータを検索…(例:host.name:host-1)", + "xpack.infra.hostsPage.experimentalBadgeDescription": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", + "xpack.infra.hostsPage.experimentalBadgeLabel": "テクニカルプレビュー", + "xpack.infra.hostsPage.giveFeedbackLink": "フィードバックを作成する", "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "メモリ合計 (平均) ", "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "メモリー使用状況(平均)", "xpack.infra.hostsTable.averageTxColumnHeader": "", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0d1d73517f78b..f8ab94422d563 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9858,16 +9858,11 @@ "xpack.csp.cspEvaluationBadge.failLabel": "失败", "xpack.csp.cspEvaluationBadge.passLabel": "通过", "xpack.csp.cspSettings.rules": "CSP 安全规则 - ", - "xpack.csp.dashboard.benchmarkSection.complianceScorePanelTitle": "合规性分数", - "xpack.csp.dashboard.benchmarkSection.failedFindingsPanelTitle": "失败的结果", - "xpack.csp.dashboard.casesTable.placeholderTitle": "即将推出", "xpack.csp.dashboard.cspPageTemplate.pageTitle": "云态势", "xpack.csp.dashboard.risksTable.cisSectionColumnLabel": "CIS 部分", "xpack.csp.dashboard.risksTable.findingsColumnLabel": "结果", "xpack.csp.dashboard.risksTable.viewAllButtonTitle": "查看所有失败的结果", "xpack.csp.dashboard.summarySection.cloudPostureScorePanelTitle": "云态势分数", - "xpack.csp.dashboard.summarySection.failedFindingsPanelTitle": "失败的结果", - "xpack.csp.dashboard.summarySection.openCasesPanelTitle": "未结案例", "xpack.csp.expandColumnDescriptionLabel": "展开", "xpack.csp.expandColumnNameLabel": "展开", "xpack.csp.findings.distributionBar.totalFailedLabel": "失败的结果", @@ -15578,6 +15573,9 @@ "xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel": "查看设置说明", "xpack.infra.homePage.settingsTabTitle": "设置", "xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder": "搜索基础设施数据……(例如 host.name:host-1)", + "xpack.infra.hostsPage.experimentalBadgeDescription": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", + "xpack.infra.hostsPage.experimentalBadgeLabel": "技术预览", + "xpack.infra.hostsPage.giveFeedbackLink": "反馈", "xpack.infra.hostsTable.averageMemoryTotalColumnHeader": "内存合计 (平均值)", "xpack.infra.hostsTable.averageMemoryUsageColumnHeader": "内存使用率(平均值)", "xpack.infra.hostsTable.averageRxColumnHeader": "", diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics_overview_top.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics_overview_top.ts index b3b3f53d6d6c3..0d88e78841f8e 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics_overview_top.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics_overview_top.ts @@ -47,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) { expect(series.length).to.be(1); expect(series[0].id).to.be('demo-stack-mysql-01'); + expect(series[0].timeseries[1].timestamp - series[0].timeseries[0].timestamp).to.be(300_000); }); }); } diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index d11ad982a0f45..242b70d3f48d9 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -84,7 +84,8 @@ export default function ({ getService }: FtrProviderContext) { }; describe('Privileges', () => { - describe('GET /api/security/privileges', () => { + // FLAKY: https://github.com/elastic/kibana/issues/145135 + describe.skip('GET /api/security/privileges', () => { it('should return a privilege map with all known privileges, without actions', async () => { // If you're adding a privilege to the following, that's great! // If you're removing a privilege, this breaks backwards compatibility @@ -193,7 +194,8 @@ export default function ({ getService }: FtrProviderContext) { }); // In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag - describe('GET /api/security/privileges?respectLicenseLevel=false', () => { + // FLAKY: https://github.com/elastic/kibana/issues/145136 + describe.skip('GET /api/security/privileges?respectLicenseLevel=false', () => { it('should return a privilege map with all known privileges, without actions', async () => { // If you're adding a privilege to the following, that's great! // If you're removing a privilege, this breaks backwards compatibility diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index ba4fefd9ae691..2e1b01a6bc715 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -13,7 +13,8 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('Privileges', () => { - describe('GET /api/security/privileges', () => { + // FLAKY: https://github.com/elastic/kibana/issues/145134 + describe.skip('GET /api/security/privileges', () => { it('should return a privilege map with all known privileges, without actions', async () => { // If you're adding a privilege to the following, that's great! // If you're removing a privilege, this breaks backwards compatibility diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts index 095ce3766918d..cd961fce7aed0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_new_terms.ts @@ -40,5 +40,22 @@ export default ({ getService }: FtrProviderContext) => { "params invalid: History window size is smaller than rule interval + additional lookback, 'historyWindowStart' must be earlier than 'from'" ); }); + + it('should not be able to create a new terms rule with fields number greater than 3', async () => { + const rule = { + ...getCreateNewTermsRulesSchemaMock('rule-1'), + history_window_start: 'now-5m', + new_terms_fields: ['field1', 'field2', 'field3', 'field4'], + }; + const response = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule); + + expect(response.status).to.equal(400); + expect(response.body.message).to.be( + '[request body]: Array size (4) is out of bounds: min: 1, max: 3' + ); + }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts index 4bfbe92118599..3b1304f12e6c3 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts @@ -11,6 +11,10 @@ import { NewTermsRuleCreateProps } from '@kbn/security-solution-plugin/common/de import { orderBy } from 'lodash'; import { getCreateNewTermsRulesSchemaMock } from '@kbn/security-solution-plugin/common/detection_engine/rule_schema/mocks'; import { DetectionAlert } from '@kbn/security-solution-plugin/common/detection_engine/schemas/alerts'; +import { + getNewTermsRuntimeMappings, + AGG_FIELD_NAME, +} from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/new_terms/utils'; import { createRule, deleteAllAlerts, @@ -18,6 +22,7 @@ import { getOpenSignals, getPreviewAlerts, previewRule, + performSearchQuery, } from '../../utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { previewRuleWithExceptionEntries } from '../../utils/preview_rule_with_exception_entries'; @@ -50,10 +55,12 @@ export default ({ getService }: FtrProviderContext) => { describe('New terms type rules', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); + await esArchiver.load('x-pack/test/functional/es_archives/security_solution/new_terms'); }); after(async () => { await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts'); + await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/new_terms'); await deleteSignalsIndex(supertest, log); await deleteAllAlerts(supertest, log); }); @@ -228,6 +235,130 @@ export default ({ getService }: FtrProviderContext) => { ]); }); + it('should generate 3 alerts when 1 document has 3 new values for multiple fields', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + new_terms_fields: ['host.name', 'host.ip'], + from: '2019-02-19T20:42:00.000Z', + history_window_start: '2019-01-19T20:42:00.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(3); + + const newTerms = orderBy( + previewAlerts.map((item) => item._source?.['kibana.alert.new_terms']), + ['0', '1'] + ); + + expect(newTerms).eql([ + ['zeek-newyork-sha-aa8df15', '10.10.0.6'], + ['zeek-newyork-sha-aa8df15', '157.230.208.30'], + ['zeek-newyork-sha-aa8df15', 'fe80::24ce:f7ff:fede:a571'], + ]); + }); + + it('should generate 1 alert for unique combination of existing terms', async () => { + // ensure there are no alerts for single new terms fields, it means values are not new + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['host.name', 'host.ip'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + // shouldn't be terms for 'host.ip' + const hostIpPreview = await previewRule({ + supertest, + rule: { ...rule, new_terms_fields: ['host.ip'] }, + }); + const hostIpPreviewAlerts = await getPreviewAlerts({ + es, + previewId: hostIpPreview.previewId, + }); + expect(hostIpPreviewAlerts.length).eql(0); + + // shouldn't be terms for 'host.name' + const hostNamePreview = await previewRule({ + supertest, + rule: { ...rule, new_terms_fields: ['host.name'] }, + }); + const hostNamePreviewAlerts = await getPreviewAlerts({ + es, + previewId: hostNamePreview.previewId, + }); + expect(hostNamePreviewAlerts.length).eql(0); + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(1); + + expect(previewAlerts[0]._source?.['kibana.alert.new_terms']).eql(['host-0', '127.0.0.2']); + }); + + it('should generate 5 alerts, 1 for each new unique combination in 2 fields', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['source.ip', 'tags'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(5); + + const newTerms = orderBy( + previewAlerts.map((item) => item._source?.['kibana.alert.new_terms']), + ['0', '1'] + ); + + expect(newTerms).eql([ + ['192.168.1.1', 'tag-new-1'], + ['192.168.1.1', 'tag-new-3'], + ['192.168.1.2', 'tag-2'], + ['192.168.1.2', 'tag-new-1'], + ['192.168.1.2', 'tag-new-3'], + ]); + }); + + it('should generate 1 alert for unique combination of terms, one of which is a number', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['user.name', 'user.id'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(1); + expect(previewAlerts[0]._source?.['kibana.alert.new_terms']).eql(['user-0', '1']); + }); + + it('should generate 1 alert for unique combination of terms, one of which is a boolean', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['user.name', 'user.enabled'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(1); + expect(previewAlerts[0]._source?.['kibana.alert.new_terms']).eql(['user-0', 'false']); + }); + it('should generate alerts for every term when history window is small', async () => { const rule: NewTermsRuleCreateProps = { ...getCreateNewTermsRulesSchemaMock('rule-1', true), @@ -251,6 +382,92 @@ export default ({ getService }: FtrProviderContext) => { expect(hostNames[4]).eql(['zeek-sensor-san-francisco']); }); + describe('null values', () => { + it('should not generate alerts with null values for single field', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['possibly_null_field'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(0); + }); + + it('should not generate alerts with null values for multiple fields', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['possibly_null_field', 'host.name'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId }); + + expect(previewAlerts.length).eql(0); + }); + }); + + describe('large arrays values', () => { + it('should generate alerts for unique values in large array for single field from a single document', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['large_array_20'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, size: 100 }); + + expect(previewAlerts.length).eql(20); + }); + + // There is a limit in ES for a number of emitted values in runtime field (100) + // This test ensures rule run doesn't fail if processed fields in runtime script generates 100 values, hard limit for ES + // For this test case: large_array_10 & large_array_5 have 100 unique combination in total + it('should generate alerts for array fields that have 100 unique combination of values in runtime field', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['large_array_10', 'large_array_5'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, size: 200 }); + + expect(previewAlerts.length).eql(100); + }); + + // There is a limit in ES for a number of emitted values in runtime field (100) + // This test ensures rule run doesn't fail if processed fields in runtime script generates 200 values + // In case of this test case: large_array_10 & large_array_20 have 200 unique combination in total + // Rule run should not fail and should generate alerts + it('should generate alert for array fields that have more than 200 unique combination of values in runtime field', async () => { + const rule: NewTermsRuleCreateProps = { + ...getCreateNewTermsRulesSchemaMock('rule-1', true), + index: ['new_terms'], + new_terms_fields: ['large_array_10', 'large_array_20'], + from: '2020-10-19T05:00:04.000Z', + history_window_start: '2020-10-13T05:00:04.000Z', + }; + + const { previewId } = await previewRule({ supertest, rule }); + const previewAlerts = await getPreviewAlerts({ es, previewId, size: 200 }); + + expect(previewAlerts.length).eql(100); + }); + }); + describe('timestamp override and fallback', () => { before(async () => { await esArchiver.load( @@ -381,5 +598,144 @@ export default ({ getService }: FtrProviderContext) => { expect(previewAlerts[0]?._source?.host?.risk?.calculated_score_norm).to.eql(23); }); }); + + describe('runtime field', () => { + it('should return runtime field created from 2 single values', async () => { + // encoded base64 values of "host-0" and "127.0.0.1" joined with underscore + const expectedEncodedValues = ['aG9zdC0w_MTI3LjAuMC4x']; + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'first_doc' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['host.name', 'host.ip']), + }); + + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.eql(expectedEncodedValues); + }); + + it('should return runtime field created from 2 single values, including number value', async () => { + // encoded base64 values of "user-0" and 0 joined with underscore + const expectedEncodedValues = ['dXNlci0w_MA==']; + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'first_doc' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['user.name', 'user.id']), + }); + + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.eql(expectedEncodedValues); + }); + + it('should return runtime field created from 2 single values, including boolean value', async () => { + // encoded base64 values of "user-0" and true joined with underscore + const expectedEncodedValues = ['dXNlci0w_dHJ1ZQ==']; + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'first_doc' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['user.name', 'user.enabled']), + }); + + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.eql(expectedEncodedValues); + }); + + it('should return runtime field created from 3 single values', async () => { + // encoded base64 values of "host-0" and "127.0.0.1" and "user-0" joined with underscore + const expectedEncodedValues = ['aG9zdC0w_MTI3LjAuMC4x_dXNlci0w']; + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'first_doc' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['host.name', 'host.ip', 'user.name']), + }); + + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.eql(expectedEncodedValues); + }); + + it('should return runtime field created from fields of arrays', async () => { + // encoded base64 values of all combinations of ["192.168.1.1", "192.168.1.2"] + // and ["tag-new-1", "tag-2", "tag-new-3"] joined with underscore + const expectedEncodedValues = [ + 'MTkyLjE2OC4xLjE=_dGFnLTI=', + 'MTkyLjE2OC4xLjE=_dGFnLW5ldy0x', + 'MTkyLjE2OC4xLjE=_dGFnLW5ldy0z', + 'MTkyLjE2OC4xLjI=_dGFnLTI=', + 'MTkyLjE2OC4xLjI=_dGFnLW5ldy0x', + 'MTkyLjE2OC4xLjI=_dGFnLW5ldy0z', + ]; + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'doc_with_source_ip_as_array' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['source.ip', 'tags']), + }); + + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.eql(expectedEncodedValues); + }); + + it('should return runtime field without duplicated values', async () => { + // encoded base64 values of "host-0" and ["tag-1", "tag-2", "tag-2", "tag-1", "tag-1"] + // joined with underscore, without duplicates in tags + const expectedEncodedValues = ['aG9zdC0w_dGFnLTE=', 'aG9zdC0w_dGFnLTI=']; + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'doc_with_duplicated_tags' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['host.name', 'tags']), + }); + + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.eql(expectedEncodedValues); + }); + + it('should not return runtime field if one of fields is null', async () => { + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'doc_with_null_field' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME, 'possibly_null_field', 'host.name'], + runtimeMappings: getNewTermsRuntimeMappings(['host.name', 'possibly_null_field']), + }); + + expect(hits.hits.length).to.be(1); + expect(hits.hits[0].fields?.[AGG_FIELD_NAME]).to.be(undefined); + expect(hits.hits[0].fields?.possibly_null_field).to.be(undefined); + expect(hits.hits[0].fields?.['host.name']).to.eql(['host-0']); + }); + + it('should not return runtime field if one of fields is not defined', async () => { + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'doc_without_large_arrays' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['host.name', 'large_array_5']), + }); + + expect(hits.hits.length).to.be(1); + expect(hits.hits[0].fields).to.be(undefined); + }); + + // There is a limit in ES for a number of emitted values in runtime field (100) + // This test makes sure runtime script doesn't cause query failure and returns first 100 results + it('should return runtime field if number of emitted values greater than 100', async () => { + const { hits } = await performSearchQuery({ + es, + query: { match: { id: 'first_doc' } }, + index: 'new_terms', + fields: [AGG_FIELD_NAME], + runtimeMappings: getNewTermsRuntimeMappings(['large_array_20', 'large_array_10']), + }); + + // runtime field should have 100 values, as large_array_20 and large_array_10 + // give in total 200 combinations + expect(hits.hits[0].fields?.[AGG_FIELD_NAME].length).to.be(100); + }); + }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/utils/index.ts b/x-pack/test/detection_engine_api_integration/utils/index.ts index b686589addc09..7d03141f58f10 100644 --- a/x-pack/test/detection_engine_api_integration/utils/index.ts +++ b/x-pack/test/detection_engine_api_integration/utils/index.ts @@ -81,6 +81,7 @@ export * from './get_web_hook_action'; export * from './index_event_log_execution_events'; export * from './install_prepackaged_rules'; export * from './machine_learning_setup'; +export * from './perform_search_query'; export * from './preview_rule_with_exception_entries'; export * from './preview_rule'; export * from './refresh_index'; diff --git a/x-pack/test/detection_engine_api_integration/utils/perform_search_query.ts b/x-pack/test/detection_engine_api_integration/utils/perform_search_query.ts new file mode 100644 index 0000000000000..6afd1eebb501c --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/perform_search_query.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Client } from '@elastic/elasticsearch'; + +import type { + QueryDslQueryContainer, + MappingRuntimeFields, + IndexName, + Field, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +interface PerformSearchQueryArgs { + es: Client; + query: QueryDslQueryContainer; + index: IndexName; + size?: number; + runtimeMappings?: MappingRuntimeFields; + fields?: Field[]; +} + +/** + * run ES search query + */ +export const performSearchQuery = async ({ + es, + query, + index, + size = 10, + runtimeMappings, + fields, +}: PerformSearchQueryArgs) => { + return es.search({ + index, + size, + fields, + query, + runtime_mappings: runtimeMappings, + }); +}; diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/index.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/index.ts index e2f6901b75c31..7696e4f97eea8 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/index.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/index.ts @@ -8,7 +8,9 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, loadTestFile }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); + const config = getService('config'); + const isCcs = config.get('esTestCluster.ccs'); + const esNode = isCcs ? getService('remoteEsArchiver' as 'esArchiver') : getService('esArchiver'); const ml = getService('ml'); describe('machine learning - anomaly detection', function () { @@ -17,6 +19,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { before(async () => { await ml.securityCommon.createMlRoles(); await ml.securityCommon.createMlUsers(); + await ml.securityUI.loginAsMlPowerUser(); }); after(async () => { @@ -26,22 +29,25 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlUsers(); await ml.securityCommon.cleanMlRoles(); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); + await esNode.unload('x-pack/test/functional/es_archives/ml/farequote'); + await esNode.unload('x-pack/test/functional/es_archives/ml/ecommerce'); + await esNode.unload('x-pack/test/functional/es_archives/ml/categorization_small'); + await esNode.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); await ml.testResources.resetKibanaTimeZone(); }); loadTestFile(require.resolve('./single_metric_job')); - loadTestFile(require.resolve('./single_metric_job_without_datafeed_start')); - loadTestFile(require.resolve('./multi_metric_job')); - loadTestFile(require.resolve('./population_job')); - loadTestFile(require.resolve('./saved_search_job')); - loadTestFile(require.resolve('./advanced_job')); - loadTestFile(require.resolve('./categorization_job')); - loadTestFile(require.resolve('./date_nanos_job')); - loadTestFile(require.resolve('./custom_urls')); + + if (!isCcs) { + loadTestFile(require.resolve('./single_metric_job_without_datafeed_start')); + loadTestFile(require.resolve('./multi_metric_job')); + loadTestFile(require.resolve('./population_job')); + loadTestFile(require.resolve('./saved_search_job')); + loadTestFile(require.resolve('./advanced_job')); + loadTestFile(require.resolve('./categorization_job')); + loadTestFile(require.resolve('./date_nanos_job')); + loadTestFile(require.resolve('./custom_urls')); + } }); } diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts index cb21f8de77bd2..a4b702e7400b6 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_jobs/single_metric_job.ts @@ -8,7 +8,10 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); + const config = getService('config'); + const esNode = config.get('esTestCluster.ccs') + ? getService('remoteEsArchiver' as 'esArchiver') + : getService('esArchiver'); const ml = getService('ml'); const browser = getService('browser'); @@ -70,12 +73,17 @@ export default function ({ getService }: FtrProviderContext) { } const calendarId = `wizard-test-calendar_${Date.now()}`; + const remoteName = 'ftr-remote:'; + const indexPatternName = 'ft_farequote'; + const indexPatternString = config.get('esTestCluster.ccs') + ? remoteName + indexPatternName + : indexPatternName; describe('single metric', function () { this.tags(['ml']); before(async () => { - await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); - await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await esNode.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.createIndexPatternIfNeeded(indexPatternString, '@timestamp'); await ml.testResources.setKibanaTimeZoneToUTC(); await ml.api.createCalendar(calendarId); @@ -84,7 +92,7 @@ export default function ({ getService }: FtrProviderContext) { after(async () => { await ml.api.cleanMlIndices(); - await ml.testResources.deleteIndexPatternByTitle('ft_farequote'); + await ml.testResources.deleteIndexPatternByTitle(indexPatternString); }); it('job creation loads the single metric wizard for the source data', async () => { @@ -96,7 +104,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.jobManagement.navigateToNewJobSourceSelection(); await ml.testExecution.logTestStep('job creation loads the job type selection page'); - await ml.jobSourceSelection.selectSourceForAnomalyDetectionJob('ft_farequote'); + await ml.jobSourceSelection.selectSourceForAnomalyDetectionJob(indexPatternString); await ml.testExecution.logTestStep('job creation loads the single metric job wizard page'); await ml.jobTypeSelection.selectSingleMetricJob(); @@ -204,7 +212,7 @@ export default function ({ getService }: FtrProviderContext) { it('job cloning fails in the single metric wizard if a matching data view does not exist', async () => { await ml.testExecution.logTestStep('delete data view used by job'); - await ml.testResources.deleteIndexPatternByTitle('ft_farequote'); + await ml.testResources.deleteIndexPatternByTitle(indexPatternString); // Refresh page to ensure page has correct cache of data views await browser.refresh(); @@ -217,7 +225,7 @@ export default function ({ getService }: FtrProviderContext) { it('job cloning opens the existing job in the single metric wizard', async () => { await ml.testExecution.logTestStep('recreate data view used by job'); - await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createIndexPatternIfNeeded(indexPatternString, '@timestamp'); // Refresh page to ensure page has correct cache of data views await browser.refresh(); diff --git a/x-pack/test/functional/config.ccs.ts b/x-pack/test/functional/config.ccs.ts index 62f988d8f2f02..59681a8c6d9f3 100644 --- a/x-pack/test/functional/config.ccs.ts +++ b/x-pack/test/functional/config.ccs.ts @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/lens/group1'), require.resolve('./apps/remote_clusters/ccs/remote_clusters_index_management_flow'), require.resolve('./apps/rollup_job'), + require.resolve('./apps/ml/anomaly_detection_jobs'), ], junit: { diff --git a/x-pack/test/functional/es_archives/security_solution/new_terms/data.json b/x-pack/test/functional/es_archives/security_solution/new_terms/data.json new file mode 100644 index 0000000000000..6970a37472c3b --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/new_terms/data.json @@ -0,0 +1,220 @@ +{ + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:01.000Z", + "id": "first_doc", + "possibly_null_field": "test-value", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"], + "large_array_10": ["value-of-10-0","value-of-10-1","value-of-10-2","value-of-10-3","value-of-10-4","value-of-10-5","value-of-10-6","value-of-10-7","value-of-10-8","value-of-10-9"], + "large_array_5": ["value-of-5-0","value-of-5-1","value-of-5-2","value-of-5-3","value-of-5-4"], + "large_array_20": ["value-of-20-0","value-of-20-1","value-of-20-2","value-of-20-3","value-of-20-4","value-of-20-5","value-of-20-6","value-of-20-7","value-of-20-8","value-of-20-9","value-of-20-10","value-of-20-11","value-of-20-12","value-of-20-13","value-of-20-14","value-of-20-15","value-of-20-16","value-of-20-17","value-of-20-18","value-of-20-19"] + } + , + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:01.000Z", + "id": "doc_without_large_arrays", + "possibly_null_field": "test-value", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-1", + "user.id": 1, + "user.enabled": false, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"] + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:01.000Z", + "id": "doc_with_duplicated_tags", + "possibly_null_field": "test-value", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-1", + "user.id": 1, + "user.enabled": false, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2", "tag-2", "tag-1", "tag-1"] + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:02.000Z", + "possibly_null_field": "test-value", + "host": { + "name": "host-1", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": "192.168.1.1" + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:02.000Z", + "possibly_null_field": "test-value", + "host": { + "name": "host-1" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"] + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:03.000Z", + "possibly_null_field": "test-value", + "host": { + "name": "host-1", + "ip": "127.0.0.2" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"] + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-14T05:00:04.000Z", + "possibly_null_field": "test-value", + "host": { + "name": "host-0", + "ip": "127.0.0.1" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"] + }, + "type": "_doc" + } + } + + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-20T05:00:04.000Z", + "possibly_null_field": "test-value", + "host": { + "name": "host-0", + "ip": "127.0.0.2" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"] + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-20T05:00:04.000Z", + "id": "doc_with_source_ip_as_array", + "possibly_null_field": "test-value", + "host": { + "name": "host-0", + "ip": "127.0.0.2" + }, + "user.name": "user-0", + "user.id": 0, + "user.enabled": true, + "source.ip": ["192.168.1.1", "192.168.1.2"], + "tags": ["tag-new-1", "tag-2", "tag-new-3"] + }, + "type": "_doc" + } + } + + { + "type": "doc", + "value": { + "index": "new_terms", + "source": { + "@timestamp": "2020-10-20T05:00:04.000Z", + "id": "doc_with_null_field", + "possibly_null_field": null, + "host": { + "name": "host-0", + "ip": "127.0.0.2" + }, + "user.name": "user-0", + "user.id": 1, + "user.enabled": false, + "source.ip": "192.168.1.1", + "tags": ["tag-1", "tag-2"], + "large_array_10": ["a-new-value-of-10-0","a-new-value-of-10-1","a-new-value-of-10-2","a-new-value-of-10-3","a-new-value-of-10-4","a-new-value-of-10-5","a-new-value-of-10-6","a-new-value-of-10-7","a-new-value-of-10-8","a-new-value-of-10-9"], + "large_array_5": ["another-new-value-of-10-0","another-new-value-of-10-1","another-new-value-of-10-2","another-new-value-of-10-3","another-new-value-of-10-4","another-new-value-of-10-5","another-new-value-of-10-6","another-new-value-of-10-7","another-new-value-of-10-8","another-new-value-of-10-9"], + "large_array_20": ["a-new-value-of-20-0","a-new-value-of-20-1","a-new-value-of-20-2","a-new-value-of-20-3","a-new-value-of-20-4","a-new-value-of-20-5","a-new-value-of-20-6","a-new-value-of-20-7","a-new-value-of-20-8","a-new-value-of-20-9","a-new-value-of-20-10","a-new-value-of-20-11","a-new-value-of-20-12","a-new-value-of-20-13","a-new-value-of-20-14","a-new-value-of-20-15","a-new-value-of-20-16","a-new-value-of-20-17","a-new-value-of-20-18","a-new-value-of-20-19"] + }, + "type": "_doc" + } + } \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/security_solution/new_terms/mappings.json b/x-pack/test/functional/es_archives/security_solution/new_terms/mappings.json new file mode 100644 index 0000000000000..2f156ddedf580 --- /dev/null +++ b/x-pack/test/functional/es_archives/security_solution/new_terms/mappings.json @@ -0,0 +1,70 @@ +{ + "type": "index", + "value": { + "index": "new_terms", + "mappings": { + "properties": { + "id": { + "type": "keyword" + }, + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "keyword" + }, + "ip": { + "type": "ip" + } + } + }, + "user": { + "properties": { + "name": { + "type": "keyword" + }, + "id": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + } + }, + "source": { + "properties": { + "ip": { + "type": "ip" + } + } + }, + "tags": { + "type": "keyword" + }, + "blob": { + "type": "binary" + }, + "possibly_null_field": { + "type": "keyword" + }, + "large_array_10": { + "type": "keyword" + }, + "large_array_20": { + "type": "keyword" + }, + "large_array_5": { + "type": "keyword" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } + } \ No newline at end of file diff --git a/x-pack/test/functional/services/ml/security_common.ts b/x-pack/test/functional/services/ml/security_common.ts index ae8f973228b39..6952183e7bdef 100644 --- a/x-pack/test/functional/services/ml/security_common.ts +++ b/x-pack/test/functional/services/ml/security_common.ts @@ -24,7 +24,9 @@ export enum USER { } export function MachineLearningSecurityCommonProvider({ getService }: FtrProviderContext) { + const config = getService('config'); const security = getService('security'); + const remoteEsRoles: undefined | Record = config.get('security.remoteEsRoles'); const roles = [ { @@ -172,6 +174,7 @@ export function MachineLearningSecurityCommonProvider({ getService }: FtrProvide 'ft_ml_source', 'ft_ml_dest', 'ft_ml_ui_extras', + ...(remoteEsRoles ? Object.keys(remoteEsRoles) : []), ], }, {