diff --git a/src/plugins/home/public/assets/googlecloud_metrics/screenshot.png b/src/plugins/home/public/assets/googlecloud_metrics/screenshot.png new file mode 100644 index 00000000000000..d4d90d27ad3025 Binary files /dev/null and b/src/plugins/home/public/assets/googlecloud_metrics/screenshot.png differ diff --git a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts index 33252401476404..210d563696667b 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts @@ -82,7 +82,7 @@ export interface TutorialSchema { name: string; isBeta?: boolean; shortDescription: string; - euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/icon; + euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/display/icons; longDescription: string; completionTimeMinutes?: number; previewImagePath?: string; diff --git a/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts new file mode 100644 index 00000000000000..504ede04c12d8e --- /dev/null +++ b/src/plugins/home/server/tutorials/googlecloud_metrics/index.ts @@ -0,0 +1,77 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { TutorialsCategory } from '../../services/tutorials'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../instructions/metricbeat_instructions'; +import { + TutorialContext, + TutorialSchema, +} from '../../services/tutorials/lib/tutorials_registry_types'; + +export function googlecloudMetricsSpecProvider(context: TutorialContext): TutorialSchema { + const moduleName = 'googlecloud'; + return { + id: 'googlecloudMetrics', + name: i18n.translate('home.tutorials.googlecloudMetrics.nameTitle', { + defaultMessage: 'Google Cloud metrics', + }), + category: TutorialsCategory.METRICS, + shortDescription: i18n.translate('home.tutorials.googlecloudMetrics.shortDescription', { + defaultMessage: + 'Fetch monitoring metrics from Google Cloud Platform using Stackdriver Monitoring API.', + }), + longDescription: i18n.translate('home.tutorials.googlecloudMetrics.longDescription', { + defaultMessage: + 'The `googlecloud` Metricbeat module fetches monitoring metrics from Google Cloud Platform using Stackdriver Monitoring API. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-googlecloud.html', + }, + }), + euiIconType: 'logoGCP', + isBeta: false, + artifacts: { + dashboards: [ + { + id: 'f40ee870-5e4a-11ea-a4f6-717338406083', + linkLabel: i18n.translate( + 'home.tutorials.googlecloudMetrics.artifacts.dashboards.linkLabel', + { + defaultMessage: 'Google Cloud metrics dashboard', + } + ), + isOverview: true, + }, + ], + exportedFields: { + documentationUrl: '{config.docs.beats.metricbeat}/exported-fields-googlecloud.html', + }, + }, + completionTimeMinutes: 10, + previewImagePath: '/plugins/home/assets/googlecloud_metrics/screenshot.png', + onPrem: onPremInstructions(moduleName, context), + elasticCloud: cloudInstructions(moduleName), + onPremElasticCloud: onPremCloudInstructions(moduleName), + }; +} diff --git a/src/plugins/home/server/tutorials/register.ts b/src/plugins/home/server/tutorials/register.ts index d13cce1c22784e..c48423edb2a07d 100644 --- a/src/plugins/home/server/tutorials/register.ts +++ b/src/plugins/home/server/tutorials/register.ts @@ -91,6 +91,7 @@ import { openmetricsMetricsSpecProvider } from './openmetrics_metrics'; import { oracleMetricsSpecProvider } from './oracle_metrics'; import { iisMetricsSpecProvider } from './iis_metrics'; import { azureLogsSpecProvider } from './azure_logs'; +import { googlecloudMetricsSpecProvider } from './googlecloud_metrics'; export const builtInTutorials = [ systemLogsSpecProvider, @@ -168,4 +169,5 @@ export const builtInTutorials = [ oracleMetricsSpecProvider, iisMetricsSpecProvider, azureLogsSpecProvider, + googlecloudMetricsSpecProvider, ]; diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 1aade472c23262..6ef4f19c1570f6 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -2,7 +2,7 @@ This plugin registers the basic usage collectors from Kibana: -- Application Usage +- [Application Usage](./server/collectors/application_usage/README.md) - UI Metrics - Ops stats - Number of Saved Objects per type diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md new file mode 100644 index 00000000000000..1ffd01fc6fde7a --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md @@ -0,0 +1,37 @@ +# Application Usage + +This collector reports the number of general clicks and minutes on screen for each registered application in Kibana. + +The final payload matches the following contract: + +```JSON +{ + "application_usage": { + "application_ID": { + "clicks_7_days": 10, + "clicks_30_days": 100, + "clicks_90_days": 300, + "clicks_total": 600, + "minutes_on_screen_7_days": 10.40, + "minutes_on_screen_30_days": 20.0, + "minutes_on_screen_90_days": 110.1, + "minutes_on_screen_total": 112.5 + } + } +} +``` + +Where `application_ID` matches the `id` registered when calling the method `core.application.register`. +This collection occurs by default for every application registered via the mentioned method and there is no need to do anything else to enable it or _opt-in_ for your plugin. + +**Note to maintainers in the Kibana repo:** At the moment of writing, the `usageCollector.schema` is not updated automatically ([#70622](https://github.com/elastic/kibana/issues/70622)) so, if you are adding a new app to Kibana, you'll need to give the Kibana Telemetry team a heads up to update the mappings in the Telemetry Cluster accordingly. + +## Developer notes + +In order to keep the count of the events, this collector uses 2 Saved Objects: + +1. `application_usage_transactional`: It stores each individually reported event (up to 90 days old). Grouped by `timestamp` and `appId`. +2. `application_usage_totals`: It stores the sum of all the events older than 90 days old per `appId`. + +Both of them use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`. `application_usage_transactional` also stores `timestamp: { type: 'date' }`. +but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 8d6a2d110efe0f..551c6e230972e7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -35,13 +35,10 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe hidden: false, namespaceType: 'agnostic', mappings: { + // Not indexing any of its contents because we use them "as-is" and don't search by these fields + // for more info, see the README.md for application_usage dynamic: false, - properties: { - // Disabled the mapping of these fields since they are not searched and we need to reduce the amount of indexed fields (#43673) - // appId: { type: 'keyword' }, - // numberOfClicks: { type: 'long' }, - // minutesOnScreen: { type: 'float' }, - }, + properties: {}, }, }); @@ -53,10 +50,6 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe dynamic: false, properties: { timestamp: { type: 'date' }, - // Disabled the mapping of these fields since they are not searched and we need to reduce the amount of indexed fields (#43673) - // appId: { type: 'keyword' }, - // numberOfClicks: { type: 'long' }, - // minutesOnScreen: { type: 'float' }, }, }, }); diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx index 43d5d5f939ce0b..701016d6bf0af8 100644 --- a/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_templates/workpad_templates.tsx @@ -24,7 +24,7 @@ import { EuiBasicTableColumn } from '@elastic/eui'; import { Paginate, PaginateChildProps } from '../paginate'; import { TagList } from '../tag_list'; import { getTagsFilter } from '../../lib/get_tags_filter'; -// @ts-ignore untyped local +// @ts-expect-error import { extractSearch } from '../../lib/extract_search'; import { ComponentStrings } from '../../../i18n'; import { CanvasTemplate } from '../../../types'; @@ -61,7 +61,7 @@ export class WorkpadTemplates extends React.PureComponent< WorkpadTemplatesState > { static propTypes = { - createFromTemplate: PropTypes.func.isRequired, + onCreateFromTemplate: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, templates: PropTypes.object, }; diff --git a/x-pack/plugins/canvas/server/templates/index.ts b/x-pack/plugins/canvas/server/templates/index.ts index bd0abed1912c32..c2723fbc87e17a 100644 --- a/x-pack/plugins/canvas/server/templates/index.ts +++ b/x-pack/plugins/canvas/server/templates/index.ts @@ -13,20 +13,21 @@ import { light } from './theme_light'; import { TEMPLATE_TYPE } from '../../common/lib/constants'; -export const templates = [pitch, status, summary, dark, light]; +export const templates = [status, summary, dark, light, pitch]; export async function initializeTemplates( - client: Pick + client: Pick ) { const existingTemplates = await client.find({ type: TEMPLATE_TYPE, perPage: 1 }); if (existingTemplates.total === 0) { - const templateObjects = templates.map((template) => ({ - id: template.id, - type: TEMPLATE_TYPE, - attributes: template, - })); - - client.bulkCreate(templateObjects); + // Some devs were seeing timeouts that would cause an unhandled promise rejection + // likely because the pitch template is so huge. + // So, rather than doing a bulk create of templates, we're going to fire off individual + // creates and catch and throw-away any errors that happen. + // Once packages are ready, we should probably move that pitch that is so large to a package + for (const template of templates) { + client.create(TEMPLATE_TYPE, template, { id: template.id }).catch((err) => undefined); + } } } diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts new file mode 100644 index 00000000000000..65dcb2e43c6f7f --- /dev/null +++ b/x-pack/plugins/infra/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_SOURCE_ID = 'default'; diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 46a0edf75b7566..65ea53a8465bbf 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -4,90 +4,220 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfraClientCoreSetup } from '../types'; -import { LogsFetchDataResponse } from '../../../observability/public'; +import { encode } from 'rison-node'; +import { i18n } from '@kbn/i18n'; +import { DEFAULT_SOURCE_ID } from '../../common/constants'; +import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; +import { + FetchData, + LogsFetchDataResponse, + HasData, + FetchDataParams, +} from '../../../observability/public'; +import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; +import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; -export function getLogsHasDataFetcher(getStartServices: InfraClientCoreSetup['getStartServices']) { - return async () => { - // if you need the data plugin, this is how you get it - // const [, startPlugins] = await getStartServices(); - // const { data } = startPlugins; +interface StatsAggregation { + buckets: Array<{ key: string; doc_count: number }>; +} + +interface SeriesAggregation { + buckets: Array<{ + key_as_string: string; + key: number; + doc_count: number; + dataset: StatsAggregation; + }>; +} + +interface LogParams { + index: string; + timestampField: string; +} - // if you need a core dep, we need to pass in more than just getStartServices +type StatsAndSeries = Pick; - // perform query - return true; +export function getLogsHasDataFetcher( + getStartServices: InfraClientCoreSetup['getStartServices'] +): HasData { + return async () => { + const [core] = await getStartServices(); + const sourceStatus = await callFetchLogSourceStatusAPI(DEFAULT_SOURCE_ID, core.http.fetch); + return sourceStatus.data.logIndicesExist; }; } export function getLogsOverviewDataFetcher( getStartServices: InfraClientCoreSetup['getStartServices'] -) { - return async (): Promise => { - // if you need the data plugin, this is how you get it - // const [, startPlugins] = await getStartServices(); - // const { data } = startPlugins; +): FetchData { + return async (params) => { + const [core, startPlugins] = await getStartServices(); + const { data } = startPlugins; + + const sourceConfiguration = await callFetchLogSourceConfigurationAPI( + DEFAULT_SOURCE_ID, + core.http.fetch + ); + + const { stats, series } = await fetchLogsOverview( + { + index: sourceConfiguration.data.configuration.logAlias, + timestampField: sourceConfiguration.data.configuration.fields.timestamp, + }, + params, + data + ); - // if you need a core dep, we need to pass in more than just getStartServices + const timeSpanInMinutes = + (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60); - // perform query return { - title: 'Log rate', - appLink: 'TBD', // TODO: what format should this be in, relative I assume? - stats: { - nginx: { - type: 'number', - label: 'nginx', - value: 345341, - }, - 'elasticsearch.audit': { - type: 'number', - label: 'elasticsearch.audit', - value: 164929, + title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', { + defaultMessage: 'Logs', + }), + appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode( + params.startTime + )})`, + stats: normalizeStats(stats, timeSpanInMinutes), + series: normalizeSeries(series), + }; + }; +} + +async function fetchLogsOverview( + logParams: LogParams, + params: FetchDataParams, + dataPlugin: InfraClientStartDeps['data'] +): Promise { + const esSearcher = dataPlugin.search.getSearchStrategy('es'); + return new Promise((resolve, reject) => { + esSearcher + .search({ + params: { + index: logParams.index, + body: { + size: 0, + query: buildLogOverviewQuery(logParams, params), + aggs: buildLogOverviewAggregations(logParams, params), + }, }, - 'haproxy.log': { - type: 'number', - label: 'haproxy.log', - value: 51101, + }) + .subscribe( + (response) => { + if (response.rawResponse.aggregations) { + resolve(processLogsOverviewAggregations(response.rawResponse.aggregations)); + } else { + resolve({ stats: {}, series: {} }); + } }, + (error) => reject(error) + ); + }); +} + +function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) { + return { + range: { + [logParams.timestampField]: { + gt: params.startTime, + lte: params.endTime, + format: 'strict_date_optional_time', }, - // Note: My understanding is that these series coordinates will be - // combined into objects that look like: - // { x: timestamp, y: value, g: label (e.g. nginx) } - // so they fit the stacked bar chart API - // https://elastic.github.io/elastic-charts/?path=/story/bar-chart--stacked-with-axis-and-legend - series: { - nginx: { - label: 'nginx', - coordinates: [ - { x: 1593000000000, y: 10014 }, - { x: 1593000900000, y: 12827 }, - { x: 1593001800000, y: 2946 }, - { x: 1593002700000, y: 14298 }, - { x: 1593003600000, y: 4096 }, - ], - }, - 'elasticsearch.audit': { - label: 'elasticsearch.audit', - coordinates: [ - { x: 1593000000000, y: 5676 }, - { x: 1593000900000, y: 6783 }, - { x: 1593001800000, y: 2394 }, - { x: 1593002700000, y: 4554 }, - { x: 1593003600000, y: 5659 }, - ], - }, - 'haproxy.log': { - label: 'haproxy.log', - coordinates: [ - { x: 1593000000000, y: 9085 }, - { x: 1593000900000, y: 9002 }, - { x: 1593001800000, y: 3940 }, - { x: 1593002700000, y: 5451 }, - { x: 1593003600000, y: 9133 }, - ], + }, + }; +} + +function buildLogOverviewAggregations(logParams: LogParams, params: FetchDataParams) { + return { + stats: { + terms: { + field: 'event.dataset', + size: 4, + }, + }, + series: { + date_histogram: { + field: logParams.timestampField, + fixed_interval: params.bucketSize, + }, + aggs: { + dataset: { + terms: { + field: 'event.dataset', + size: 4, + }, }, }, - }; + }, + }; +} + +function processLogsOverviewAggregations(aggregations: { + stats: StatsAggregation; + series: SeriesAggregation; +}): StatsAndSeries { + const processedStats = aggregations.stats.buckets.reduce( + (result, bucket) => { + result[bucket.key] = { + type: 'number', + label: bucket.key, + value: bucket.doc_count, + }; + + return result; + }, + {} + ); + + const processedSeries = aggregations.series.buckets.reduce( + (result, bucket) => { + const x = bucket.key; // the timestamp of the bucket + bucket.dataset.buckets.forEach((b) => { + const label = b.key; + result[label] = result[label] || { label, coordinates: [] }; + result[label].coordinates.push({ x, y: b.doc_count }); + }); + + return result; + }, + {} + ); + + return { + stats: processedStats, + series: processedSeries, }; } + +function normalizeStats( + stats: LogsFetchDataResponse['stats'], + timeSpanInMinutes: number +): LogsFetchDataResponse['stats'] { + return Object.keys(stats).reduce((normalized, key) => { + normalized[key] = { + ...stats[key], + value: stats[key].value / timeSpanInMinutes, + }; + return normalized; + }, {}); +} + +function normalizeSeries(series: LogsFetchDataResponse['series']): LogsFetchDataResponse['series'] { + const seriesKeys = Object.keys(series); + const timestamps = seriesKeys.flatMap((key) => series[key].coordinates.map((c) => c.x)); + const [first, second] = [...new Set(timestamps)].sort(); + const timeSpanInMinutes = (second - first) / (1000 * 60); + + return seriesKeys.reduce((normalized, key) => { + normalized[key] = { + ...series[key], + coordinates: series[key].coordinates.map((c) => { + if (c.y) { + return { ...c, y: c.y / timeSpanInMinutes }; + } + return c; + }), + }; + return normalized; + }, {}); +} diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts new file mode 100644 index 00000000000000..6f9e41fbd08f3a --- /dev/null +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetches.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { coreMock } from 'src/core/public/mocks'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { getLogsHasDataFetcher } from './logs_overview_fetchers'; +import { InfraClientStartDeps, InfraClientStartExports } from '../types'; +import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; + +// Note +// Calls to `.mock*` functions will fail the typecheck because how jest does the mocking. +// The calls will be preluded with a `@ts-expect-error` +jest.mock('../containers/logs/log_source/api/fetch_log_source_status'); + +function setup() { + const core = coreMock.createStart(); + const data = dataPluginMock.createStartContract(); + + const mockedGetStartServices = jest.fn(() => { + const deps = { data }; + return Promise.resolve([ + core as CoreStart, + deps as InfraClientStartDeps, + void 0 as InfraClientStartExports, + ]) as Promise<[CoreStart, InfraClientStartDeps, InfraClientStartExports]>; + }); + return { core, mockedGetStartServices }; +} + +describe('Logs UI Observability Homepage Functions', () => { + describe('getLogsHasDataFetcher()', () => { + beforeEach(() => { + // @ts-expect-error + callFetchLogSourceStatusAPI.mockReset(); + }); + it('should return true when some index is present', async () => { + const { mockedGetStartServices } = setup(); + + // @ts-expect-error + callFetchLogSourceStatusAPI.mockResolvedValue({ + data: { logIndexFields: [], logIndicesExist: true }, + }); + + const hasData = getLogsHasDataFetcher(mockedGetStartServices); + const response = await hasData(); + + expect(callFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1); + expect(response).toBe(true); + }); + + it('should return false when no index is present', async () => { + const { mockedGetStartServices } = setup(); + + // @ts-expect-error + callFetchLogSourceStatusAPI.mockResolvedValue({ + data: { logIndexFields: [], logIndicesExist: false }, + }); + + const hasData = getLogsHasDataFetcher(mockedGetStartServices); + const response = await hasData(); + + expect(callFetchLogSourceStatusAPI).toHaveBeenCalledTimes(1); + expect(response).toBe(false); + }); + }); + + describe('getLogsOverviewDataFetcher()', () => { + it.skip('should work', async () => { + // Pending + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index 9617173bd0c7b1..c374cbb3bb1460 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -3520,7 +3520,17 @@ ] } }, - "/fleet/agents/unenroll": { + "/fleet/agents/{agentId}/unenroll": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], "post": { "summary": "Fleet - Agent - Unenroll", "tags": [], @@ -3530,7 +3540,26 @@ { "$ref": "#/components/parameters/xsrfHeader" } - ] + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { "type": "boolean" } + } + }, + "examples": { + "example-1": { + "value": { + "force": true + } + } + } + } + } + } } }, "/fleet/config/{configId}/agent-status": { @@ -4096,6 +4125,12 @@ "enrolled_at": { "type": "string" }, + "unenrolled_at": { + "type": "string" + }, + "unenrollment_started_at": { + "type": "string" + }, "shared_id": { "type": "string" }, diff --git a/x-pack/plugins/ingest_manager/common/services/agent_status.ts b/x-pack/plugins/ingest_manager/common/services/agent_status.ts index cc1c2da710516f..b1d92d3a78e655 100644 --- a/x-pack/plugins/ingest_manager/common/services/agent_status.ts +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -21,6 +21,9 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (!agent.active) { return 'inactive'; } + if (agent.unenrollment_started_at && !agent.unenrolled_at) { + return 'unenrolling'; + } if (agent.current_error_events.length > 0) { return 'error'; } diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index d2a2a3f5705ae3..27f0c61685fd4e 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -11,10 +11,10 @@ export type AgentType = | typeof AGENT_TYPE_PERMANENT | typeof AGENT_TYPE_TEMPORARY; -export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; - +export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning' | 'unenrolling'; +export type AgentActionType = 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE' | 'UNENROLL'; export interface NewAgentAction { - type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; + type: AgentActionType; data?: any; sent_at?: string; } @@ -26,7 +26,7 @@ export interface AgentAction extends NewAgentAction { } export interface AgentActionSOAttributes { - type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; + type: AgentActionType; sent_at?: string; timestamp?: string; created_at: string; @@ -73,6 +73,8 @@ interface AgentBase { type: AgentType; active: boolean; enrolled_at: string; + unenrolled_at?: string; + unenrollment_started_at?: string; shared_id?: string; access_api_key_id?: string; default_api_key?: string; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 75d05567551491..6d04f63702c641 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -236,7 +236,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'active', - width: '100px', + width: '120px', name: i18n.translate('xpack.ingestManager.agentList.statusColumnTitle', { defaultMessage: 'Status', }), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx index 181ebe35042227..e4dfa520259eb3 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx @@ -53,6 +53,14 @@ const Status = { /> ), + Unenrolling: ( + + + + ), }; function getStatusComponent(agent: Agent): React.ReactElement { @@ -65,6 +73,8 @@ function getStatusComponent(agent: Agent): React.ReactElement { return Status.Offline; case 'warning': return Status.Warning; + case 'unenrolling': + return Status.Unenrolling; default: return Status.Online; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx index fec2253c0dd562..90d8ff545341de 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx @@ -74,7 +74,7 @@ export const AgentUnenrollProvider: React.FunctionComponent = ({ children const successMessage = i18n.translate( 'xpack.ingestManager.unenrollAgents.successSingleNotificationTitle', { - defaultMessage: "Unenrolled agent '{id}'", + defaultMessage: "Unenrolling agent '{id}'", values: { id: agentId }, } ); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index d31498599a2b65..d9a9572237126e 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -13,7 +13,6 @@ import { GetOneAgentEventsResponse, PostAgentCheckinResponse, PostAgentEnrollResponse, - PostAgentUnenrollResponse, GetAgentStatusResponse, PutAgentReassignResponse, } from '../../../common/types'; @@ -25,7 +24,6 @@ import { GetOneAgentEventsRequestSchema, PostAgentCheckinRequestSchema, PostAgentEnrollRequestSchema, - PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, PutAgentReassignRequestSchema, } from '../../types'; @@ -302,25 +300,6 @@ export const getAgentsHandler: RequestHandler< } }; -export const postAgentsUnenrollHandler: RequestHandler> = async (context, request, response) => { - const soClient = context.core.savedObjects.client; - try { - await AgentService.unenrollAgent(soClient, request.params.agentId); - - const body: PostAgentUnenrollResponse = { - success: true, - }; - return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); - } -}; - export const putAgentsReassignHandler: RequestHandler< TypeOf, undefined, diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index eaab46c7b455c3..d7eec50eac3cfb 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -33,7 +33,6 @@ import { getAgentEventsHandler, postAgentCheckinHandler, postAgentEnrollHandler, - postAgentsUnenrollHandler, getAgentStatusForConfigHandler, putAgentsReassignHandler, } from './handlers'; @@ -41,6 +40,7 @@ import { postAgentAcksHandlerBuilder } from './acks_handlers'; import * as AgentService from '../../services/agents'; import { postNewAgentActionHandlerBuilder } from './actions_handlers'; import { appContextService } from '../../services'; +import { postAgentsUnenrollHandler } from './unenroll_handler'; export const registerRoutes = (router: IRouter) => { // Get one diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts new file mode 100644 index 00000000000000..d1e54fe4fb3a17 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { TypeOf } from '@kbn/config-schema'; +import { PostAgentUnenrollResponse } from '../../../common/types'; +import { PostAgentUnenrollRequestSchema } from '../../types'; +import * as AgentService from '../../services/agents'; + +export const postAgentsUnenrollHandler: RequestHandler< + TypeOf, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + if (request.body?.force === true) { + await AgentService.forceUnenrollAgent(soClient, request.params.agentId); + } else { + await AgentService.unenrollAgent(soClient, request.params.agentId); + } + + const body: PostAgentUnenrollResponse = { + success: true, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 9819a4fa5d7507..b47cf4f7e7c3ba 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -54,6 +54,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { type: { type: 'keyword' }, active: { type: 'boolean' }, enrolled_at: { type: 'date' }, + unenrolled_at: { type: 'date' }, + unenrollment_started_at: { type: 'date' }, access_api_key_id: { type: 'keyword' }, version: { type: 'keyword' }, user_provided_metadata: { type: 'flattened' }, @@ -313,6 +315,9 @@ export function registerEncryptedSavedObjects( 'config_newest_revision', 'updated_at', 'current_error_events', + 'unenrolled_at', + 'unenrollment_started_at', + 'packages', ]), }); encryptedSavedObjects.registerType({ diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts index c59bac6a5469ac..1dfe4e067dafe8 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts @@ -26,6 +26,7 @@ import { AGENT_ACTION_SAVED_OBJECT_TYPE, } from '../../constants'; import { getAgentActionByIds } from './actions'; +import { forceUnenrollAgent } from './unenroll'; const ALLOWED_ACKNOWLEDGEMENT_TYPE: string[] = ['ACTION_RESULT']; @@ -63,6 +64,12 @@ export async function acknowledgeAgentActions( if (actions.length === 0) { return []; } + + const isAgentUnenrolled = actions.some((action) => action.type === 'UNENROLL'); + if (isAgentUnenrolled) { + await forceUnenrollAgent(soClient, agent.id); + } + const config = getLatestConfigIfUpdated(agent, actions); await soClient.bulkUpdate([ diff --git a/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts index ee7e08d7410358..e0ac2620cafd39 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts @@ -9,8 +9,21 @@ import { AgentSOAttributes } from '../../types'; import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; import { getAgent } from './crud'; import * as APIKeyService from '../api_keys'; +import { createAgentAction } from './actions'; export async function unenrollAgent(soClient: SavedObjectsClientContract, agentId: string) { + const now = new Date().toISOString(); + await createAgentAction(soClient, { + agent_id: agentId, + created_at: now, + type: 'UNENROLL', + }); + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + unenrollment_started_at: now, + }); +} + +export async function forceUnenrollAgent(soClient: SavedObjectsClientContract, agentId: string) { const agent = await getAgent(soClient, agentId); await Promise.all([ @@ -21,7 +34,9 @@ export async function unenrollAgent(soClient: SavedObjectsClientContract, agentI ? APIKeyService.invalidateAPIKey(soClient, agent.default_api_key_id) : undefined, ]); + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { active: false, + unenrolled_at: new Date().toISOString(), }); } diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 5526e889124f9c..a508c33e0347bd 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -70,6 +70,11 @@ export const PostAgentUnenrollRequestSchema = { params: schema.object({ agentId: schema.string(), }), + body: schema.nullable( + schema.object({ + force: schema.boolean(), + }) + ), }; export const PutAgentReassignRequestSchema = { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 60c31e5d090e5a..f21939b3a28954 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -68,29 +68,33 @@ export function WorkspacePanelWrapper({ return ( - + + + + + {activeVisualization && activeVisualization.renderToolbar && ( + + + + )} + - {activeVisualization && activeVisualization.renderToolbar && ( - - - - )} {(!emptyExpression || title) && ( diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index c037aecde558b9..d7d76bdd1f44aa 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -5,6 +5,9 @@ Object { "chain": Array [ Object { "arguments": Object { + "fittingFunction": Array [ + "Carry", + ], "layers": Array [ Object { "chain": Array [ diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 8cb30037379da9..c7c173f87ad7cb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -36,6 +36,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` title="a" /> { + if (layer.splitAccessor) { + return null; + } + return ( + layer?.yConfig?.find((yConfig: YConfig) => yConfig.forAccessor === accessor)?.color || null + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index e9e0cfed909fbf..31b34e41e82db0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -40,6 +40,7 @@ describe('#toExpression', () => { { legend: { position: Position.Bottom, isVisible: true }, preferredSeriesType: 'bar', + fittingFunction: 'Carry', layers: [ { layerId: 'first', @@ -55,6 +56,27 @@ describe('#toExpression', () => { ).toMatchSnapshot(); }); + it('should default the fitting function to None', () => { + expect( + (xyVisualization.toExpression( + { + legend: { position: Position.Bottom, isVisible: true }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], + }, + frame + ) as Ast).chain[0].arguments.fittingFunction[0] + ).toEqual('None'); + }); + it('should not generate an expression when missing x', () => { expect( xyVisualization.toExpression( diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 6ec22270d8b183..3b9406cedd4995 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -133,6 +133,7 @@ export const buildExpression = ( ], }, ], + fittingFunction: [state.fittingFunction || 'None'], layers: validLayers.map((layer) => { const columnToLabel: Record = {}; @@ -188,7 +189,8 @@ export const buildExpression = ( function: 'lens_xy_yConfig', arguments: { forAccessor: [yConfig.forAccessor], - axisMode: [yConfig.axisMode], + axisMode: yConfig.axisMode ? [yConfig.axisMode] : [], + color: yConfig.color ? [yConfig.color] : [], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index e62c5f60a58e16..08f29c65b26d0a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -16,6 +16,7 @@ import chartBarHorizontalStackedSVG from '../assets/chart_bar_horizontal_stacked import chartLineSVG from '../assets/chart_line.svg'; import { VisualizationType } from '../index'; +import { FittingFunction } from './fitting_functions'; export interface LegendConfig { isVisible: boolean; @@ -100,6 +101,10 @@ export const yAxisConfig: ExpressionFunctionDefinition< options: ['auto', 'left', 'right'], help: 'The axis mode of the metric', }, + color: { + types: ['string'], + help: 'The color of the series', + }, }, fn: function fn(input: unknown, args: YConfig) { return { @@ -195,6 +200,7 @@ export type YAxisMode = 'auto' | 'left' | 'right'; export interface YConfig { forAccessor: string; axisMode?: YAxisMode; + color?: string; } export interface LayerConfig { @@ -220,12 +226,14 @@ export interface XYArgs { yTitle: string; legend: LegendConfig & { type: 'lens_xy_legendConfig' }; layers: LayerArgs[]; + fittingFunction?: FittingFunction; } // Persisted parts of the state export interface XYState { preferredSeriesType: SeriesType; legend: LegendConfig; + fittingFunction?: FittingFunction; layers: LayerConfig[]; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss new file mode 100644 index 00000000000000..c353f3f370ee56 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.scss @@ -0,0 +1,3 @@ +.lnsXyToolbar__popover { + width: 400px; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 7544ed0f87b7d0..981ce1cca59589 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -5,15 +5,15 @@ */ import React from 'react'; -import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; -import { EuiButtonGroupProps } from '@elastic/eui'; -import { LayerContextMenu } from './xy_config_panel'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { EuiButtonGroupProps, EuiSuperSelect } from '@elastic/eui'; +import { LayerContextMenu, XyToolbar } from './xy_config_panel'; import { FramePublicAPI } from '../types'; import { State } from './types'; import { Position } from '@elastic/charts'; import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_service/mocks'; -describe('LayerContextMenu', () => { +describe('XY Config panels', () => { let frame: FramePublicAPI; function testState(): State { @@ -39,11 +39,6 @@ describe('LayerContextMenu', () => { }; }); - test.skip('allows toggling of legend visibility', () => {}); - test.skip('allows changing legend position', () => {}); - test.skip('allows toggling the y axis gridlines', () => {}); - test.skip('allows toggling the x axis gridlines', () => {}); - describe('LayerContextMenu', () => { test('enables stacked chart types even when there is no split series', () => { const state = testState(); @@ -92,4 +87,45 @@ describe('LayerContextMenu', () => { expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); }); }); + + describe('XyToolbar', () => { + it('should show currently selected fitting function', () => { + const state = testState(); + + const component = shallow( + + ); + + expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); + }); + + it('should disable the select if there is no unstacked area or line series', () => { + const state = testState(); + + const component = shallow( + + ); + + expect(component.find(EuiSuperSelect).prop('disabled')).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 3e73cd256bdbf1..84ea53fb4dc3dc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -4,13 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; +import { debounce } from 'lodash'; +import { + EuiButtonEmpty, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, + EuiFormRow, + EuiPopover, + EuiText, + htmlIdGenerator, + EuiForm, + EuiColorPicker, + EuiColorPickerProps, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import { + VisualizationLayerWidgetProps, + VisualizationDimensionEditorProps, + VisualizationToolbarProps, +} from '../types'; import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; -import { VisualizationDimensionEditorProps, VisualizationLayerWidgetProps } from '../types'; -import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; +import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; +import { fittingFunctionDefinitions } from './fitting_functions'; + +import './xy_config_panel.scss'; type UnwrapArray = T extends Array ? P : T; @@ -68,72 +91,247 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } +export function XyToolbar(props: VisualizationToolbarProps) { + const [open, setOpen] = useState(false); + const hasNonBarSeries = props.state?.layers.some( + (layer) => layer.seriesType === 'line' || layer.seriesType === 'area' + ); + return ( + + + { + setOpen(!open); + }} + > + {i18n.translate('xpack.lens.xyChart.settingsLabel', { defaultMessage: 'Settings' })} + + } + isOpen={open} + closePopover={() => { + setOpen(false); + }} + anchorPosition="downRight" + > + + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={props.state?.fittingFunction || 'None'} + onChange={(value) => props.setState({ ...props.state, fittingFunction: value })} + itemLayoutAlign="top" + hasDividers + /> +
+
+
+
+ ); +} const idPrefix = htmlIdGenerator()(); -export function DimensionEditor({ - state, - setState, - layerId, - accessor, -}: VisualizationDimensionEditorProps) { +export function DimensionEditor(props: VisualizationDimensionEditorProps) { + const { state, setState, layerId, accessor } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; const axisMode = (layer.yConfig && layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || 'auto'; + return ( - - + + + { - const newMode = id.replace(idPrefix, '') as YAxisMode; - const newYAxisConfigs = [...(layer.yConfig || [])]; - const existingIndex = newYAxisConfigs.findIndex( - (yAxisConfig) => yAxisConfig.forAccessor === accessor - ); - if (existingIndex !== -1) { - newYAxisConfigs[existingIndex].axisMode = newMode; + > + { + const newMode = id.replace(idPrefix, '') as YAxisMode; + const newYAxisConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYAxisConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYAxisConfigs[existingIndex].axisMode = newMode; + } else { + newYAxisConfigs.push({ + forAccessor: accessor, + axisMode: newMode, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); + }} + /> + + + ); +} + +const tooltipContent = { + auto: i18n.translate('xpack.lens.configPanel.color.tooltip.auto', { + defaultMessage: 'Lens automatically picks colors for you unless you specify a custom color.', + }), + custom: i18n.translate('xpack.lens.configPanel.color.tooltip.custom', { + defaultMessage: 'Clear the custom color to return to “Auto” mode.', + }), + disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { + defaultMessage: + 'Individual series cannot be custom colored when the layer includes a “Break down by“', + }), +}; + +const ColorPicker = ({ + state, + setState, + layerId, + accessor, +}: VisualizationDimensionEditorProps) => { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const disabled = !!layer.splitAccessor; + + const [color, setColor] = useState(getSeriesColor(layer, accessor)); + + const handleColor: EuiColorPickerProps['onChange'] = (text, output) => { + setColor(text); + if (output.isValid || text === '') { + updateColorInState(text, output); + } + }; + + const updateColorInState: EuiColorPickerProps['onChange'] = React.useMemo( + () => + debounce((text, output) => { + const newYConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYConfigs.findIndex((yConfig) => yConfig.forAccessor === accessor); + if (existingIndex !== -1) { + if (text === '') { + delete newYConfigs[existingIndex].color; } else { - newYAxisConfigs.push({ - forAccessor: accessor, - axisMode: newMode, - }); + newYConfigs[existingIndex].color = output.hex; } - setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); - }} - /> + } else { + newYConfigs.push({ + forAccessor: accessor, + color: output.hex, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYConfigs }, index)); + }, 256), + [state, layer, accessor, index] + ); + + return ( + + + {i18n.translate('xpack.lens.xyChart.seriesColor.label', { + defaultMessage: 'Series color', + })}{' '} + + + + } + > + {disabled ? ( + + + + ) : ( + + )} ); -} +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index f433a88e3bdbd0..b7a50b3af640c9 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -15,6 +15,7 @@ import { GeometryValue, XYChartSeriesIdentifier, SeriesNameFn, + Fit, } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { LensMultiTable } from '../types'; @@ -996,6 +997,75 @@ describe('xy_expression', () => { }); }); + describe('y series coloring', () => { + test('color is applied to chart for multiple series', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + splitAccessor: undefined, + accessors: ['a', 'b'], + yConfig: [ + { + forAccessor: 'a', + color: '#550000', + }, + { + forAccessor: 'b', + color: '#FFFF00', + }, + ], + }, + { + ...args.layers[0], + splitAccessor: undefined, + accessors: ['c'], + yConfig: [ + { + forAccessor: 'c', + color: '#FEECDF', + }, + ], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + expect((component.find(LineSeries).at(0).prop('color') as Function)!()).toEqual('#550000'); + expect((component.find(LineSeries).at(1).prop('color') as Function)!()).toEqual('#FFFF00'); + expect((component.find(LineSeries).at(2).prop('color') as Function)!()).toEqual('#FEECDF'); + }); + test('color is not applied to chart when splitAccessor is defined or when yConfig is not configured', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a'], + yConfig: [ + { + forAccessor: 'a', + color: '#550000', + }, + ], + }, + { + ...args.layers[0], + splitAccessor: undefined, + accessors: ['c'], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + expect((component.find(LineSeries).at(0).prop('color') as Function)!()).toEqual(null); + expect((component.find(LineSeries).at(1).prop('color') as Function)!()).toEqual(null); + }); + }); + describe('provides correct series naming', () => { const nameFnArgs = { seriesKeys: [], @@ -1485,5 +1555,66 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('showLegend')).toEqual(true); }); + + test('it should apply the fitting function to all non-bar series', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: createSampleDatatableWithRows([ + { a: 1, b: 2, c: 'I', d: 'Foo' }, + { a: 1, b: 5, c: 'J', d: 'Bar' }, + ]), + }, + }; + + const args: XYArgs = createArgsWithLayers([ + { ...sampleLayer, accessors: ['a'] }, + { ...sampleLayer, seriesType: 'bar', accessors: ['a'] }, + { ...sampleLayer, seriesType: 'area', accessors: ['a'] }, + { ...sampleLayer, seriesType: 'area_stacked', accessors: ['a'] }, + ]); + + const component = shallow( + + ); + + expect(component.find(LineSeries).prop('fit')).toEqual({ type: Fit.Carry }); + expect(component.find(BarSeries).prop('fit')).toEqual(undefined); + expect(component.find(AreaSeries).at(0).prop('fit')).toEqual({ type: Fit.Carry }); + expect(component.find(AreaSeries).at(0).prop('stackAccessors')).toEqual([]); + // stacked area series doesn't get the fit prop + expect(component.find(AreaSeries).at(1).prop('fit')).toEqual(undefined); + expect(component.find(AreaSeries).at(1).prop('stackAccessors')).toEqual(['c']); + }); + + test('it should apply None fitting function if not specified', () => { + const { data, args } = sampleArgs(); + + args.layers[0].accessors = ['a']; + + const component = shallow( + + ); + + expect(component.find(LineSeries).prop('fit')).toEqual({ type: Fit.None }); + }); }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 3ff7bd7fda3046..3ab12aa0879b04 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -35,11 +35,12 @@ import { } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; -import { isHorizontalChart } from './state_helpers'; +import { isHorizontalChart, getSeriesColor } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { EmptyPlaceholder } from '../shared_components'; import { desanitizeFilterContext } from '../utils'; +import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration } from './axes_configuration'; type InferPropType = T extends React.FunctionComponent ? P : T; @@ -94,6 +95,13 @@ export const xyChart: ExpressionFunctionDefinition< defaultMessage: 'Configure the chart legend.', }), }, + fittingFunction: { + types: ['string'], + options: [...fittingFunctionDefinitions.map(({ id }) => id)], + help: i18n.translate('xpack.lens.xyChart.fittingFunction.help', { + defaultMessage: 'Define how missing values are treated', + }), + }, layers: { // eslint-disable-next-line @typescript-eslint/no-explicit-any types: ['lens_xy_layer'] as any, @@ -191,7 +199,7 @@ export function XYChart({ onClickValue, onSelectRange, }: XYChartRenderProps) { - const { legend, layers } = args; + const { legend, layers, fittingFunction } = args; const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); @@ -433,6 +441,7 @@ export function XYChart({ data: rows, xScaleType, yScaleType, + color: () => getSeriesColor(layer, accessor), groupId: yAxesConfiguration.find((axisConfiguration) => axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) )?.groupId, @@ -462,7 +471,7 @@ export function XYChart({ } // This handles both split and single-y cases: // * If split series without formatting, show the value literally - // * If single Y, the seriesKey will be the acccessor, so we show the human-readable name + // * If single Y, the seriesKey will be the accessor, so we show the human-readable name return splitAccessor ? d.seriesKeys[0] : columnToLabelMap[d.seriesKeys[0]] ?? ''; }, }; @@ -471,17 +480,29 @@ export function XYChart({ switch (seriesType) { case 'line': - return ; + return ( + + ); case 'bar': case 'bar_stacked': case 'bar_horizontal': case 'bar_horizontal_stacked': return ; - default: + case 'area_stacked': return ; + case 'area': + return ( + + ); + default: + return assertNever(seriesType); } }) )} ); } + +function assertNever(x: never): never { + throw new Error('Unexpected series type: ' + x); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index c107d8d368248a..f3012063550606 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -331,6 +331,7 @@ describe('xy_suggestions', () => { test('makes a visible seriesType suggestion for unchanged table without split', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, + fittingFunction: 'None', preferredSeriesType: 'bar', layers: [ { @@ -368,6 +369,7 @@ describe('xy_suggestions', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', + fittingFunction: 'None', layers: [ { accessors: ['price', 'quantity'], @@ -408,6 +410,7 @@ describe('xy_suggestions', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, + fittingFunction: 'None', preferredSeriesType: 'bar', layers: [ { @@ -440,6 +443,7 @@ describe('xy_suggestions', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', + fittingFunction: 'None', layers: [ { accessors: ['price'], @@ -474,6 +478,7 @@ describe('xy_suggestions', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', + fittingFunction: 'None', layers: [ { accessors: ['price', 'quantity'], @@ -512,6 +517,7 @@ describe('xy_suggestions', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', + fittingFunction: 'None', layers: [ { accessors: ['price'], @@ -551,6 +557,7 @@ describe('xy_suggestions', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, preferredSeriesType: 'bar', + fittingFunction: 'None', layers: [ { accessors: ['price', 'quantity'], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 9d0ebbb389c077..e0bfbd266f8f12 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -402,6 +402,7 @@ function buildSuggestion({ const state: State = { legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, + fittingFunction: currentState?.fittingFunction || 'None', preferredSeriesType: seriesType, layers: [...keptLayers, newLayer], }; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx index d38f51cb1621aa..f321e0962caa87 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx @@ -11,7 +11,7 @@ import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; -import { DimensionEditor, LayerContextMenu } from './xy_config_panel'; +import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; import { Visualization, OperationMetadata, VisualizationType } from '../types'; import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; @@ -264,6 +264,15 @@ export const xyVisualization: Visualization = { ); }, + renderToolbar(domElement, props) { + render( + + + , + domElement + ); + }, + renderDimensionEditor(domElement, props) { render( diff --git a/x-pack/test/api_integration/apis/fleet/agent_flow.ts b/x-pack/test/api_integration/apis/fleet/agent_flow.ts index a6a4003a554fcb..e14a85d6e30c16 100644 --- a/x-pack/test/api_integration/apis/fleet/agent_flow.ts +++ b/x-pack/test/api_integration/apis/fleet/agent_flow.ts @@ -90,7 +90,7 @@ export default function (providerContext: FtrProviderContext) { events: [ { type: 'ACTION_RESULT', - subtype: 'CONFIG', + subtype: 'ACKNOWLEDGED', timestamp: '2019-01-04T14:32:03.36764-05:00', action_id: configChangeAction.id, agent_id: enrollmentResponse.item.id, @@ -132,7 +132,43 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(unenrollResponse.success).to.eql(true); - // Checkin after unenrollment + // Checkin after unenrollment + const { body: checkinAfterUnenrollResponse } = await supertestWithoutAuth + .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) + .set('kbn-xsrf', 'xx') + .set('Authorization', `ApiKey ${agentAccessAPIKey}`) + .send({ + events: [], + }) + .expect(200); + + expect(checkinAfterUnenrollResponse.success).to.eql(true); + expect(checkinAfterUnenrollResponse.actions).length(1); + expect(checkinAfterUnenrollResponse.actions[0].type).be('UNENROLL'); + const unenrollAction = checkinAfterUnenrollResponse.actions[0]; + + // ack unenroll actions + const { body: ackUnenrollApiResponse } = await supertestWithoutAuth + .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/acks`) + .set('Authorization', `ApiKey ${agentAccessAPIKey}`) + .set('kbn-xsrf', 'xx') + .send({ + events: [ + { + type: 'ACTION_RESULT', + subtype: 'ACKNOWLEDGED', + timestamp: '2019-01-04T14:32:03.36764-05:00', + action_id: unenrollAction.id, + agent_id: enrollmentResponse.item.id, + message: 'hello', + payload: 'payload', + }, + ], + }) + .expect(200); + expect(ackUnenrollApiResponse.success).to.eql(true); + + // Checkin after unenrollment acknowledged await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) .set('kbn-xsrf', 'xx') diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts index ecc39ea6455892..bc6c44e590cc4a 100644 --- a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts +++ b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts @@ -67,7 +67,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') .send({ - ids: ['agent1'], + force: true, }) .expect(200); @@ -80,7 +80,7 @@ export default function (providerContext: FtrProviderContext) { .post(`/api/ingest_manager/fleet/agents/agent1/unenroll`) .set('kbn-xsrf', 'xxx') .send({ - ids: ['agent1'], + force: true, }) .expect(200);