Skip to content

Commit

Permalink
[Logs Explorer] Support logs data views (elastic#176078)
Browse files Browse the repository at this point in the history
## 📓 Summary

Closes elastic#175767 

🧪 You can access a live deployment of this PR
[here](https://issue-deploy-kibana-pr176078.kb.us-west2.gcp.elastic-cloud.com/app/observability-logs-explorer/).

There has been a lot of talking around supporting selection for data
views and staying on the Logs Explorer for those concerning logs data
streams.

This work supports selecting and exploring Discover data views on Logs
Explorer. This is currently limited to logs data views.


https://github.com/elastic/kibana/assets/34506779/cccd6863-e1c1-4fa6-a530-9aed5d8d97c1

## Next steps

We had already an offline conversation with the team about how naming
the selector, selection modes and related entities is becoming
inconsistent and more difficult to maintain.
To keep this PR narrowed to the data views support, an upcoming PR will
focus on renaming according to the new selector's responsibilities.

## Core changes

### DataViewDescriptor
The `DataViewDescriptor` instance is a way to describe a data view in
the context of Logs Explorer. This does not represent a DataView object
complete of fields and all the details provided with a DataView
instance, but it instead encapsulates the logic around identifying what
data type a data view is about, as well as defining the logic to use it
with the new `dataView` selection mode.

It creates a new instance starting from a `DataViewListItem` object,
which is a minimal object provided by the dataViews service to list
existing data views.

### LogExplorerController
The `LogExplorerController` state machine now handles the selected entry
depending on its type, triggering different flows. There are 3 different
journeys depending on the selected entry:
- For a data view entry which is not about logs, the page redirects to
Discover with the data view selected
- For a data view entry about logs, the data loads in the Logs Explorer,
switching to the persisted DataView.
- For a dataset entry, an ad-hoc data view loads in the Logs Explorer.

To avoid updating twice the data view (once during initialization, and
immediately after during selection validation), the validation flow has
been anticipated and restructured to follow different flows, depending
on the selection type.

<img width="1953" alt="Screenshot 2024-02-09 at 12 02 10"
src="https://github.com/elastic/kibana/assets/34506779/a63201f5-67b2-4890-8823-6ffd6691e249">

### Dataset selector
The selector state machine unifies the selection handler and expands the
selection modes, adding a new `dataView` mode which handles logs data
view selections.

<img width="1281" alt="Screenshot 2024-02-05 at 16 24 31"
src="https://github.com/elastic/kibana/assets/34506779/80e04331-7b93-40fc-af1d-32ef4ef705f5">

---------

Co-authored-by: Marco Antonio Ghiani <marcoantonio.ghiani@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
3 people authored Feb 13, 2024
1 parent 1aa5e38 commit dd06f81
Show file tree
Hide file tree
Showing 34 changed files with 793 additions and 256 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataViewDescriptor } from './data_view_descriptor';

describe('DataViewDescriptor', () => {
it('should correctly assert whether a data view has "logs" type', () => {
const id = 'test-id';

// Assert truthy cases
expect(DataViewDescriptor.create({ id, title: 'auditbeat*' }).isLogsDataType()).toBeTruthy();
expect(DataViewDescriptor.create({ id, title: 'auditbeat-*' }).isLogsDataType()).toBeTruthy();
expect(DataViewDescriptor.create({ id, title: 'logs*' }).isLogsDataType()).toBeTruthy();
expect(DataViewDescriptor.create({ id, title: 'logs-*' }).isLogsDataType()).toBeTruthy();
expect(DataViewDescriptor.create({ id, title: 'logs-*-*' }).isLogsDataType()).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'logs-system.syslog-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'logs-system.syslog-default' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'cluster1:logs-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'cluster1:logs-*-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'cluster1:logs-system.syslog-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({
id,
title: 'cluster1:logs-system.syslog-default',
}).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'logs-*,cluster1:logs-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'logs-*,cluster1:logs-*,' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'cluster1:logs-*,cluster2:logs-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({ id, title: 'cluster1:logs-*,cluster2:logs-*' }).isLogsDataType()
).toBeTruthy();
expect(
DataViewDescriptor.create({
id,
title: '*:logs-system.syslog-*,*:logs-system.errors-*',
}).isLogsDataType()
).toBeTruthy();

// Assert falsy cases
expect(DataViewDescriptor.create({ id, title: 'auditbeats*' }).isLogsDataType()).toBeFalsy();
expect(DataViewDescriptor.create({ id, title: 'auditbeats-*' }).isLogsDataType()).toBeFalsy();
expect(DataViewDescriptor.create({ id, title: 'logss*' }).isLogsDataType()).toBeFalsy();
expect(DataViewDescriptor.create({ id, title: 'logss-*' }).isLogsDataType()).toBeFalsy();
expect(DataViewDescriptor.create({ id, title: 'metrics*' }).isLogsDataType()).toBeFalsy();
expect(DataViewDescriptor.create({ id, title: 'metrics-*' }).isLogsDataType()).toBeFalsy();
expect(
DataViewDescriptor.create({
id,
title: '*:metrics-system.syslog-*,logs-system.errors-*',
}).isLogsDataType()
).toBeFalsy();
expect(
DataViewDescriptor.create({ id, title: 'cluster1:logs-*,clust,er2:logs-*' }).isLogsDataType()
).toBeFalsy();
expect(
DataViewDescriptor.create({
id,
title: 'cluster1:logs-*, cluster2:logs-*',
}).isLogsDataType()
).toBeFalsy();
});
});
Original file line number Diff line number Diff line change
@@ -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 { DataViewListItem } from '@kbn/data-views-plugin/common';
import { DataViewSpecWithId } from '../../dataset_selection';
import { DataViewDescriptorType } from '../types';
import { buildIndexPatternRegExp } from '../utils';

type Allowlist = Array<string | RegExp>;

const LOGS_ALLOWLIST: Allowlist = [
buildIndexPatternRegExp(['logs', 'auditbeat', 'filebeat', 'winbeat']),
// Add more strings or regex patterns as needed
];

export class DataViewDescriptor {
id: DataViewDescriptorType['id'];
dataType: DataViewDescriptorType['dataType'];
kibanaSpaces: DataViewDescriptorType['kibanaSpaces'];
name: DataViewDescriptorType['name'];
title: DataViewDescriptorType['title'];
type: DataViewDescriptorType['type'];

private constructor(dataViewDescriptor: DataViewDescriptorType) {
this.id = dataViewDescriptor.id;
this.dataType = dataViewDescriptor.dataType;
this.kibanaSpaces = dataViewDescriptor.kibanaSpaces;
this.name = dataViewDescriptor.name;
this.title = dataViewDescriptor.title;
this.type = dataViewDescriptor.type;
}

getFullTitle() {
return this.name;
}

toDataviewSpec(): DataViewSpecWithId {
return {
id: this.id,
name: this.name,
title: this.title,
};
}

toPlain() {
return {
id: this.id,
dataType: this.dataType,
name: this.name,
title: this.title,
};
}

public static create({ id, namespaces, title, type, name }: DataViewListItem) {
const nameWithFallbackTitle = name ?? title;
const dataType = DataViewDescriptor.#extractDataType(title);
const kibanaSpaces = namespaces;

return new DataViewDescriptor({
id,
dataType,
kibanaSpaces,
name: nameWithFallbackTitle,
title,
type,
});
}

static #extractDataType(title: string): DataViewDescriptorType['dataType'] {
if (isAllowed(title, LOGS_ALLOWLIST)) {
return 'logs';
}

return 'unknown';
}

public isLogsDataType() {
return this.dataType === 'logs';
}

public isUnknownDataType() {
return this.dataType === 'unknown';
}
}

function isAllowed(value: string, allowList: Allowlist) {
for (const allowedItem of allowList) {
if (typeof allowedItem === 'string') {
return value === allowedItem;
}
if (allowedItem instanceof RegExp) {
return allowedItem.test(value);
}
}

// If no match is found in the allowList, return false
return false;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';

const dataTypeRT = rt.keyof({
logs: null,
unknown: null,
});

export const dataViewDescriptorRT = rt.exact(
rt.intersection([
rt.type({
id: rt.string,
name: rt.string,
title: rt.string,
dataType: dataTypeRT,
}),
rt.partial({
kibanaSpaces: rt.array(rt.string),
type: rt.string,
}),
])
);

export type DataViewDescriptorType = rt.TypeOf<typeof dataViewDescriptorRT>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export const buildIndexPatternRegExp = (basePatterns: string[]) => {
// Create the base patterns union with strict boundaries
const basePatternGroup = `\\b(${basePatterns.join('|')})\\b[^,\\s]+`;
// Apply base patterns union for local and remote clusters
const localAndRemotePatternGroup = `((${basePatternGroup})|([^:,\\s]+:${basePatternGroup}))`;
// Handle trailing comma and multiple pattern concatenation
return new RegExp(`^${localAndRemotePatternGroup}(,${localAndRemotePatternGroup})*(,$|$)`, 'i');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { DataViewDescriptor } from '../data_views/models/data_view_descriptor';
import { DatasetSelectionStrategy, DataViewSelectionPayload } from './types';

export class DataViewSelection implements DatasetSelectionStrategy {
selectionType: 'dataView';
selection: {
dataView: DataViewDescriptor;
};

private constructor(dataViewDescriptor: DataViewDescriptor) {
this.selectionType = 'dataView';
this.selection = {
dataView: dataViewDescriptor,
};
}

toDataviewSpec() {
return this.selection.dataView.toDataviewSpec();
}

toPlainSelection() {
return {
selectionType: this.selectionType,
selection: {
dataView: this.selection.dataView.toPlain(),
},
};
}

public static fromSelection(selection: DataViewSelectionPayload) {
const { dataView } = selection;

const dataViewDescriptor = DataViewDescriptor.create(dataView);
return DataViewSelection.create(dataViewDescriptor);
}

public static create(dataViewDescriptor: DataViewDescriptor) {
return new DataViewSelection(dataViewDescriptor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { AllDatasetSelection } from './all_dataset_selection';
import { DataViewSelection } from './data_view_selection';
import { SingleDatasetSelection } from './single_dataset_selection';
import { DatasetSelectionPlain } from './types';
import { UnresolvedDatasetSelection } from './unresolved_dataset_selection';
Expand All @@ -15,6 +16,8 @@ export const hydrateDatasetSelection = (datasetSelection: DatasetSelectionPlain)
return AllDatasetSelection.create();
} else if (datasetSelection.selectionType === 'single') {
return SingleDatasetSelection.fromSelection(datasetSelection.selection);
} else if (datasetSelection.selectionType === 'dataView') {
return DataViewSelection.fromSelection(datasetSelection.selection);
} else {
return UnresolvedDatasetSelection.fromSelection(datasetSelection.selection);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@
* 2.0.
*/

import { DataViewListItem } from '@kbn/data-views-plugin/common';
import { AllDatasetSelection } from './all_dataset_selection';
import { DataViewSelection } from './data_view_selection';
import { SingleDatasetSelection } from './single_dataset_selection';
import { UnresolvedDatasetSelection } from './unresolved_dataset_selection';

export type DatasetSelection =
| AllDatasetSelection
| SingleDatasetSelection
| UnresolvedDatasetSelection;
export type DatasetSelectionChange = (datasetSelection: DatasetSelection) => void;
export type DataViewSelection = (dataView: DataViewListItem) => void;
export type SelectionChange = (selection: DatasetSelection | DataViewSelection) => void;

export const isDatasetSelection = (input: any): input is DatasetSelection => {
return (
Expand All @@ -25,7 +24,17 @@ export const isDatasetSelection = (input: any): input is DatasetSelection => {
);
};

export const isUnresolvedDatasetSelection = (input: any): input is UnresolvedDatasetSelection => {
return input instanceof UnresolvedDatasetSelection;
};

export const isDataViewSelection = (input: any): input is DataViewSelection => {
return input instanceof DataViewSelection;
};

export * from './all_dataset_selection';
export * from './data_view_selection';
export * from './single_dataset_selection';
export * from './single_dataset_selection';
export * from './unresolved_dataset_selection';
export * from './errors';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class SingleDatasetSelection implements DatasetSelectionStrategy {
const integration = name && version ? { name, title, version } : undefined;
const datasetInstance = Dataset.create(dataset, integration);

return new SingleDatasetSelection(datasetInstance);
return SingleDatasetSelection.create(datasetInstance);
}

public static create(dataset: Dataset) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { DataViewSpec } from '@kbn/data-views-plugin/common';
import * as rt from 'io-ts';
import { datasetRT } from '../datasets';
import { dataViewDescriptorRT } from '../data_views/types';

export const allDatasetSelectionPlainRT = rt.type({
selectionType: rt.literal('all'),
Expand All @@ -33,6 +34,10 @@ const singleDatasetSelectionPayloadRT = rt.intersection([
}),
]);

const dataViewSelectionPayloadRT = rt.type({
dataView: dataViewDescriptorRT,
});

const unresolvedDatasetSelectionPayloadRT = rt.intersection([
integrationNameRT,
rt.type({
Expand All @@ -45,18 +50,25 @@ export const singleDatasetSelectionPlainRT = rt.type({
selection: singleDatasetSelectionPayloadRT,
});

export const dataViewSelectionPlainRT = rt.type({
selectionType: rt.literal('dataView'),
selection: dataViewSelectionPayloadRT,
});

export const unresolvedDatasetSelectionPlainRT = rt.type({
selectionType: rt.literal('unresolved'),
selection: unresolvedDatasetSelectionPayloadRT,
});

export const datasetSelectionPlainRT = rt.union([
allDatasetSelectionPlainRT,
dataViewSelectionPlainRT,
singleDatasetSelectionPlainRT,
unresolvedDatasetSelectionPlainRT,
]);

export type SingleDatasetSelectionPayload = rt.TypeOf<typeof singleDatasetSelectionPayloadRT>;
export type DataViewSelectionPayload = rt.TypeOf<typeof dataViewSelectionPayloadRT>;
export type UnresolvedDatasetSelectionPayload = rt.TypeOf<
typeof unresolvedDatasetSelectionPayloadRT
>;
Expand Down
Loading

0 comments on commit dd06f81

Please sign in to comment.