[Logs+] Refactor state and URL persistence of Log Explorer (#170200)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Felix Stürmer <felix.stuermer@elastic.co>
This commit is contained in:
Kerry Gallagher 2023-12-11 13:56:44 +00:00 committed by GitHub
parent 2d3d21500f
commit dbabd6d16e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 3434 additions and 1856 deletions

View file

@ -8,7 +8,7 @@
export const LOGS_APP_ID = 'logs';
export const OBSERVABILITY_LOG_EXPLORER = 'observability-log-explorer';
export const OBSERVABILITY_LOG_EXPLORER_APP_ID = 'observability-log-explorer';
export const OBSERVABILITY_OVERVIEW_APP_ID = 'observability-overview';

View file

@ -7,16 +7,16 @@
*/
import {
LOGS_APP_ID,
OBSERVABILITY_LOG_EXPLORER,
OBSERVABILITY_OVERVIEW_APP_ID,
METRICS_APP_ID,
APM_APP_ID,
LOGS_APP_ID,
METRICS_APP_ID,
OBSERVABILITY_LOG_EXPLORER_APP_ID,
OBSERVABILITY_ONBOARDING_APP_ID,
OBSERVABILITY_OVERVIEW_APP_ID,
} from './constants';
type LogsApp = typeof LOGS_APP_ID;
type ObservabilityLogExplorerApp = typeof OBSERVABILITY_LOG_EXPLORER;
type ObservabilityLogExplorerApp = typeof OBSERVABILITY_LOG_EXPLORER_APP_ID;
type ObservabilityOverviewApp = typeof OBSERVABILITY_OVERVIEW_APP_ID;
type MetricsApp = typeof METRICS_APP_ID;
type ApmApp = typeof APM_APP_ID;

View file

@ -7,12 +7,10 @@
*/
export {
OBSERVABILITY_ONBOARDING_APP_ID,
LOGS_APP_ID,
OBSERVABILITY_LOG_EXPLORER,
OBSERVABILITY_LOG_EXPLORER_APP_ID,
OBSERVABILITY_ONBOARDING_APP_ID,
OBSERVABILITY_OVERVIEW_APP_ID,
} from './constants';
export type { AppId, DeepLinkId } from './deep_links';
export * from './locators';

View file

@ -14,6 +14,17 @@ export type RefreshInterval = {
value: number;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type FilterControls = {
namespace?: ListFilterControl;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ListFilterControl = {
mode: 'include';
values: string[];
};
export const LOG_EXPLORER_LOCATOR_ID = 'LOG_EXPLORER_LOCATOR';
export interface LogExplorerNavigationParams extends SerializableRecord {
@ -34,13 +45,13 @@ export interface LogExplorerNavigationParams extends SerializableRecord {
*/
columns?: string[];
/**
* Array of the used sorting [[field,direction],...]
*/
sort?: string[][];
/**
* Optionally apply filters.
* Optionally apply free-form filters.
*/
filters?: Filter[];
/**
* Optionally apply curated filter controls
*/
filterControls?: FilterControls;
}
export interface LogExplorerLocatorParams extends LogExplorerNavigationParams {

View file

@ -6,4 +6,52 @@
* Side Public License, v 1.
*/
import {
isArray,
isBoolean,
isDate,
isNil,
isNumber,
isPlainObject,
isString,
mapValues,
} from 'lodash';
export const isDevMode = () => process.env.NODE_ENV !== 'production';
export const getDevToolsOptions = (): boolean | object =>
isDevMode()
? {
actionSanitizer: sanitizeAction,
stateSanitizer: sanitizeState,
}
: false;
const redactComplexValues = (value: unknown): unknown => {
if (isString(value) || isNumber(value) || isBoolean(value) || isDate(value) || isNil(value)) {
return value;
}
if (isArray(value)) {
if (value.length > 100) {
return '[redacted large array]';
}
return value.map(redactComplexValues);
}
if ((isPlainObject as (v: unknown) => v is object)(value)) {
if (Object.keys(value).length > 100) {
return '[redacted large object]';
}
return mapValues(value, (innerValue: unknown) => redactComplexValues(innerValue));
}
return `[redacted complex value of type ${typeof value}]`;
};
const sanitizeAction = redactComplexValues;
const sanitizeState = (state: Record<string, unknown>) => ({
value: state.value,
context: redactComplexValues(state.context),
});

View file

@ -7,6 +7,6 @@
*/
export * from './actions';
export * from './dev_tools';
export * from './notification_channel';
export * from './types';
export * from './dev_tools';

View file

@ -9,7 +9,11 @@
import React, { useEffect, useState, memo, useCallback, useMemo } from 'react';
import { useParams, useHistory } from 'react-router-dom';
import type { DataView } from '@kbn/data-views-plugin/public';
import { redirectWhenMissing, SavedObjectNotFound } from '@kbn/kibana-utils-plugin/public';
import {
type IKbnUrlStateStorage,
redirectWhenMissing,
SavedObjectNotFound,
} from '@kbn/kibana-utils-plugin/public';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import {
AnalyticsNoDataPageKibanaProvider,
@ -46,6 +50,7 @@ interface DiscoverLandingParams {
export interface MainRouteProps {
customizationCallbacks: CustomizationCallback[];
stateStorageContainer?: IKbnUrlStateStorage;
isDev: boolean;
customizationContext: DiscoverCustomizationContext;
}
@ -53,6 +58,7 @@ export interface MainRouteProps {
export function DiscoverMainRoute({
customizationCallbacks,
customizationContext,
stateStorageContainer,
}: MainRouteProps) {
const history = useHistory();
const services = useDiscoverServices();
@ -70,6 +76,7 @@ export function DiscoverMainRoute({
history,
services,
customizationContext,
stateStorageContainer,
})
);
const { customizationService, isInitialized: isCustomizationServiceInitialized } =

View file

@ -11,7 +11,7 @@ import {
DiscoverStateContainer,
createSearchSessionRestorationDataProvider,
} from './discover_state';
import { createBrowserHistory, History } from 'history';
import { createBrowserHistory, createMemoryHistory, History } from 'history';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import type { SavedSearch, SortOrder } from '@kbn/saved-search-plugin/public';
import {
@ -27,6 +27,7 @@ import { waitFor } from '@testing-library/react';
import { DiscoverCustomizationContext, FetchStatus } from '../../types';
import { dataViewAdHoc, dataViewComplexMock } from '../../../__mocks__/data_view_complex';
import { copySavedSearch } from './discover_saved_search_container';
import { createKbnUrlStateStorage, IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
const startSync = (appState: DiscoverAppStateContainer) => {
const { start, stop } = appState.syncState();
@ -151,6 +152,68 @@ describe('Test discover state', () => {
expect(getCurrentUrl()).toBe('/#?_g=(refreshInterval:(pause:!t,value:5000))');
});
});
describe('Test discover state with overridden state storage', () => {
let stopSync = () => {};
let history: History;
let stateStorage: IKbnUrlStateStorage;
let state: DiscoverStateContainer;
beforeEach(async () => {
jest.useFakeTimers();
history = createMemoryHistory({
initialEntries: [
{
pathname: '/',
hash: `?_a=()`,
},
],
});
stateStorage = createKbnUrlStateStorage({
history,
useHash: false,
useHashQuery: true,
});
state = getDiscoverStateContainer({
services: discoverServiceMock,
history,
customizationContext,
stateStorageContainer: stateStorage,
});
state.savedSearchState.set(savedSearchMock);
state.appState.update({}, true);
stopSync = startSync(state.appState);
});
afterEach(() => {
stopSync();
stopSync = () => {};
jest.useRealTimers();
});
test('setting app state and syncing to URL', async () => {
state.appState.update({ index: 'modified' });
await jest.runAllTimersAsync();
expect(history.createHref(history.location)).toMatchInlineSnapshot(
`"/#?_a=(columns:!(default_column),index:modified,interval:auto,sort:!())"`
);
});
test('changing URL to be propagated to appState', async () => {
history.push('/#?_a=(index:modified)');
await jest.runAllTimersAsync();
expect(state.appState.getState()).toMatchInlineSnapshot(`
Object {
"index": "modified",
}
`);
});
});
describe('Test discover initial state sort handling', () => {
test('Non-empty sort in URL should not be overwritten by saved search sort', async () => {
const savedSearch = {

View file

@ -71,6 +71,10 @@ interface DiscoverStateContainerParams {
* Context object for customization related properties
*/
customizationContext: DiscoverCustomizationContext;
/**
* a custom url state storage
*/
stateStorageContainer?: IKbnUrlStateStorage;
}
export interface LoadParams {
@ -204,6 +208,7 @@ export function getDiscoverStateContainer({
history,
services,
customizationContext,
stateStorageContainer,
}: DiscoverStateContainerParams): DiscoverStateContainer {
const storeInSessionStorage = services.uiSettings.get('state:storeInSessionStorage');
const toasts = services.core.notifications.toasts;
@ -211,12 +216,14 @@ export function getDiscoverStateContainer({
/**
* state storage for state in the URL
*/
const stateStorage = createKbnUrlStateStorage({
useHash: storeInSessionStorage,
history,
useHashQuery: customizationContext.displayMode !== 'embedded',
...(toasts && withNotifyOnErrors(toasts)),
});
const stateStorage =
stateStorageContainer ??
createKbnUrlStateStorage({
useHash: storeInSessionStorage,
history,
useHashQuery: customizationContext.displayMode !== 'embedded',
...(toasts && withNotifyOnErrors(toasts)),
});
/**
* Search session logic

View file

@ -11,6 +11,7 @@ import type { ScopedHistory } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import React, { useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { DiscoverMainRoute } from '../../application/main';
import type { DiscoverServices } from '../../build_services';
import type { CustomizationCallback } from '../../customizations';
@ -29,6 +30,7 @@ export interface DiscoverContainerInternalProps {
getDiscoverServices: () => Promise<DiscoverServices>;
scopedHistory: ScopedHistory;
customizationCallbacks: CustomizationCallback[];
stateStorageContainer?: IKbnUrlStateStorage;
isDev: boolean;
isLoading?: boolean;
}
@ -55,6 +57,7 @@ export const DiscoverContainerInternal = ({
customizationCallbacks,
isDev,
getDiscoverServices,
stateStorageContainer,
isLoading = false,
}: DiscoverContainerInternalProps) => {
const [discoverServices, setDiscoverServices] = useState<DiscoverServices | undefined>();
@ -97,6 +100,7 @@ export const DiscoverContainerInternal = ({
<DiscoverMainRoute
customizationCallbacks={customizationCallbacks}
customizationContext={customizationContext}
stateStorageContainer={stateStorageContainer}
isDev={isDev}
/>
</KibanaContextProvider>

View file

@ -202,7 +202,7 @@ describe('kbn_url_storage', () => {
await Promise.all([pr1, pr2, pr3]);
expect(getCurrentUrl()).toBe('/3');
expect(urlControls.getPendingUrl()).toBeUndefined();
expect(urlControls.getPendingUrl()).toEqual(getCurrentUrl());
});
});

View file

@ -193,17 +193,17 @@ export const createKbnUrlControls = (
// runs scheduled url updates
function flush(replace = shouldReplace) {
if (updateQueue.length === 0) {
return;
}
const nextUrl = getPendingUrl();
if (!nextUrl) return;
cleanUp();
const newUrl = updateUrl(nextUrl, replace);
return newUrl;
}
function getPendingUrl() {
if (updateQueue.length === 0) return undefined;
const resultUrl = updateQueue.reduce(
(url, nextUpdate) => nextUpdate(url) ?? url,
getCurrentUrl(history)

View file

@ -116,7 +116,11 @@ export const createKbnUrlStateStorage = (
unlisten();
};
}).pipe(
map(() => getStateFromKbnUrl<State>(key, undefined, { getFromHashQuery: useHashQuery })),
map(() =>
getStateFromKbnUrl<State>(key, history?.createHref(history.location), {
getFromHashQuery: useHashQuery,
})
),
catchError((error) => {
if (onGetErrorThrottled) onGetErrorThrottled(error);
return of(null);

View file

@ -32,8 +32,17 @@ export const DATA_GRID_COLUMN_WIDTH_SMALL = 240;
export const DATA_GRID_COLUMN_WIDTH_MEDIUM = 320;
// UI preferences
export const DATA_GRID_DEFAULT_COLUMNS = [SERVICE_NAME_FIELD, HOST_NAME_FIELD, MESSAGE_FIELD];
export const DATA_GRID_COLUMNS_PREFERENCES = {
[HOST_NAME_FIELD]: { width: DATA_GRID_COLUMN_WIDTH_MEDIUM },
[SERVICE_NAME_FIELD]: { width: DATA_GRID_COLUMN_WIDTH_SMALL },
};
export const DEFAULT_COLUMNS = [
{
field: SERVICE_NAME_FIELD,
width: DATA_GRID_COLUMN_WIDTH_SMALL,
},
{
field: HOST_NAME_FIELD,
width: DATA_GRID_COLUMN_WIDTH_MEDIUM,
},
{
field: MESSAGE_FIELD,
},
];
export const DEFAULT_ROWS_PER_PAGE = 100;

View file

@ -5,18 +5,13 @@
* 2.0.
*/
import { AllDatasetSelection } from '../../../../common/dataset_selection';
import { ControlPanels, DefaultLogExplorerProfileState } from './types';
export const DEFAULT_CONTEXT: DefaultLogExplorerProfileState = {
datasetSelection: AllDatasetSelection.create(),
};
export const CONTROL_PANELS_URL_KEY = 'controlPanels';
import { ControlPanels } from './types';
export const availableControlsPanels = {
NAMESPACE: 'data_stream.namespace',
};
} as const;
export type AvailableControlPanels = typeof availableControlsPanels;
export const controlPanelConfigs: ControlPanels = {
[availableControlsPanels.NAMESPACE]: {

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './available_control_panels';
export * from './types';

View file

@ -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 PanelRT = rt.type({
order: rt.number,
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
type: rt.string,
explicitInput: rt.intersection([
rt.type({ id: rt.string }),
rt.partial({
dataViewId: rt.string,
exclude: rt.boolean,
existsSelected: rt.boolean,
fieldName: rt.string,
selectedOptions: rt.array(rt.string),
title: rt.union([rt.string, rt.undefined]),
}),
]),
});
export const ControlPanelRT = rt.record(rt.string, PanelRT);
export type ControlPanels = rt.TypeOf<typeof ControlPanelRT>;

View file

@ -6,7 +6,6 @@
*/
import { Dataset } from '../datasets';
import { encodeDatasetSelection } from './encoding';
import { DatasetSelectionStrategy } from './types';
export class AllDatasetSelection implements DatasetSelectionStrategy {
@ -23,18 +22,13 @@ export class AllDatasetSelection implements DatasetSelectionStrategy {
}
toDataviewSpec() {
const { name, title } = this.selection.dataset.toDataviewSpec();
return {
id: this.toURLSelectionId(),
name,
title,
};
return this.selection.dataset.toDataviewSpec();
}
toURLSelectionId() {
return encodeDatasetSelection({
toPlainSelection() {
return {
selectionType: this.selectionType,
});
};
}
public static create() {

View file

@ -1,100 +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 { IndexPattern } from '@kbn/io-ts-utils';
import { encodeDatasetSelection, decodeDatasetSelectionId } from './encoding';
import { DatasetEncodingError } from './errors';
import { DatasetSelectionPlain } from './types';
describe('DatasetSelection', () => {
const allDatasetSelectionPlain: DatasetSelectionPlain = {
selectionType: 'all',
};
const encodedAllDatasetSelection = 'BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA';
const singleDatasetSelectionPlain: DatasetSelectionPlain = {
selectionType: 'single',
selection: {
name: 'azure',
version: '1.5.23',
dataset: {
name: 'logs-azure.activitylogs-*' as IndexPattern,
title: 'activitylogs',
},
},
};
const encodedSingleDatasetSelection =
'BQZwpgNmDGAuCWB7AdgLmAEwIay+W6yWAtmKgOQSIDmIAtFgF4CuATmAHRZzwBu8sAJ5VadAFTkANAlhRU3BPyEiQASklFS8lu0m8wrEEjTkAjBwCsHAEwBmcuvBQeKACqCADmSPJqUVUA==';
const invalidDatasetSelectionPlain = {
selectionType: 'single',
selection: {
dataset: {
// Missing mandatory `name` property
title: 'activitylogs',
},
},
};
const invalidCompressedId = 'random';
const invalidEncodedDatasetSelection = 'BQZwpgNmDGAuCWB7AdgFQJ4AcwC4T2QHMoBKIA==';
describe('#encodeDatasetSelection', () => {
test('should encode and compress a valid DatasetSelection plain object', () => {
// Encode AllDatasetSelection plain object
expect(encodeDatasetSelection(allDatasetSelectionPlain)).toEqual(encodedAllDatasetSelection);
// Encode SingleDatasetSelection plain object
expect(encodeDatasetSelection(singleDatasetSelectionPlain)).toEqual(
encodedSingleDatasetSelection
);
});
test('should throw a DatasetEncodingError if the input is an invalid DatasetSelection plain object', () => {
const encodingRunner = () =>
encodeDatasetSelection(invalidDatasetSelectionPlain as DatasetSelectionPlain);
expect(encodingRunner).toThrow(DatasetEncodingError);
expect(encodingRunner).toThrow(/^The current dataset selection is invalid/);
});
});
describe('#decodeDatasetSelectionId', () => {
test('should decode and decompress a valid encoded string', () => {
// Decode AllDatasetSelection plain object
expect(decodeDatasetSelectionId(encodedAllDatasetSelection)).toEqual(
allDatasetSelectionPlain
);
// Decode SingleDatasetSelection plain object
expect(decodeDatasetSelectionId(encodedSingleDatasetSelection)).toEqual(
singleDatasetSelectionPlain
);
});
test('should throw a DatasetEncodingError if the input is an invalid compressed id', () => {
expect(() => decodeDatasetSelectionId(invalidCompressedId)).toThrow(
new DatasetEncodingError('The stored id is not a valid compressed value.')
);
});
test('should throw a DatasetEncodingError if the decompressed value is an invalid DatasetSelection plain object', () => {
const decodingRunner = () => decodeDatasetSelectionId(invalidEncodedDatasetSelection);
expect(decodingRunner).toThrow(DatasetEncodingError);
expect(decodingRunner).toThrow(/^The current dataset selection is invalid/);
});
});
test('encoding and decoding should restore the original DatasetSelection plain object', () => {
// Encode/Decode AllDatasetSelection plain object
expect(decodeDatasetSelectionId(encodeDatasetSelection(allDatasetSelectionPlain))).toEqual(
allDatasetSelectionPlain
);
// Encode/Decode SingleDatasetSelection plain object
expect(decodeDatasetSelectionId(encodeDatasetSelection(singleDatasetSelectionPlain))).toEqual(
singleDatasetSelectionPlain
);
});
});

View file

@ -1,40 +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 { decode, encode, RisonValue } from '@kbn/rison';
import * as lz from 'lz-string';
import { decodeOrThrow } from '../runtime_types';
import { DatasetEncodingError } from './errors';
import { DatasetSelectionPlain, datasetSelectionPlainRT } from './types';
export const encodeDatasetSelection = (datasetSelectionPlain: DatasetSelectionPlain) => {
const safeDatasetSelection = decodeOrThrow(
datasetSelectionPlainRT,
(message: string) =>
new DatasetEncodingError(`The current dataset selection is invalid: ${message}"`)
)(datasetSelectionPlain);
return lz.compressToBase64(encode(safeDatasetSelection));
};
export const decodeDatasetSelectionId = (datasetSelectionId: string): DatasetSelectionPlain => {
const risonDatasetSelection: RisonValue = lz.decompressFromBase64(datasetSelectionId);
if (risonDatasetSelection === null || risonDatasetSelection === '') {
throw new DatasetEncodingError('The stored id is not a valid compressed value.');
}
const decodedDatasetSelection = decode(risonDatasetSelection);
const datasetSelection = decodeOrThrow(
datasetSelectionPlainRT,
(message: string) =>
new DatasetEncodingError(`The current dataset selection is invalid: ${message}"`)
)(decodedDatasetSelection);
return datasetSelection;
};

View file

@ -13,11 +13,9 @@ import { UnresolvedDatasetSelection } from './unresolved_dataset_selection';
export const hydrateDatasetSelection = (datasetSelection: DatasetSelectionPlain) => {
if (datasetSelection.selectionType === 'all') {
return AllDatasetSelection.create();
}
if (datasetSelection.selectionType === 'single') {
} else if (datasetSelection.selectionType === 'single') {
return SingleDatasetSelection.fromSelection(datasetSelection.selection);
}
if (datasetSelection.selectionType === 'unresolved') {
} else {
return UnresolvedDatasetSelection.fromSelection(datasetSelection.selection);
}
};

View file

@ -28,7 +28,6 @@ export const isDatasetSelection = (input: any): input is DatasetSelection => {
export * from './all_dataset_selection';
export * from './single_dataset_selection';
export * from './unresolved_dataset_selection';
export * from './encoding';
export * from './errors';
export * from './hydrate_dataset_selection.ts';
export * from './types';

View file

@ -6,7 +6,6 @@
*/
import { Dataset } from '../datasets';
import { encodeDatasetSelection } from './encoding';
import { DatasetSelectionStrategy, SingleDatasetSelectionPayload } from './types';
export class SingleDatasetSelection implements DatasetSelectionStrategy {
@ -29,16 +28,11 @@ export class SingleDatasetSelection implements DatasetSelectionStrategy {
}
toDataviewSpec() {
const { name, title } = this.selection.dataset.toDataviewSpec();
return {
id: this.toURLSelectionId(),
name,
title,
};
return this.selection.dataset.toDataviewSpec();
}
toURLSelectionId() {
return encodeDatasetSelection({
toPlainSelection() {
return {
selectionType: this.selectionType,
selection: {
name: this.selection.name,
@ -46,7 +40,7 @@ export class SingleDatasetSelection implements DatasetSelectionStrategy {
version: this.selection.version,
dataset: this.selection.dataset.toPlain(),
},
});
};
}
public static fromSelection(selection: SingleDatasetSelectionPayload) {

View file

@ -62,7 +62,11 @@ export type UnresolvedDatasetSelectionPayload = rt.TypeOf<
>;
export type DatasetSelectionPlain = rt.TypeOf<typeof datasetSelectionPlainRT>;
export type DataViewSpecWithId = DataViewSpec & {
id: string;
};
export interface DatasetSelectionStrategy {
toDataviewSpec(): DataViewSpec;
toURLSelectionId(): string;
toDataviewSpec(): DataViewSpecWithId;
toPlainSelection(): DatasetSelectionPlain;
}

View file

@ -6,7 +6,6 @@
*/
import { Dataset } from '../datasets';
import { encodeDatasetSelection } from './encoding';
import { DatasetSelectionStrategy, UnresolvedDatasetSelectionPayload } from './types';
export class UnresolvedDatasetSelection implements DatasetSelectionStrategy {
@ -25,22 +24,17 @@ export class UnresolvedDatasetSelection implements DatasetSelectionStrategy {
}
toDataviewSpec() {
const { name, title } = this.selection.dataset.toDataviewSpec();
return {
id: this.toURLSelectionId(),
name,
title,
};
return this.selection.dataset.toDataviewSpec();
}
toURLSelectionId() {
return encodeDatasetSelection({
toPlainSelection() {
return {
selectionType: this.selectionType,
selection: {
name: this.selection.name,
dataset: this.selection.dataset.toPlain(),
},
});
};
}
public static fromSelection(selection: UnresolvedDatasetSelectionPayload) {

View file

@ -6,9 +6,9 @@
*/
import { IconType } from '@elastic/eui';
import { DataViewSpec } from '@kbn/data-views-plugin/common';
import { IndexPattern } from '@kbn/io-ts-utils';
import { TIMESTAMP_FIELD } from '../../constants';
import { DataViewSpecWithId } from '../../dataset_selection';
import { DatasetId, DatasetType, IntegrationType } from '../types';
type IntegrationBase = Partial<Pick<IntegrationType, 'name' | 'title' | 'icons' | 'version'>>;
@ -49,7 +49,7 @@ export class Dataset {
return `${type}-${dataset}-*` as IndexPattern;
}
toDataviewSpec(): DataViewSpec {
toDataviewSpec(): DataViewSpecWithId {
// Invert the property because the API returns the index pattern as `name` and a readable name as `title`
return {
id: this.id,

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './types';

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface ChartDisplayOptions {
breakdownField: string | null;
}
export type PartialChartDisplayOptions = Partial<ChartDisplayOptions>;
export interface GridColumnDisplayOptions {
field: string;
width?: number;
}
export interface GridRowsDisplayOptions {
rowHeight: number;
rowsPerPage: number;
}
export type PartialGridRowsDisplayOptions = Partial<GridRowsDisplayOptions>;
export interface GridDisplayOptions {
columns: GridColumnDisplayOptions[];
rows: GridRowsDisplayOptions;
}
export type PartialGridDisplayOptions = Partial<
Omit<GridDisplayOptions, 'rows'> & { rows?: PartialGridRowsDisplayOptions }
>;
export interface DisplayOptions {
grid: GridDisplayOptions;
chart: ChartDisplayOptions;
}
export interface PartialDisplayOptions {
grid?: PartialGridDisplayOptions;
chart?: PartialChartDisplayOptions;
}

View file

@ -5,4 +5,28 @@
* 2.0.
*/
export { AllDatasetSelection, UnresolvedDatasetSelection } from './dataset_selection';
export {
availableControlPanelFields,
availableControlsPanels,
controlPanelConfigs,
ControlPanelRT,
} from './control_panels';
export type { AvailableControlPanels, ControlPanels } from './control_panels';
export {
AllDatasetSelection,
datasetSelectionPlainRT,
hydrateDatasetSelection,
UnresolvedDatasetSelection,
} from './dataset_selection';
export type { DatasetSelectionPlain } from './dataset_selection';
export type {
ChartDisplayOptions,
DisplayOptions,
GridColumnDisplayOptions,
GridDisplayOptions,
GridRowsDisplayOptions,
PartialChartDisplayOptions,
PartialDisplayOptions,
PartialGridDisplayOptions,
PartialGridRowsDisplayOptions,
} from './display_options';

View file

@ -12,16 +12,18 @@
"logExplorer"
],
"requiredPlugins": [
"controls",
"data",
"dataViews",
"discover",
"embeddable",
"fieldFormats",
"fleet",
"kibanaReact",
"kibanaUtils",
"controls",
"embeddable",
"navigation",
"share",
"unifiedSearch"
],
"optionalPlugins": [],
"requiredBundles": [],

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { FlyoutProps, LogDocument } from './types';
import { LogExplorerFlyoutContentProps, LogDocument } from './types';
import { useDocDetail } from './use_doc_detail';
import { FlyoutHeader } from './flyout_header';
import { FlyoutHighlights } from './flyout_highlights';
@ -16,7 +16,7 @@ export function FlyoutDetail({
dataView,
doc,
actions,
}: Pick<FlyoutProps, 'dataView' | 'doc' | 'actions'>) {
}: Pick<LogExplorerFlyoutContentProps, 'dataView' | 'doc' | 'actions'>) {
const parsedDoc = useDocDetail(doc as LogDocument, { dataView });
return (

View file

@ -5,55 +5,4 @@
* 2.0.
*/
import type { DataView } from '@kbn/data-views-plugin/common';
import type { FlyoutContentProps } from '@kbn/discover-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils/types';
export interface FlyoutProps extends FlyoutContentProps {
dataView: DataView;
doc: LogDocument;
}
export interface LogDocument extends DataTableRecord {
flattened: {
'@timestamp': string;
'log.level'?: [string];
message?: [string];
'host.name'?: string;
'service.name'?: string;
'trace.id'?: string;
'agent.name'?: string;
'orchestrator.cluster.name'?: string;
'orchestrator.resource.id'?: string;
'cloud.provider'?: string;
'cloud.region'?: string;
'cloud.availability_zone'?: string;
'cloud.project.id'?: string;
'cloud.instance.id'?: string;
'log.file.path'?: string;
'data_stream.namespace': string;
'data_stream.dataset': string;
};
}
export interface FlyoutDoc {
'@timestamp': string;
'log.level'?: string;
message?: string;
'host.name'?: string;
'service.name'?: string;
'trace.id'?: string;
'agent.name'?: string;
'orchestrator.cluster.name'?: string;
'orchestrator.resource.id'?: string;
'cloud.provider'?: string;
'cloud.region'?: string;
'cloud.availability_zone'?: string;
'cloud.project.id'?: string;
'cloud.instance.id'?: string;
'log.file.path'?: string;
'data_stream.namespace': string;
'data_stream.dataset': string;
}
export type { FlyoutDoc, LogDocument, LogExplorerFlyoutContentProps } from '../../controller';

View file

@ -7,11 +7,11 @@
import { formatFieldValue } from '@kbn/discover-utils';
import * as constants from '../../../common/constants';
import { useKibanaContextForPlugin } from '../../utils/use_kibana';
import { FlyoutDoc, FlyoutProps, LogDocument } from './types';
import { FlyoutDoc, LogExplorerFlyoutContentProps, LogDocument } from './types';
export function useDocDetail(
doc: LogDocument,
{ dataView }: Pick<FlyoutProps, 'dataView'>
{ dataView }: Pick<LogExplorerFlyoutContentProps, 'dataView'>
): FlyoutDoc {
const { services } = useKibanaContextForPlugin();

View file

@ -5,100 +5,43 @@
* 2.0.
*/
import type { ScopedHistory } from '@kbn/core-application-browser';
import type { CoreStart } from '@kbn/core/public';
import React, { useMemo } from 'react';
import { ScopedHistory } from '@kbn/core-application-browser';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DiscoverAppState } from '@kbn/discover-plugin/public';
import type { BehaviorSubject } from 'rxjs';
import { CoreStart } from '@kbn/core/public';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { HIDE_ANNOUNCEMENTS } from '@kbn/discover-utils';
import type { LogExplorerController } from '../../controller';
import { createLogExplorerProfileCustomizations } from '../../customizations/log_explorer_profile';
import { createPropertyGetProxy } from '../../utils/proxies';
import { LogExplorerProfileContext } from '../../state_machines/log_explorer_profile';
import { LogExplorerStartDeps } from '../../types';
import { LogExplorerCustomizations } from './types';
export interface CreateLogExplorerArgs {
core: CoreStart;
plugins: LogExplorerStartDeps;
}
export interface LogExplorerStateContainer {
appState?: DiscoverAppState;
logExplorerState?: Partial<LogExplorerProfileContext>;
}
export interface LogExplorerProps {
customizations?: LogExplorerCustomizations;
scopedHistory: ScopedHistory;
state$?: BehaviorSubject<LogExplorerStateContainer>;
controller: LogExplorerController;
}
export const createLogExplorer = ({ core, plugins }: CreateLogExplorerArgs) => {
const {
data,
discover: { DiscoverContainer },
} = plugins;
const overrideServices = {
data: createDataServiceProxy(data),
uiSettings: createUiSettingsServiceProxy(core.uiSettings),
};
return ({ customizations = {}, scopedHistory, state$ }: LogExplorerProps) => {
return ({ scopedHistory, controller }: LogExplorerProps) => {
const logExplorerCustomizations = useMemo(
() => [createLogExplorerProfileCustomizations({ core, customizations, plugins, state$ })],
[customizations, state$]
() => [createLogExplorerProfileCustomizations({ controller, core, plugins })],
[controller]
);
const { urlStateStorage, ...overrideServices } = controller.discoverServices;
return (
<DiscoverContainer
customizationCallbacks={logExplorerCustomizations}
overrideServices={overrideServices}
scopedHistory={scopedHistory}
stateStorageContainer={urlStateStorage}
/>
);
};
};
/**
* Create proxy for the data service, in which session service enablement calls
* are no-ops.
*/
const createDataServiceProxy = (data: DataPublicPluginStart) => {
const noOpEnableStorage = () => {};
const sessionServiceProxy = createPropertyGetProxy(data.search.session, {
enableStorage: () => noOpEnableStorage,
});
const searchServiceProxy = createPropertyGetProxy(data.search, {
session: () => sessionServiceProxy,
});
return createPropertyGetProxy(data, {
search: () => searchServiceProxy,
});
};
/**
* Create proxy for the uiSettings service, in which settings preferences are overwritten
* with custom values
*/
const createUiSettingsServiceProxy = (uiSettings: IUiSettingsClient) => {
const overrides: Record<string, any> = {
[HIDE_ANNOUNCEMENTS]: true,
};
return createPropertyGetProxy(uiSettings, {
get:
() =>
(key, ...args) => {
if (key in overrides) {
return overrides[key];
}
return uiSettings.get(key, ...args);
},
});
};

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataTableRecord } from '@kbn/discover-utils/types';
export type RenderPreviousContent = () => React.ReactNode;
export interface LogExplorerFlyoutContentProps {
doc: DataTableRecord;
}
export type FlyoutRenderContent = (
renderPreviousContent: RenderPreviousContent,
props: LogExplorerFlyoutContentProps
) => React.ReactNode;
export interface LogExplorerCustomizations {
flyout?: {
renderContent?: FlyoutRenderContent;
};
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 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 { DataView } from '@kbn/data-views-plugin/common';
import { FlyoutContentProps as DiscoverFlyoutContentProps } from '@kbn/discover-plugin/public';
import type { DataTableRecord } from '@kbn/discover-utils/types';
export interface LogExplorerCustomizations {
flyout?: {
renderContent?: RenderContentCustomization<LogExplorerFlyoutContentProps>;
};
}
export interface LogExplorerFlyoutContentProps extends DiscoverFlyoutContentProps {
dataView: DataView;
doc: LogDocument;
}
export interface LogDocument extends DataTableRecord {
flattened: {
'@timestamp': string;
'log.level'?: [string];
message?: [string];
'host.name'?: string;
'service.name'?: string;
'trace.id'?: string;
'agent.name'?: string;
'orchestrator.cluster.name'?: string;
'orchestrator.resource.id'?: string;
'cloud.provider'?: string;
'cloud.region'?: string;
'cloud.availability_zone'?: string;
'cloud.project.id'?: string;
'cloud.instance.id'?: string;
'log.file.path'?: string;
'data_stream.namespace': string;
'data_stream.dataset': string;
};
}
export interface FlyoutDoc {
'@timestamp': string;
'log.level'?: string;
message?: string;
'host.name'?: string;
'service.name'?: string;
'trace.id'?: string;
'agent.name'?: string;
'orchestrator.cluster.name'?: string;
'orchestrator.resource.id'?: string;
'cloud.provider'?: string;
'cloud.region'?: string;
'cloud.availability_zone'?: string;
'cloud.project.id'?: string;
'cloud.instance.id'?: string;
'log.file.path'?: string;
'data_stream.namespace': string;
'data_stream.dataset': string;
}
export type RenderContentCustomization<Props> = (
renderPreviousContent: RenderPreviousContent<Props>
) => (props: Props) => React.ReactNode;
export type RenderPreviousContent<Props> = (props: Props) => React.ReactNode;

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreStart } from '@kbn/core/public';
import { getDevToolsOptions } from '@kbn/xstate-utils';
import equal from 'fast-deep-equal';
import { distinctUntilChanged, EMPTY, from, map, shareReplay } from 'rxjs';
import { interpret } from 'xstate';
import { DatasetsService } from '../services/datasets';
import { createLogExplorerControllerStateMachine } from '../state_machines/log_explorer_controller';
import { LogExplorerStartDeps } from '../types';
import { LogExplorerCustomizations } from './controller_customizations';
import { createDataServiceProxy } from './custom_data_service';
import { createUiSettingsServiceProxy } from './custom_ui_settings_service';
import {
createDiscoverMemoryHistory,
createMemoryUrlStateStorage,
} from './custom_url_state_storage';
import { getContextFromPublicState, getPublicStateFromContext } from './public_state';
import {
LogExplorerController,
LogExplorerDiscoverServices,
LogExplorerPublicStateUpdate,
} from './types';
interface Dependencies {
core: CoreStart;
plugins: LogExplorerStartDeps;
}
type InitialState = LogExplorerPublicStateUpdate;
export const createLogExplorerControllerFactory =
({ core, plugins: { data } }: Dependencies) =>
async ({
customizations = {},
initialState,
}: {
customizations?: LogExplorerCustomizations;
initialState?: InitialState;
}): Promise<LogExplorerController> => {
const datasetsClient = new DatasetsService().start({
http: core.http,
}).client;
const customMemoryHistory = createDiscoverMemoryHistory();
const customMemoryUrlStateStorage = createMemoryUrlStateStorage(customMemoryHistory);
const customUiSettings = createUiSettingsServiceProxy(core.uiSettings);
const customData = createDataServiceProxy({
data,
http: core.http,
uiSettings: customUiSettings,
});
const discoverServices: LogExplorerDiscoverServices = {
data: customData,
history: () => customMemoryHistory,
uiSettings: customUiSettings,
filterManager: customData.query.filterManager,
timefilter: customData.query.timefilter.timefilter,
urlStateStorage: customMemoryUrlStateStorage,
};
const initialContext = getContextFromPublicState(initialState ?? {});
const machine = createLogExplorerControllerStateMachine({
datasetsClient,
initialContext,
query: discoverServices.data.query,
toasts: core.notifications.toasts,
});
const service = interpret(machine, {
devTools: getDevToolsOptions(),
});
const logExplorerState$ = from(service).pipe(
map(({ context }) => getPublicStateFromContext(context)),
distinctUntilChanged(equal),
shareReplay(1)
);
return {
actions: {},
customizations,
datasetsClient,
discoverServices,
event$: EMPTY,
service,
state$: logExplorerState$,
stateMachine: machine,
};
};
export type CreateLogExplorerControllerFactory = typeof createLogExplorerControllerFactory;
export type CreateLogExplorerController = ReturnType<typeof createLogExplorerControllerFactory>;

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpStart } from '@kbn/core-http-browser';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { DataPublicPluginStart, NowProvider, QueryService } from '@kbn/data-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { createPropertyGetProxy } from '../utils/proxies';
/**
* Create proxy for the data service, in which session service enablement calls
* are no-ops.
*/
export const createDataServiceProxy = ({
data,
http,
uiSettings,
}: {
data: DataPublicPluginStart;
http: HttpStart;
uiSettings: IUiSettingsClient;
}) => {
/**
* search session
*/
const noOpEnableStorage = () => {};
const sessionServiceProxy = createPropertyGetProxy(data.search.session, {
enableStorage: () => noOpEnableStorage,
});
const searchServiceProxy = createPropertyGetProxy(data.search, {
session: () => sessionServiceProxy,
});
/**
* query
*/
const customStorage = new Storage(localStorage);
const customQueryService = new QueryService();
customQueryService.setup({
nowProvider: new NowProvider(),
storage: customStorage,
uiSettings,
});
const customQuery = customQueryService.start({
http,
storage: customStorage,
uiSettings,
});
/**
* combined
*/
return createPropertyGetProxy(data, {
query: () => customQuery,
search: () => searchServiceProxy,
});
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { HIDE_ANNOUNCEMENTS, MODIFY_COLUMNS_ON_SWITCH } from '@kbn/discover-utils';
import { createPropertyGetProxy } from '../utils/proxies';
/**
* Create proxy for the uiSettings service, in which settings preferences are overwritten
* with custom values
*/
export const createUiSettingsServiceProxy = (uiSettings: IUiSettingsClient) => {
const overrides: Record<string, any> = {
[HIDE_ANNOUNCEMENTS]: true,
[MODIFY_COLUMNS_ON_SWITCH]: false,
};
return createPropertyGetProxy(uiSettings, {
get:
() =>
(key, ...args) => {
if (key in overrides) {
return overrides[key];
}
return uiSettings.get(key, ...args);
},
});
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { createMemoryHistory } from 'history';
import { LogExplorerDiscoverServices } from './types';
type DiscoverHistory = ReturnType<LogExplorerDiscoverServices['history']>;
/**
* Create a MemoryHistory instance. It is initialized with an application state
* object, because Discover radically resets too much when the URL is "empty".
*/
export const createDiscoverMemoryHistory = (): DiscoverHistory =>
createMemoryHistory({
initialEntries: [{ search: `?_a=()` }],
});
/**
* Create a url state storage that's not connected to the real browser location
* to isolate the Discover component from these side-effects.
*/
export const createMemoryUrlStateStorage = (memoryHistory: DiscoverHistory) =>
createKbnUrlStateStorage({
history: memoryHistory,
useHash: false,
useHashQuery: false,
});

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './controller_customizations';
export * from './create_controller';
export * from './provider';
export * from './types';

View file

@ -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.
*/
import type { CreateLogExplorerControllerFactory } from './create_controller';
export const createLogExplorerControllerLazyFactory: CreateLogExplorerControllerFactory =
(dependencies) => async (args) => {
const { createLogExplorerControllerFactory } = await import('./create_controller');
return createLogExplorerControllerFactory(dependencies)(args);
};

View file

@ -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.
*/
import createContainer from 'constate';
import type { LogExplorerController } from './types';
const useLogExplorerController = ({ controller }: { controller: LogExplorerController }) =>
controller;
export const [LogExplorerControllerProvider, useLogExplorerControllerContext] =
createContainer(useLogExplorerController);

View file

@ -0,0 +1,134 @@
/*
* Copyright 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 {
availableControlsPanels,
controlPanelConfigs,
ControlPanels,
hydrateDatasetSelection,
} from '../../common';
import {
DEFAULT_CONTEXT,
LogExplorerControllerContext,
} from '../state_machines/log_explorer_controller';
import {
LogExplorerPublicState,
LogExplorerPublicStateUpdate,
OptionsListControlOption,
} from './types';
export const getPublicStateFromContext = (
context: LogExplorerControllerContext
): LogExplorerPublicState => {
return {
chart: context.chart,
datasetSelection: context.datasetSelection.toPlainSelection(),
grid: context.grid,
filters: context.filters,
query: context.query,
refreshInterval: context.refreshInterval,
time: context.time,
controls: getPublicControlsStateFromControlPanels(context.controlPanels),
};
};
export const getContextFromPublicState = (
publicState: LogExplorerPublicStateUpdate
): LogExplorerControllerContext => ({
...DEFAULT_CONTEXT,
chart: {
...DEFAULT_CONTEXT.chart,
...publicState.chart,
},
controlPanels: getControlPanelsFromPublicControlsState(publicState.controls),
datasetSelection:
publicState.datasetSelection != null
? hydrateDatasetSelection(publicState.datasetSelection)
: DEFAULT_CONTEXT.datasetSelection,
grid: {
...DEFAULT_CONTEXT.grid,
...publicState.grid,
rows: {
...DEFAULT_CONTEXT.grid.rows,
...publicState.grid?.rows,
},
},
filters: publicState.filters ?? DEFAULT_CONTEXT.filters,
query: publicState.query ?? DEFAULT_CONTEXT.query,
refreshInterval: publicState.refreshInterval ?? DEFAULT_CONTEXT.refreshInterval,
time: publicState.time ?? DEFAULT_CONTEXT.time,
});
const getPublicControlsStateFromControlPanels = (
controlPanels: ControlPanels | undefined
): LogExplorerPublicState['controls'] =>
controlPanels != null
? {
...(availableControlsPanels.NAMESPACE in controlPanels
? {
[availableControlsPanels.NAMESPACE]: getOptionsListPublicControlStateFromControlPanel(
controlPanels[availableControlsPanels.NAMESPACE]
),
}
: {}),
}
: {};
const getOptionsListPublicControlStateFromControlPanel = (
optionsListControlPanel: ControlPanels[string]
): OptionsListControlOption => ({
mode: optionsListControlPanel.explicitInput.exclude ? 'exclude' : 'include',
selection: optionsListControlPanel.explicitInput.existsSelected
? { type: 'exists' }
: {
type: 'options',
selectedOptions: optionsListControlPanel.explicitInput.selectedOptions ?? [],
},
});
const getControlPanelsFromPublicControlsState = (
publicControlsState: LogExplorerPublicStateUpdate['controls']
): ControlPanels => {
if (publicControlsState == null) {
return {};
}
const namespacePublicControlState = publicControlsState[availableControlsPanels.NAMESPACE];
return {
...(namespacePublicControlState
? {
[availableControlsPanels.NAMESPACE]: getControlPanelFromOptionsListPublicControlState(
availableControlsPanels.NAMESPACE,
namespacePublicControlState
),
}
: {}),
};
};
const getControlPanelFromOptionsListPublicControlState = (
controlId: string,
publicControlState: OptionsListControlOption
): ControlPanels[string] => {
const defaultControlPanelConfig = controlPanelConfigs[controlId];
return {
...defaultControlPanelConfig,
explicitInput: {
...defaultControlPanelConfig.explicitInput,
exclude: publicControlState.mode === 'exclude',
...(publicControlState.selection.type === 'exists'
? {
existsSelected: true,
}
: {
selectedOptions: publicControlState.selection.selectedOptions,
}),
},
};
};

View file

@ -0,0 +1,73 @@
/*
* Copyright 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 { QueryState } from '@kbn/data-plugin/public';
import { DiscoverContainerProps } from '@kbn/discover-plugin/public';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { Observable } from 'rxjs';
import {
availableControlsPanels,
DatasetSelectionPlain,
DisplayOptions,
PartialDisplayOptions,
} from '../../common';
import { IDatasetsClient } from '../services/datasets';
import {
LogExplorerControllerStateMachine,
LogExplorerControllerStateService,
} from '../state_machines/log_explorer_controller';
import { LogExplorerCustomizations } from './controller_customizations';
export interface LogExplorerController {
actions: {};
customizations: LogExplorerCustomizations;
datasetsClient: IDatasetsClient;
discoverServices: LogExplorerDiscoverServices;
event$: Observable<LogExplorerPublicEvent>;
service: LogExplorerControllerStateService;
state$: Observable<LogExplorerPublicState>;
stateMachine: LogExplorerControllerStateMachine;
}
export type LogExplorerDiscoverServices = Pick<
Required<DiscoverContainerProps['overrideServices']>,
'data' | 'filterManager' | 'timefilter' | 'uiSettings' | 'history'
> & {
urlStateStorage: IKbnUrlStateStorage;
};
export interface OptionsListControlOption {
mode: 'include' | 'exclude';
selection:
| {
type: 'options';
selectedOptions: string[];
}
| {
type: 'exists';
};
}
export interface ControlOptions {
[availableControlsPanels.NAMESPACE]?: OptionsListControlOption;
}
// we might want to wrap this into an object that has a "state value" laster
export type LogExplorerPublicState = QueryState &
DisplayOptions & {
controls: ControlOptions;
datasetSelection: DatasetSelectionPlain;
};
export type LogExplorerPublicStateUpdate = QueryState &
PartialDisplayOptions & {
controls?: ControlOptions;
datasetSelection?: DatasetSelectionPlain;
};
// a placeholder for now
export type LogExplorerPublicEvent = never;

View file

@ -10,21 +10,21 @@ import { Query } from '@kbn/es-query';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { useControlPanels } from '../hooks/use_control_panels';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { LogExplorerControllerStateService } from '../state_machines/log_explorer_controller';
const DATASET_FILTERS_CUSTOMIZATION_ID = 'datasetFiltersCustomization';
interface CustomDatasetFiltersProps {
logExplorerProfileStateService: LogExplorerProfileStateService;
logExplorerControllerStateService: LogExplorerControllerStateService;
data: DataPublicPluginStart;
}
const CustomDatasetFilters = ({
logExplorerProfileStateService,
logExplorerControllerStateService,
data,
}: CustomDatasetFiltersProps) => {
const { getInitialInput, setControlGroupAPI, query, filters, timeRange } = useControlPanels(
logExplorerProfileStateService,
logExplorerControllerStateService,
data
);

View file

@ -15,15 +15,15 @@ import { DataViewsProvider, useDataViewsContext } from '../hooks/use_data_views'
import { useEsql } from '../hooks/use_esql';
import { IntegrationsProvider, useIntegrationsContext } from '../hooks/use_integrations';
import { IDatasetsClient } from '../services/datasets';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { LogExplorerControllerStateService } from '../state_machines/log_explorer_controller';
interface CustomDatasetSelectorProps {
logExplorerProfileStateService: LogExplorerProfileStateService;
logExplorerControllerStateService: LogExplorerControllerStateService;
}
export const CustomDatasetSelector = withProviders(({ logExplorerProfileStateService }) => {
export const CustomDatasetSelector = withProviders(({ logExplorerControllerStateService }) => {
const { datasetSelection, handleDatasetSelectionChange } = useDatasetSelection(
logExplorerProfileStateService
logExplorerControllerStateService
);
const {
@ -111,13 +111,13 @@ function withProviders(Component: React.FunctionComponent<CustomDatasetSelectorP
datasetsClient,
dataViews,
discover,
logExplorerProfileStateService,
logExplorerControllerStateService,
}: CustomDatasetSelectorBuilderProps) {
return (
<IntegrationsProvider datasetsClient={datasetsClient}>
<DatasetsProvider datasetsClient={datasetsClient}>
<DataViewsProvider dataViewsService={dataViews} discoverService={discover}>
<Component logExplorerProfileStateService={logExplorerProfileStateService} />
<Component logExplorerControllerStateService={logExplorerControllerStateService} />
</DataViewsProvider>
</DatasetsProvider>
</IntegrationsProvider>

View file

@ -5,46 +5,41 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import { FlyoutDetail } from '../components/flyout_detail/flyout_detail';
import { FlyoutProps } from '../components/flyout_detail';
import { useLogExplorerCustomizationsContext } from '../hooks/use_log_explorer_customizations';
import { LogExplorerFlyoutContentProps } from '../components/flyout_detail';
import { useLogExplorerControllerContext } from '../controller';
export const CustomFlyoutContent = ({
actions,
dataView,
doc,
renderDefaultContent,
}: FlyoutProps) => {
const { flyout } = useLogExplorerCustomizationsContext();
export const CustomFlyoutContent = (props: LogExplorerFlyoutContentProps) => {
const {
customizations: { flyout },
} = useLogExplorerControllerContext();
const renderPreviousContent = useCallback(
() => (
<>
{/* Apply custom Log Explorer detail */}
<EuiFlexItem>
<FlyoutDetail actions={actions} dataView={dataView} doc={doc} />
</EuiFlexItem>
</>
),
[actions, dataView, doc]
const renderCustomizedContent = useMemo(
() => flyout?.renderContent?.(renderContent) ?? renderContent,
[flyout]
);
const content = flyout?.renderContent
? flyout?.renderContent(renderPreviousContent, { doc })
: renderPreviousContent();
return (
<EuiFlexGroup direction="column" gutterSize="m">
{/* Apply custom Log Explorer detail */}
{content}
{renderCustomizedContent(props)}
{/* Restore default content */}
<EuiHorizontalRule margin="xs" />
<EuiFlexItem>{renderDefaultContent()}</EuiFlexItem>
<EuiFlexItem>{props.renderDefaultContent()}</EuiFlexItem>
</EuiFlexGroup>
);
};
const renderContent = ({ actions, dataView, doc }: LogExplorerFlyoutContentProps) => (
<>
{/* Apply custom Log Explorer detail */}
<EuiFlexItem>
<FlyoutDetail actions={actions} dataView={dataView} doc={doc} />
</EuiFlexItem>
</>
);
// eslint-disable-next-line import/no-default-export
export default CustomFlyoutContent;

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
export const createCustomSearchBar = ({
navigation,
data,
unifiedSearch,
}: {
data: DataPublicPluginStart;
navigation: NavigationPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}) => {
const {
ui: { createTopNavWithCustomContext },
} = navigation;
const {
ui: { getCustomSearchBar },
} = unifiedSearch;
const CustomSearchBar = getCustomSearchBar(data);
const customUnifiedSearch = {
...unifiedSearch,
ui: {
...unifiedSearch.ui,
SearchBar: CustomSearchBar,
AggregateQuerySearchBar: CustomSearchBar,
},
};
return createTopNavWithCustomContext(customUnifiedSearch);
};

View file

@ -4,19 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { CoreStart } from '@kbn/core/public';
import { CustomizationCallback, DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { CustomizationCallback } from '@kbn/discover-plugin/public';
import { i18n } from '@kbn/i18n';
import React from 'react';
import useObservable from 'react-use/lib/useObservable';
import { combineLatest, from, map, Subscription, type BehaviorSubject } from 'rxjs';
import { LogExplorerStateContainer } from '../components/log_explorer';
import { LogExplorerCustomizations } from '../components/log_explorer/types';
import { LogExplorerCustomizationsProvider } from '../hooks/use_log_explorer_customizations';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { LogExplorerStartDeps } from '../types';
import { waitFor } from 'xstate/lib/waitFor';
import type { LogExplorerController } from '../controller';
import { LogExplorerControllerProvider } from '../controller/provider';
import type { LogExplorerStartDeps } from '../types';
import { dynamic } from '../utils/dynamic';
import { useKibanaContextForPluginProvider } from '../utils/use_kibana';
import { createCustomSearchBar } from './custom_search_bar';
const LazyCustomDatasetFilters = dynamic(() => import('./custom_dataset_filters'));
const LazyCustomDatasetSelector = dynamic(() => import('./custom_dataset_selector'));
@ -24,56 +24,31 @@ const LazyCustomFlyoutContent = dynamic(() => import('./custom_flyout_content'))
export interface CreateLogExplorerProfileCustomizationsDeps {
core: CoreStart;
customizations: LogExplorerCustomizations;
plugins: LogExplorerStartDeps;
state$?: BehaviorSubject<LogExplorerStateContainer>;
controller: LogExplorerController;
}
export const createLogExplorerProfileCustomizations =
({
core,
customizations: logExplorerCustomizations,
plugins,
state$,
controller,
}: CreateLogExplorerProfileCustomizationsDeps): CustomizationCallback =>
async ({ customizations, stateContainer }) => {
const { data, dataViews, discover } = plugins;
// Lazy load dependencies
const datasetServiceModuleLoadable = import('../services/datasets');
const logExplorerMachineModuleLoadable = import('../state_machines/log_explorer_profile');
const { discoverServices, service } = controller;
const pluginsWithOverrides = {
...plugins,
...discoverServices,
};
const { data, dataViews, discover, navigation, unifiedSearch } = pluginsWithOverrides;
const [{ DatasetsService }, { initializeLogExplorerProfileStateService, waitForState }] =
await Promise.all([datasetServiceModuleLoadable, logExplorerMachineModuleLoadable]);
const datasetsClient = new DatasetsService().start({
http: core.http,
}).client;
const logExplorerProfileStateService = initializeLogExplorerProfileStateService({
datasetsClient,
stateContainer,
toasts: core.notifications.toasts,
});
service.send('RECEIVED_STATE_CONTAINER', { discoverStateContainer: stateContainer });
/**
* Wait for the machine to be fully initialized to set the restored selection
* create the DataView and set it in the stateContainer from Discover
*/
await waitForState(logExplorerProfileStateService, 'initialized');
/**
* Subscribe the state$ BehaviorSubject when the consumer app wants to react to state changes.
* It emits a combined state of:
* - log explorer state machine context
* - appState from the discover stateContainer
*/
let stateSubscription: Subscription;
if (state$) {
stateSubscription = createStateUpdater({
logExplorerProfileStateService,
stateContainer,
}).subscribe(state$);
}
await waitFor(service, (state) => state.matches('initialized'), { timeout: 30000 });
/**
* Replace the DataViewPicker with a custom `DatasetSelector` to pick integrations streams
@ -87,20 +62,22 @@ export const createLogExplorerProfileCustomizations =
return (
<KibanaContextProviderForPlugin>
<LazyCustomDatasetSelector
datasetsClient={datasetsClient}
datasetsClient={controller.datasetsClient}
dataViews={dataViews}
discover={discover}
logExplorerProfileStateService={logExplorerProfileStateService}
logExplorerControllerStateService={service}
/>
</KibanaContextProviderForPlugin>
);
},
PrependFilterBar: () => (
<LazyCustomDatasetFilters
logExplorerProfileStateService={logExplorerProfileStateService}
data={data}
/>
<LazyCustomDatasetFilters logExplorerControllerStateService={service} data={data} />
),
CustomSearchBar: createCustomSearchBar({
data,
navigation,
unifiedSearch,
}),
});
/**
@ -143,32 +120,13 @@ export const createLogExplorerProfileCustomizations =
return (
<KibanaContextProviderForPlugin>
<LogExplorerCustomizationsProvider value={logExplorerCustomizations}>
<LogExplorerControllerProvider controller={controller}>
<LazyCustomFlyoutContent {...props} dataView={internalState.dataView} />
</LogExplorerCustomizationsProvider>
</LogExplorerControllerProvider>
</KibanaContextProviderForPlugin>
);
},
});
return () => {
if (stateSubscription) {
stateSubscription.unsubscribe();
}
};
return () => {};
};
const createStateUpdater = ({
logExplorerProfileStateService,
stateContainer,
}: {
logExplorerProfileStateService: LogExplorerProfileStateService;
stateContainer: DiscoverStateContainer;
}) => {
return combineLatest([from(logExplorerProfileStateService), stateContainer.appState.state$]).pipe(
map(([logExplorerState, appState]) => ({
logExplorerState: logExplorerState.context,
appState,
}))
);
};

View file

@ -13,16 +13,16 @@ import { Query, TimeRange } from '@kbn/es-query';
import { useQuerySubscriber } from '@kbn/unified-field-list';
import { useSelector } from '@xstate/react';
import { useCallback } from 'react';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { LogExplorerControllerStateService } from '../state_machines/log_explorer_controller';
export const useControlPanels = (
logExplorerProfileStateService: LogExplorerProfileStateService,
logExplorerControllerStateService: LogExplorerControllerStateService,
data: DataPublicPluginStart
) => {
const { query, filters, fromDate, toDate } = useQuerySubscriber({ data });
const timeRange: TimeRange = { from: fromDate!, to: toDate! };
const controlPanels = useSelector(logExplorerProfileStateService, (state) => {
const controlPanels = useSelector(logExplorerControllerStateService, (state) => {
if (!('controlPanels' in state.context)) return;
return state.context.controlPanels;
});
@ -45,12 +45,12 @@ export const useControlPanels = (
const setControlGroupAPI = useCallback(
(controlGroupAPI: ControlGroupAPI) => {
logExplorerProfileStateService.send({
logExplorerControllerStateService.send({
type: 'INITIALIZE_CONTROL_GROUP_API',
controlGroupAPI,
});
},
[logExplorerProfileStateService]
[logExplorerControllerStateService]
);
return { getInitialInput, setControlGroupAPI, query, filters, timeRange };

View file

@ -8,20 +8,20 @@
import { useSelector } from '@xstate/react';
import { useCallback } from 'react';
import { DatasetSelectionChange } from '../../common/dataset_selection';
import { LogExplorerProfileStateService } from '../state_machines/log_explorer_profile';
import { LogExplorerControllerStateService } from '../state_machines/log_explorer_controller';
export const useDatasetSelection = (
logExplorerProfileStateService: LogExplorerProfileStateService
logExplorerControllerStateService: LogExplorerControllerStateService
) => {
const datasetSelection = useSelector(logExplorerProfileStateService, (state) => {
const datasetSelection = useSelector(logExplorerControllerStateService, (state) => {
return state.context.datasetSelection;
});
const handleDatasetSelectionChange: DatasetSelectionChange = useCallback(
(data) => {
logExplorerProfileStateService.send({ type: 'UPDATE_DATASET_SELECTION', data });
logExplorerControllerStateService.send({ type: 'UPDATE_DATASET_SELECTION', data });
},
[logExplorerProfileStateService]
[logExplorerControllerStateService]
);
return { datasetSelection, handleDatasetSelectionChange };

View file

@ -1,17 +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 createContainer from 'constate';
import { LogExplorerCustomizations } from '../components/log_explorer/types';
interface UseLogExplorerCustomizationsDeps {
value: LogExplorerCustomizations;
}
const useLogExplorerCustomizations = ({ value }: UseLogExplorerCustomizationsDeps) => value;
export const [LogExplorerCustomizationsProvider, useLogExplorerCustomizationsContext] =
createContainer(useLogExplorerCustomizations);

View file

@ -8,12 +8,20 @@
import type { PluginInitializerContext } from '@kbn/core/public';
import type { LogExplorerConfig } from '../common/plugin_config';
import { LogExplorerPlugin } from './plugin';
export type { LogExplorerPluginSetup, LogExplorerPluginStart } from './types';
export type { LogExplorerStateContainer } from './components/log_explorer';
export type {
CreateLogExplorerController,
LogExplorerController,
LogExplorerCustomizations,
LogExplorerFlyoutContentProps,
} from './components/log_explorer/types';
LogExplorerPublicState,
LogExplorerPublicStateUpdate,
} from './controller';
export type { LogExplorerControllerContext } from './state_machines/log_explorer_controller';
export type { LogExplorerPluginSetup, LogExplorerPluginStart } from './types';
export {
getDiscoverColumnsFromDisplayOptions,
getDiscoverGridFromDisplayOptions,
} from './utils/convert_discover_app_state';
export function plugin(context: PluginInitializerContext<LogExplorerConfig>) {
return new LogExplorerPlugin(context);

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { DISCOVER_APP_LOCATOR, DiscoverAppLocatorParams } from '@kbn/discover-plugin/common';
import { LogExplorerLocatorDefinition, LogExplorerLocators } from '../common/locators';
import { createLogExplorer } from './components/log_explorer';
import {
import { createLogExplorerControllerLazyFactory } from './controller/lazy_create_controller';
import type {
LogExplorerPluginSetup,
LogExplorerPluginStart,
LogExplorerSetupDeps,
@ -48,8 +49,14 @@ export class LogExplorerPlugin implements Plugin<LogExplorerPluginSetup, LogExpl
plugins,
});
const createLogExplorerController = createLogExplorerControllerLazyFactory({
core,
plugins,
});
return {
LogExplorer,
createLogExplorerController,
};
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ROWS_HEIGHT_OPTIONS } from '@kbn/unified-data-table';
import {
DEFAULT_COLUMNS,
DEFAULT_ROWS_PER_PAGE,
LOG_LEVEL_FIELD,
} from '../../../../common/constants';
import { AllDatasetSelection } from '../../../../common/dataset_selection';
import { DefaultLogExplorerControllerState } from './types';
export const DEFAULT_CONTEXT: DefaultLogExplorerControllerState = {
datasetSelection: AllDatasetSelection.create(),
grid: {
columns: DEFAULT_COLUMNS,
rows: {
rowHeight: ROWS_HEIGHT_OPTIONS.single,
rowsPerPage: DEFAULT_ROWS_PER_PAGE,
},
},
chart: {
breakdownField: LOG_LEVEL_FIELD,
},
filters: [],
query: {
language: 'kuery',
query: '',
},
refreshInterval: {
pause: true,
value: 60000,
},
time: {
mode: 'relative',
from: 'now-15m/m',
to: 'now',
},
};

View file

@ -5,6 +5,6 @@
* 2.0.
*/
export * from './defaults';
export * from './state_machine';
export * from './types';
export * from './utils';

View file

@ -0,0 +1,134 @@
/*
* Copyright 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 { DataView } from '@kbn/data-views-plugin/public';
import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import deepEqual from 'fast-deep-equal';
import { mapValues, pick } from 'lodash';
import { InvokeCreator } from 'xstate';
import {
availableControlPanelFields,
controlPanelConfigs,
ControlPanelRT,
ControlPanels,
} from '../../../../../common';
import { LogExplorerControllerContext, LogExplorerControllerEvent } from '../types';
export const initializeControlPanels =
(): InvokeCreator<LogExplorerControllerContext, LogExplorerControllerEvent> =>
async (context) => {
if (!('discoverStateContainer' in context)) return;
return context.controlPanels
? constructControlPanelsWithDataViewId(context.discoverStateContainer, context.controlPanels)
: undefined;
};
export const subscribeControlGroup =
(): InvokeCreator<LogExplorerControllerContext, LogExplorerControllerEvent> =>
(context) =>
(send) => {
if (!('controlGroupAPI' in context)) return;
if (!('discoverStateContainer' in context)) return;
const { discoverStateContainer } = context;
const filtersSubscription = context.controlGroupAPI.onFiltersPublished$.subscribe(
(newFilters) => {
discoverStateContainer.internalState.transitions.setCustomFilters(newFilters);
discoverStateContainer.actions.fetchData();
}
);
const inputSubscription = context.controlGroupAPI.getInput$().subscribe(({ panels }) => {
if (!deepEqual(panels, context.controlPanels)) {
send({ type: 'UPDATE_CONTROL_PANELS', controlPanels: panels });
}
});
return () => {
filtersSubscription.unsubscribe();
inputSubscription.unsubscribe();
};
};
export const updateControlPanels =
(): InvokeCreator<LogExplorerControllerContext, LogExplorerControllerEvent> =>
async (context, event) => {
if (!('controlGroupAPI' in context)) return;
if (!('discoverStateContainer' in context)) return;
const { discoverStateContainer } = context;
const newControlPanels =
('controlPanels' in event && event.controlPanels) || context.controlPanels;
if (!newControlPanels) return undefined;
const controlPanelsWithId = constructControlPanelsWithDataViewId(
discoverStateContainer,
newControlPanels!
);
context.controlGroupAPI.updateInput({ panels: controlPanelsWithId });
return controlPanelsWithId;
};
const constructControlPanelsWithDataViewId = (
stateContainer: DiscoverStateContainer,
newControlPanels: ControlPanels
) => {
const dataView = stateContainer.internalState.getState().dataView!;
const validatedControlPanels = isValidState(newControlPanels)
? newControlPanels
: getVisibleControlPanelsConfig(dataView);
const controlsPanelsWithId = mergeDefaultPanelsWithControlPanels(
dataView,
validatedControlPanels!
);
return controlsPanelsWithId;
};
const isValidState = (state: ControlPanels | undefined | null): boolean => {
return Object.keys(state ?? {}).length > 0 && ControlPanelRT.is(state);
};
const getVisibleControlPanels = (dataView: DataView | undefined) =>
availableControlPanelFields.filter(
(panelKey) => dataView?.fields.getByName(panelKey) !== undefined
);
export const getVisibleControlPanelsConfig = (dataView?: DataView) => {
return getVisibleControlPanels(dataView).reduce((panelsMap, panelKey) => {
const config = controlPanelConfigs[panelKey];
return { ...panelsMap, [panelKey]: config };
}, {} as ControlPanels);
};
const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId: string = '') => {
return mapValues(controlPanels, (controlPanelConfig) => ({
...controlPanelConfig,
explicitInput: { ...controlPanelConfig.explicitInput, dataViewId },
}));
};
const mergeDefaultPanelsWithControlPanels = (dataView: DataView, urlPanels: ControlPanels) => {
// Get default panel configs from existing fields in data view
const visiblePanels = getVisibleControlPanelsConfig(dataView);
// Get list of panel which can be overridden to avoid merging additional config from url
const existingKeys = Object.keys(visiblePanels);
const controlPanelsToOverride = pick(urlPanels, existingKeys);
// Merge default and existing configs and add dataView.id to each of them
return addDataViewIdToControlPanels(
{ ...visiblePanels, ...controlPanelsToOverride },
dataView.id
);
};

View file

@ -5,23 +5,15 @@
* 2.0.
*/
import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import { InvokeCreator } from 'xstate';
import { LogExplorerProfileContext, LogExplorerProfileEvent } from './types';
interface LogExplorerProfileDataViewStateDependencies {
stateContainer: DiscoverStateContainer;
}
import { LogExplorerControllerContext, LogExplorerControllerEvent } from '../types';
export const createAndSetDataView =
({
stateContainer,
}: LogExplorerProfileDataViewStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
(): InvokeCreator<LogExplorerControllerContext, LogExplorerControllerEvent> =>
async (context) => {
const dataView = await stateContainer.actions.createAndAppendAdHocDataView(
if (!('discoverStateContainer' in context)) return;
const { discoverStateContainer } = context;
const dataView = await discoverStateContainer.actions.createAndAppendAdHocDataView(
context.datasetSelection.toDataviewSpec()
);
/**
@ -32,5 +24,5 @@ export const createAndSetDataView =
* to the existing one or the default logs-*.
* We set explicitly the data view here to be used when restoring the data view on the initial load.
*/
stateContainer.actions.setDataView(dataView);
discoverStateContainer.actions.setDataView(dataView);
};

View file

@ -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 { isEmpty } from 'lodash';
import { ActionFunction, actions, InvokeCallback } from 'xstate';
import {
getChartDisplayOptionsFromDiscoverAppState,
getDiscoverAppStateFromContext,
getGridColumnDisplayOptionsFromDiscoverAppState,
getGridRowsDisplayOptionsFromDiscoverAppState,
getQueryStateFromDiscoverAppState,
} from '../../../../utils/convert_discover_app_state';
import { LogExplorerControllerContext, LogExplorerControllerEvent } from '../types';
export const subscribeToDiscoverState =
() =>
(
context: LogExplorerControllerContext
): InvokeCallback<LogExplorerControllerEvent, LogExplorerControllerEvent> =>
(send, onEvent) => {
if (!('discoverStateContainer' in context)) {
throw new Error('Failed to subscribe to the Discover state: no state container in context.');
}
const { appState } = context.discoverStateContainer;
const subscription = appState.state$.subscribe({
next: (newAppState) => {
if (isEmpty(newAppState)) {
return;
}
send({
type: 'RECEIVE_DISCOVER_APP_STATE',
appState: newAppState,
});
},
});
return () => {
subscription.unsubscribe();
};
};
export const updateContextFromDiscoverAppState = actions.assign<
LogExplorerControllerContext,
LogExplorerControllerEvent
>((context, event) => {
if ('appState' in event && event.type === 'RECEIVE_DISCOVER_APP_STATE') {
return {
chart: {
...context.chart,
...getChartDisplayOptionsFromDiscoverAppState(event.appState),
},
grid: {
columns:
getGridColumnDisplayOptionsFromDiscoverAppState(event.appState) ?? context.grid.columns,
rows: {
...context.grid.rows,
...getGridRowsDisplayOptionsFromDiscoverAppState(event.appState),
},
},
...getQueryStateFromDiscoverAppState(event.appState),
};
}
return {};
});
export const updateDiscoverAppStateFromContext: ActionFunction<
LogExplorerControllerContext,
LogExplorerControllerEvent
> = (context, _event) => {
if (!('discoverStateContainer' in context)) {
return;
}
context.discoverStateContainer.appState.update(getDiscoverAppStateFromContext(context));
};

View file

@ -6,21 +6,21 @@
*/
import { InvokeCreator } from 'xstate';
import { Dataset } from '../../../../common/datasets';
import { SingleDatasetSelection } from '../../../../common/dataset_selection';
import { IDatasetsClient } from '../../../services/datasets';
import { LogExplorerProfileContext, LogExplorerProfileEvent } from './types';
import { Dataset } from '../../../../../common/datasets';
import { SingleDatasetSelection } from '../../../../../common/dataset_selection';
import { IDatasetsClient } from '../../../../services/datasets';
import { LogExplorerControllerContext, LogExplorerControllerEvent } from '../types';
interface LogExplorerProfileUrlStateDependencies {
interface LogExplorerControllerUrlStateDependencies {
datasetsClient: IDatasetsClient;
}
export const validateSelection =
({
datasetsClient,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
}: LogExplorerControllerUrlStateDependencies): InvokeCreator<
LogExplorerControllerContext,
LogExplorerControllerEvent
> =>
(context) =>
async (send) => {

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { QueryStart } from '@kbn/data-plugin/public';
import { map, merge, Observable } from 'rxjs';
import { ActionFunction, actions } from 'xstate';
import type { LogExplorerControllerContext, LogExplorerControllerEvent } from '../types';
export const subscribeToTimefilterService =
(query: QueryStart) => (): Observable<LogExplorerControllerEvent> => {
const {
timefilter: { timefilter },
} = query;
const time$ = timefilter.getTimeUpdate$().pipe(
map(
(): LogExplorerControllerEvent => ({
type: 'RECEIVE_TIMEFILTER_TIME',
time: timefilter.getTime(),
})
)
);
const refreshInterval$ = timefilter.getRefreshIntervalUpdate$().pipe(
map(
(): LogExplorerControllerEvent => ({
type: 'RECEIVE_TIMEFILTER_REFRESH_INTERVAL',
refreshInterval: timefilter.getRefreshInterval(),
})
)
);
return merge(time$, refreshInterval$);
};
export const updateContextFromTimefilter = actions.assign<
LogExplorerControllerContext,
LogExplorerControllerEvent
>((context, event) => {
if (event.type === 'RECEIVE_TIMEFILTER_TIME' && 'time' in event) {
return {
time: event.time,
};
}
if (event.type === 'RECEIVE_TIMEFILTER_REFRESH_INTERVAL' && 'refreshInterval' in event) {
return {
refreshInterval: event.refreshInterval,
};
}
return {};
});
export const updateTimefilterFromContext =
(query: QueryStart): ActionFunction<LogExplorerControllerContext, LogExplorerControllerEvent> =>
(context, _event) => {
if (context.time != null) {
query.timefilter.timefilter.setTime(context.time);
}
if (context.refreshInterval != null) {
query.timefilter.timefilter.setRefreshInterval(context.refreshInterval);
}
};

View file

@ -0,0 +1,298 @@
/*
* Copyright 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 { IToasts } from '@kbn/core/public';
import { QueryStart } from '@kbn/data-plugin/public';
import { actions, createMachine, interpret, InterpreterFrom, raise } from 'xstate';
import { ControlPanelRT } from '../../../../common/control_panels';
import { isDatasetSelection } from '../../../../common/dataset_selection';
import { IDatasetsClient } from '../../../services/datasets';
import { DEFAULT_CONTEXT } from './defaults';
import {
createCreateDataViewFailedNotifier,
createDatasetSelectionRestoreFailedNotifier,
} from './notifications';
import {
initializeControlPanels,
subscribeControlGroup,
updateControlPanels,
} from './services/control_panels';
import { createAndSetDataView } from './services/data_view_service';
import {
subscribeToDiscoverState,
updateContextFromDiscoverAppState,
updateDiscoverAppStateFromContext,
} from './services/discover_service';
import { validateSelection } from './services/selection_service';
import {
subscribeToTimefilterService,
updateContextFromTimefilter,
updateTimefilterFromContext,
} from './services/timefilter_service';
import {
LogExplorerControllerContext,
LogExplorerControllerEvent,
LogExplorerControllerTypeState,
} from './types';
export const createPureLogExplorerControllerStateMachine = (
initialContext: LogExplorerControllerContext
) =>
/** @xstate-layout N4IgpgJg5mDOIC5QBkD2UCiAPADgG1QCcxCBhVAOwBdDU88SA6AVwoEt2q2BDPNgL0gBiAEoZSGAJIA1DABEA+gGUAKgEEVGBaQDyAOXWS9GEQG0ADAF1EoHKlhsulGyCyIAtACYAbIwDsAIzmAJwAzH4ArAA0IACeiJ5+AByMwZ6hnokALD6e5kneAL6FMWiYuATEZJQ0dAyEjByOPHz8HFBy3FTc0mxgAO5CEJRgjRQAbqgA1qNl2PhEJOTUtPRMTVy8Au2d3b0DCByTAMZdbJQWlpcudg5OFC5uCAGejObentFxiBHBwYyeLIBX5JfLeX7BCLFUroeaVJY1Vb1MbNLZtCgdLo9PqDEi0Br4LoAMyIAFtGHMKotqis6utOC1thjdtiDkdUKd7pdrkgQLdms5eU9sowAqEkn8IuLIUDvN4YvEEFksqE3hFQaEglkperPNCQJSFlVlrU1g0Noz0VATasAArcChgPCwIYjMaTGYU2FU42Iunmhlo9o2uj2x3Ow4TDlnC5WHm2ewCh5Cjy+YLeJJ+cGfTMS96fBWIJJJAKMULa8wZbzmPyfbxZfWG+E003Ii1BjEhvBhp0uvFERiEqgkwjkpvUrttwOtYN+7sO3uRk4xijcqw3RP3R4JPyqgJZDM14sygJywsIXf-TV+LLBEIFXc5Rveo0I2lmlGbVrCMQSGRaORJCUXRZBEBQ1FtW1lHUTR4z5TdzmTUBhVCdNRX3QEQmSbUknPCJAn8IJawiTxgm1dIoRKA0X2bSd6VRb8IFEcQpFkBQAEUAFUTAATWgjQMDg-ktxTBBMIiRhiyzcUpSzSJz0fRhvACPws0rEsIgietn3KV8WyReivwESBGAgLFYDAKglCdMBjnuRhxi2MyuAxayGDsxChGQIDND0BQVB0bQAAk1D0ABxDAlCEhDBWQxAyN8W8wkCJIfDSIFzzlLJ0KyfIwmVAIklCUIdLhCc5ynBjjIgUzzMstzbPsxy+Gc9oGo8yghE4205AEhRevUJQMBUZQMGQcQVEkfRoruRDtwQB9RXBVCIn3cwQnBc8S1VcEVQ0yIPgCAJSp9N9W0My0TOc7gLKsmyOooBynLOVz7vuIQBrUIaRqG8bSEm-QFDEVQdDEBQADE1EkZBOLEGak3m8FS1COUAmCNS5Wvc80hSYtPFS4rvDvJJNJOvS6IDKrBBq67bva+y2AgBgup6vrPu+0a-oBvR4ZEuLnh8FJCaK6t8drcstrFJSIj24EDs8I6ydoiqLrRK66ru9yGaZsAPo0L7hs5iapr84GArByHodhwT115YS5tE4FwUYFUifMdJlQid2sklnaZfFOWtIV46qPHX130qozqdq7o6bexCWBwVrmSxfZBmGR13WmWYaPKiPVcYmObvq+PKET5PMT2HEl2jLk41thNZti1xEEK4qlKBPIUfVHJQmxtCIVS9MvewopQ9z8PzspqP1djkutYT5gk5eyvWVxQh8UHPBiTJL1dOV-Pp8ummNfpxfl5c1e05rzlELXaw7ZipCW+edMUi9kIwh77xNXPIJITeGkfKy0ayniVnnKen5j6MGOHOMKtAl6wBYOwac1UhBGEkJNNQ3kABaWhdAGBEDoZACgwpEO6uBW0kheYO35nmAEgI0afGVAHXC3xnjlgkrlSUwQmHuzHjCfeECDJHzVjVWB754GoEQY0HWet1AKGkJIDAAB1BQ3UBryBoc3J4LwDwAk1Ojcih5wh-ylNlIqljEj4wopRQRZVJ4iKgWImBcCEE4CQYzZmGi+oEJUEQkhtpQpjSig3eCTdn66JRn4VImRqx+DCLwzIAQzFkRdvuDMqk-D5FvHY6iQjHH+mcYXCRpopEyKXhXLsPZnSukzuyT0YczpOPbCUtx0iPHlxXtUhcEZ2S31jFcMJ9sdGt3RuYN4GRAShG-r-dhKkMzSzvFpXcRNEgh3sadfSRTWnVVcZI9xSDKndLnDUvsG8BxDhHGOCezSdmoOjqU1Y5TOnHMvj08MsAb4rnvhuCJ80VJpKSNqLIoJ+FpBvH-QIEl1rlhJumImaRKJUQoKgCAcAXBNO2WaP5CNRLuBvIREI4QviKgSf4CUwQSYkQPOkMi4DCkflYLs6muK+YvxyH-bwMT8Z-D5fyv4Aj8kOLuR+FlOxU44jZbQl+7hSye01AWdhyoJnYV+DM7UMzUoMtFZHS0s53xnOlaMhAXgJlZCzEHJK-KCgKX3GWFSt57zcvLHqceBTdUF2qsayJPwSKpA+JWBWvwgTanPKhV4CteEkTlCEHIwQdXYr1S42m89GoypGb6hA1ZVQo2UujasmMVJ4QIoENIGZgS5MSImimxS9mps1umsuzVGYrzPs3TN81Ah7hybC3aeQ-CZV+ACJhqUf5BoKDWlWojC4NvbY9LxYAfUAu1DyksCSSb1nMAeNhioso5TyuWIERUSrupFUmr10c52l0em8iVVcBjLsdqeGJwJULig3WRIq-dspVgjcCI8B4p2HzrY89piCn382rBM9MXdg1kX3KS1unxX0oyBO+9U6M8lYtrSykyTy6AvKOSgqmkBIMv3rCkWDQbgQIbDfMlGKQxTZlPGkfhwHIF4fEeBzpi7yO6KlP8UiylbzZLyPjP+R6lLdwVms9jZ6tm4YefhnjRyL4GtNEax+-zHZYQBH8EInxNLrsHfMt2bxKygt+HKP4nxijFCAA */
createMachine<
LogExplorerControllerContext,
LogExplorerControllerEvent,
LogExplorerControllerTypeState
>(
{
context: initialContext,
predictableActionArguments: true,
id: 'LogExplorerController',
initial: 'uninitialized',
states: {
uninitialized: {
on: {
RECEIVED_STATE_CONTAINER: {
target: 'initializingDataView',
actions: ['storeDiscoverStateContainer'],
},
},
},
initializingDataView: {
invoke: {
src: 'createDataView',
onDone: {
target: 'initializingControlPanels',
actions: ['updateDiscoverAppStateFromContext', 'updateTimefilterFromContext'],
},
onError: {
target: 'initialized',
actions: [
'notifyCreateDataViewFailed',
'updateDiscoverAppStateFromContext',
'updateTimefilterFromContext',
],
},
},
},
initializingControlPanels: {
invoke: {
src: 'initializeControlPanels',
onDone: {
target: 'initialized',
actions: ['storeControlPanels'],
},
onError: {
target: 'initialized',
},
},
},
initialized: {
type: 'parallel',
invoke: [
{
src: 'discoverStateService',
id: 'discoverStateService',
},
{
src: 'timefilterService',
id: 'timefilterService',
},
],
states: {
datasetSelection: {
initial: 'validatingSelection',
states: {
validatingSelection: {
invoke: {
src: 'validateSelection',
},
on: {
LISTEN_TO_CHANGES: {
target: 'idle',
},
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
},
idle: {
on: {
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
},
updatingDataView: {
invoke: {
src: 'createDataView',
onDone: {
target: 'idle',
actions: ['notifyDataViewUpdate'],
},
onError: {
target: 'idle',
actions: ['notifyCreateDataViewFailed'],
},
},
},
},
},
controlGroups: {
initial: 'uninitialized',
states: {
uninitialized: {
on: {
INITIALIZE_CONTROL_GROUP_API: {
target: 'idle',
cond: 'controlGroupAPIExists',
actions: ['storeControlGroupAPI'],
},
},
},
idle: {
invoke: {
src: 'subscribeControlGroup',
},
on: {
DATA_VIEW_UPDATED: {
target: 'updatingControlPanels',
},
UPDATE_CONTROL_PANELS: {
target: 'updatingControlPanels',
},
},
},
updatingControlPanels: {
invoke: {
src: 'updateControlPanels',
onDone: {
target: 'idle',
actions: ['storeControlPanels'],
},
onError: {
target: 'idle',
},
},
},
},
},
},
on: {
RECEIVE_DISCOVER_APP_STATE: {
actions: ['updateContextFromDiscoverAppState'],
},
RECEIVE_QUERY_STATE: {
actions: ['updateQueryStateFromQueryServiceState'],
},
RECEIVE_TIMEFILTER_TIME: {
actions: ['updateContextFromTimefilter'],
},
RECEIVE_TIMEFILTER_REFRESH_INTERVAL: {
actions: ['updateContextFromTimefilter'],
},
},
},
},
},
{
actions: {
storeDatasetSelection: actions.assign((_context, event) =>
'data' in event && isDatasetSelection(event.data)
? {
datasetSelection: event.data,
}
: {}
),
storeDiscoverStateContainer: actions.assign((_context, event) =>
'discoverStateContainer' in event
? {
discoverStateContainer: event.discoverStateContainer,
}
: {}
),
storeControlGroupAPI: actions.assign((_context, event) =>
'controlGroupAPI' in event
? {
controlGroupAPI: event.controlGroupAPI,
}
: {}
),
storeControlPanels: actions.assign((_context, event) =>
'data' in event && ControlPanelRT.is(event.data)
? {
controlPanels: event.data,
}
: {}
),
notifyDataViewUpdate: raise('DATA_VIEW_UPDATED'),
updateContextFromDiscoverAppState,
updateDiscoverAppStateFromContext,
updateContextFromTimefilter,
},
guards: {
controlGroupAPIExists: (_context, event) => {
return 'controlGroupAPI' in event && event.controlGroupAPI != null;
},
},
}
);
export interface LogExplorerControllerStateMachineDependencies {
datasetsClient: IDatasetsClient;
initialContext?: LogExplorerControllerContext;
query: QueryStart;
toasts: IToasts;
}
export const createLogExplorerControllerStateMachine = ({
datasetsClient,
initialContext = DEFAULT_CONTEXT,
query,
toasts,
}: LogExplorerControllerStateMachineDependencies) =>
createPureLogExplorerControllerStateMachine(initialContext).withConfig({
actions: {
notifyCreateDataViewFailed: createCreateDataViewFailedNotifier(toasts),
notifyDatasetSelectionRestoreFailed: createDatasetSelectionRestoreFailedNotifier(toasts),
updateTimefilterFromContext: updateTimefilterFromContext(query),
},
services: {
createDataView: createAndSetDataView(),
initializeControlPanels: initializeControlPanels(),
subscribeControlGroup: subscribeControlGroup(),
updateControlPanels: updateControlPanels(),
validateSelection: validateSelection({ datasetsClient }),
discoverStateService: subscribeToDiscoverState(),
timefilterService: subscribeToTimefilterService(query),
},
});
export const initializeLogExplorerControllerStateService = (
deps: LogExplorerControllerStateMachineDependencies
) => {
const machine = createLogExplorerControllerStateMachine(deps);
return interpret(machine).start();
};
export type LogExplorerControllerStateService = InterpreterFrom<
typeof createLogExplorerControllerStateMachine
>;
export type LogExplorerControllerStateMachine = ReturnType<
typeof createLogExplorerControllerStateMachine
>;

View file

@ -0,0 +1,166 @@
/*
* Copyright 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 { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { QueryState, RefreshInterval, TimeRange } from '@kbn/data-plugin/common';
import { DiscoverAppState, DiscoverStateContainer } from '@kbn/discover-plugin/public';
import { DoneInvokeEvent } from 'xstate';
import { ControlPanels, DisplayOptions } from '../../../../common';
import type { DatasetEncodingError, DatasetSelection } from '../../../../common/dataset_selection';
export interface WithDatasetSelection {
datasetSelection: DatasetSelection;
}
export interface WithControlPanelGroupAPI {
controlGroupAPI: ControlGroupAPI;
}
export interface WithControlPanels {
controlPanels?: ControlPanels;
}
export type WithQueryState = QueryState;
export type WithDisplayOptions = DisplayOptions;
export interface WithDiscoverStateContainer {
discoverStateContainer: DiscoverStateContainer;
}
export type DefaultLogExplorerControllerState = WithDatasetSelection &
WithQueryState &
WithDisplayOptions;
export type LogExplorerControllerTypeState =
| {
value: 'uninitialized';
context: WithDatasetSelection & WithControlPanels & WithQueryState & WithDisplayOptions;
}
| {
value: 'initializingDataView';
context: WithDatasetSelection & WithControlPanels & WithQueryState & WithDisplayOptions;
}
| {
value: 'initializingControlPanels';
context: WithDatasetSelection & WithControlPanels & WithQueryState & WithDisplayOptions;
}
| {
value: 'initializingStateContainer';
context: WithDatasetSelection & WithControlPanels & WithQueryState & WithDisplayOptions;
}
| {
value: 'initialized';
context: WithDatasetSelection &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.datasetSelection.validatingSelection';
context: WithDatasetSelection &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.datasetSelection.idle';
context: WithDatasetSelection &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.datasetSelection.updatingDataView';
context: WithDatasetSelection &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.datasetSelection.updatingStateContainer';
context: WithDatasetSelection &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.controlGroups.uninitialized';
context: WithDatasetSelection &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.controlGroups.idle';
context: WithDatasetSelection &
WithControlPanelGroupAPI &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
}
| {
value: 'initialized.controlGroups.updatingControlPanels';
context: WithDatasetSelection &
WithControlPanelGroupAPI &
WithControlPanels &
WithQueryState &
WithDisplayOptions &
WithDiscoverStateContainer;
};
export type LogExplorerControllerContext = LogExplorerControllerTypeState['context'];
export type LogExplorerControllerStateValue = LogExplorerControllerTypeState['value'];
export type LogExplorerControllerEvent =
| {
type: 'RECEIVED_STATE_CONTAINER';
discoverStateContainer: DiscoverStateContainer;
}
| {
type: 'LISTEN_TO_CHANGES';
}
| {
type: 'UPDATE_DATASET_SELECTION';
data: DatasetSelection;
}
| {
type: 'DATASET_SELECTION_RESTORE_FAILURE';
}
| {
type: 'INITIALIZE_CONTROL_GROUP_API';
controlGroupAPI: ControlGroupAPI | undefined;
}
| {
type: 'UPDATE_CONTROL_PANELS';
controlPanels: ControlPanels | null;
}
| {
type: 'RECEIVE_DISCOVER_APP_STATE';
appState: DiscoverAppState;
}
| {
type: 'RECEIVE_TIMEFILTER_TIME';
time: TimeRange;
}
| {
type: 'RECEIVE_TIMEFILTER_REFRESH_INTERVAL';
refreshInterval: RefreshInterval;
}
| DoneInvokeEvent<DatasetSelection>
| DoneInvokeEvent<ControlPanels>
| DoneInvokeEvent<ControlGroupAPI>
| DoneInvokeEvent<DatasetEncodingError>
| DoneInvokeEvent<Error>;

View file

@ -1,279 +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 { IToasts } from '@kbn/core/public';
import { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import { actions, createMachine, interpret, InterpreterFrom, raise } from 'xstate';
import { IDatasetsClient } from '../../../services/datasets';
import { isDatasetSelection } from '../../../../common/dataset_selection';
import { createAndSetDataView } from './data_view_service';
import { validateSelection } from './selection_service';
import { DEFAULT_CONTEXT } from './defaults';
import {
createCreateDataViewFailedNotifier,
createDatasetSelectionRestoreFailedNotifier,
} from './notifications';
import {
ControlPanelRT,
LogExplorerProfileContext,
LogExplorerProfileEvent,
LogExplorerProfileTypeState,
} from './types';
import {
initializeControlPanels,
initializeFromUrl,
listenUrlChange,
subscribeControlGroup,
updateControlPanels,
updateStateContainer,
} from './url_state_storage_service';
export const createPureLogExplorerProfileStateMachine = (
initialContext: LogExplorerProfileContext
) =>
/** @xstate-layout N4IgpgJg5mDOIC5QBkD2UCiAPADgG1QCcxCAFQ1AMwEs8wA6AVwDtrWAXagQz2oC9IAYgDaABgC6iUDlSxqnVMykgsiAIwAOAMz0NagEwBWUdoBsWgOxaAnEYA0IAJ7rt9UxesWD+2xf2WAXwCHNExcAmIyCho6ejZ5bl4+NigAMQoAWwBVQjxBCEUGNgA3VABrBlDsfCIScipaIo5E-hT01GzchBLUAGMuBWYxcWHlGTlB5VUEYw1dfQ1RQ30LC0NrbX0HZwQ1CwAWemtDLTV991FTPWWtIJD0aoi66Ma45p5W5jTMnLySCkI9HwA0oRAy9Cq4VqUQasXinA+yS+7U6eG6zFK-UGw1GSBA4wSiimiFm80Wy1W60220QenoxlEjP2J32alEmmsdxAkJqkXqMSaCURKQAIgMuAA1ahgADu+UKb1KFQhDyhfJecPeSVF4qlsvRmIG1EUOIkY1khKUeOmPkM9D8pkMFm0bMpFhpM3Obi0tqdxk0Fi5PKeMIFbyF2q+YvYkulcv+RCBeBBYJVYV5z1hgoRkag0dj+p6WONQwkuOkFsm1sQtvt+kdztOojdHquonoah91h9WkWS1MQdVGdDr3hLSRUAAwop2BQ8KQuMwwHhYPKl4rypUhyH+aOtZ8pzO5wulyuDX0jSay2a8QSq6BphZTNZ6Po1O+jMy++YPScLEdLi0UwDG7Z9jkHdMdw1bNxxSadmFnVB50XZdVwTQFgXYUFCHBYNoV3TUIwPeDEOQ09YHPYsrxGG8KwmEtiQQJ8XzfD9DC-RkfycRB9msdt9kuBYNA0Dxlg0Adgm5bd8Og8McwPABlGN2DAEiuDYEg1yaJUt0gmSszk2CviUgZVJndSl0ISjL1LGjJFvSsGOrBAtC0Q4tFEc5DHE-YPI0XjTA9NRDCdI4rhEvR9HrEKIMefSwzHYVjOUsyEIszT0KTFMcLTOL1QMxLcxMlS1I0qyixs017Loy1GNc9zPMdHy-ICoLDDZehzg0Wx3z4vtbkkvD8oS-cBAgegIHFWAwHYBTlzAXpBnoYoPkmzhjPmxaS0EZAAEkFIAFQwAA5AB9A6AHlTsnAAJABBY6AHEMAU8t8UcolnOE-9fLWZ9NEsAwgtc9ttG6p0fQsQCNFitVMxGoixomqaZrmugtsUZbVqNDb0cGQQslIEU7qO07iYOu6FIwA7Tqp5AMEnA7dou463rvJyH1pETOssQx-u0Lwtm43Yzjtbz1jZGwDg2Ab7j04a90RyBkZjabZs2paVt4NaUjRhb8fJynqdpjB6cZ5mzoAJRey7rdO1I7t25AsmttmPqtTmEG+nm-usAHBba9rOp67trGffz-Nh4cCJgxFlbWrg1b1jHmDiCA6AJomSYwMmSaNmm6YZpmWbd+jPs9s5PFff19jWZsDH2IWdk7MP7UdUx-GbdxTGbKOoIK0b45R9W8ZLNOM8NqmC9NouLdO63Douu2Hadl2MFL2rnMr-8jHZWvjEFxvgcZehTn0fZIr48WYcG6SFcI+SkYTpONbHxgcB1qNdTjLSN2VIb4aK0fkPVWqNX6Y3fp-PM39CwYgvNia81V3plw9ioGsbI1CvnfJ5SwtdTB4OBmoF83k+KmHcCcNyN85Z5UAQ-ccIDE5gNHhAj+ONoExj1PGQgAIspYVTAAkcdC47jWfkw-Wb9WHrXYQWGU1kEF2XNCgxib52RYLZL9PBBDhadncPQEwfF-BXGWKIBYfd4pAPoSI4eyclqQLYcVVKMYyq-x6P-O+tDY5JAYS-Zhqc7FSIcaVSyciSxVUUZvT2KjMGsRwQcJ8Wjm7sUwdYEOlgnxOk5LfeWHjDLCJVowke4iWFQMCeZZxmVMLYVwu4wRnj+DeLESnJgkjdYpSCSQEJ1EN73jQQgFR+h6TnGsL5TQQlz5BWCnMEwEsTGeSipk6hcNam5K8eNXoR4kKPQoO-WATBWCDwgIIXax1dpMzuntAAWjnScLMDqWwusgU6j17mE1OndUgu1ukc16bYQ4ngyHuDDs2ICgVtFrB+syICfse5QxsGY++dSkbrIQnOLZqAdnjzAIIQ2p0JS7QwAAdVOoTcmGARRfPLr098T5T6+T4s6RYWhvIen0J5O0TLgpkPWN3FY8KcmFXqWsjZeA0UYuoOnLFJLs7XVufcx5pAHqm1erRZBESqWdh0EsWu-kiG1zWEFDQJxdAbHaqIYZVxnQSUWdHWSAqkXCtFTgXZ-i4LCpPKhFxcC3HZOWXa5WyLSKOudS0r4JFjwoTPBVeRFLUHTF+UcJ8ZCPAwpBUFPi7kNjnGlm+c1fLfUHPoAG1F2ynXNKgWGpC7qVyCAqcmPhOUBExxWYKwtDqS3BvLW6iNFEo2hMQeEnpcbz4JoBcm4FZC01V0NWoHuYd8F9SCJJZgqAIBwGUI26CA7vnTAALSgp2Duu0KTj0npPVFPNTaWB+ogFuyl0wj7aI0AMwFBxOx8ydG+C9trRptB+LkW9saazOmNf5aZ+CfTMjai+PYngjCdgOJ5bQX6B6Ix1BwuMAHGLuFbPg0+6xTV6EuMyK1UkfVNrta6lFlbu2YecsYAZjciGms8qyVlhhfxRQ7F2KW58Fh+2QwjR+rTTLtMILRz22HtHtRYhyN87gpZMoExY4R4nensR0E6PQz5WTsm+h6Tw7ZvIwuZBSZ8stSM0PzUrKxoDCkp1U9MCW9JnQzuGRgvT2ilhTMNSk8+axOz7CU0I1Z+SfFFNTlrcV9jwGoPZnexA5hiEue0+5kSx9-xgxScJXiWqFkWaWeRgtoi7NLXFXQBz6gjDPq8H7KkAkbDpd0DYfs6wTGdiC4ihpJWJFQPzJwiruwRIMf8rxXyzZmSdmPi+VyfFJawfEoYDrzan7WJi2W+xbSymWQG4Y0wnURvNYg0yiZ-ksHn20LxXsIUSMbpQ8AoVVGRXtoG05zTrmdOaDS9oshL5hkUL9D3KKVD8s2ru5Y1tj2g17OvQNtYcwZ2smy+Qp8WggrOnbCHYCLmNX6CW9eiHgb22YoG2cDqrJWR+Dwf6DQaaTG6GluJNy13nR44LUWzZROXWhq7eRAbiXnNabc7pr7zdDV7cQ+1CbthnyBkXUAA */
createMachine<LogExplorerProfileContext, LogExplorerProfileEvent, LogExplorerProfileTypeState>(
{
context: initialContext,
predictableActionArguments: true,
id: 'LogExplorerProfile',
initial: 'uninitialized',
states: {
uninitialized: {
always: 'initializingFromUrl',
},
initializingFromUrl: {
invoke: {
src: 'initializeFromUrl',
onDone: {
target: 'initializingDataView',
actions: ['storeDatasetSelection'],
},
onError: {
target: 'initializingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
},
initializingDataView: {
invoke: {
src: 'createDataView',
onDone: {
target: 'initializingControlPanels',
},
onError: {
target: 'initialized',
actions: ['notifyCreateDataViewFailed'],
},
},
},
initializingControlPanels: {
invoke: {
src: 'initializeControlPanels',
onDone: {
target: 'initializingStateContainer',
actions: ['storeControlPanels'],
},
onError: {
target: 'initializingStateContainer',
},
},
},
initializingStateContainer: {
invoke: {
src: 'updateStateContainer',
onDone: {
target: 'initialized',
},
onError: {
target: 'initialized',
},
},
},
initialized: {
type: 'parallel',
states: {
datasetSelection: {
initial: 'validatingSelection',
states: {
validatingSelection: {
invoke: {
src: 'validateSelection',
},
on: {
LISTEN_TO_CHANGES: {
target: 'idle',
},
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
},
idle: {
invoke: {
src: 'listenUrlChange',
},
on: {
UPDATE_DATASET_SELECTION: {
target: 'updatingDataView',
actions: ['storeDatasetSelection'],
},
DATASET_SELECTION_RESTORE_FAILURE: {
target: 'updatingDataView',
actions: ['notifyDatasetSelectionRestoreFailed'],
},
},
},
updatingDataView: {
invoke: {
src: 'createDataView',
onDone: {
target: 'updatingStateContainer',
},
onError: {
target: 'updatingStateContainer',
actions: ['notifyCreateDataViewFailed'],
},
},
},
updatingStateContainer: {
invoke: {
src: 'updateStateContainer',
onDone: {
target: 'idle',
actions: ['notifyDataViewUpdate'],
},
onError: {
target: 'idle',
actions: ['notifyCreateDataViewFailed'],
},
},
},
},
},
controlGroups: {
initial: 'uninitialized',
states: {
uninitialized: {
on: {
INITIALIZE_CONTROL_GROUP_API: {
target: 'idle',
cond: 'controlGroupAPIExists',
actions: ['storeControlGroupAPI'],
},
},
},
idle: {
invoke: {
src: 'subscribeControlGroup',
},
on: {
DATA_VIEW_UPDATED: {
target: 'updatingControlPanels',
},
UPDATE_CONTROL_PANELS: {
target: 'updatingControlPanels',
},
},
},
updatingControlPanels: {
invoke: {
src: 'updateControlPanels',
onDone: {
target: 'idle',
actions: ['storeControlPanels'],
},
onError: {
target: 'idle',
},
},
},
},
},
},
},
},
},
{
actions: {
storeDatasetSelection: actions.assign((_context, event) =>
'data' in event && isDatasetSelection(event.data)
? {
datasetSelection: event.data,
}
: {}
),
storeControlGroupAPI: actions.assign((_context, event) =>
'controlGroupAPI' in event
? {
controlGroupAPI: event.controlGroupAPI,
}
: {}
),
storeControlPanels: actions.assign((_context, event) =>
'data' in event && ControlPanelRT.is(event.data)
? {
controlPanels: event.data,
}
: {}
),
notifyDataViewUpdate: raise('DATA_VIEW_UPDATED'),
},
guards: {
controlGroupAPIExists: (_context, event) => {
return 'controlGroupAPI' in event && event.controlGroupAPI != null;
},
},
}
);
export interface LogExplorerProfileStateMachineDependencies {
initialContext?: LogExplorerProfileContext;
datasetsClient: IDatasetsClient;
stateContainer: DiscoverStateContainer;
toasts: IToasts;
}
export const createLogExplorerProfileStateMachine = ({
initialContext = DEFAULT_CONTEXT,
datasetsClient,
stateContainer,
toasts,
}: LogExplorerProfileStateMachineDependencies) =>
createPureLogExplorerProfileStateMachine(initialContext).withConfig({
actions: {
notifyCreateDataViewFailed: createCreateDataViewFailedNotifier(toasts),
notifyDatasetSelectionRestoreFailed: createDatasetSelectionRestoreFailedNotifier(toasts),
},
services: {
createDataView: createAndSetDataView({ stateContainer }),
initializeFromUrl: initializeFromUrl({ stateContainer }),
initializeControlPanels: initializeControlPanels({ stateContainer }),
listenUrlChange: listenUrlChange({ stateContainer }),
subscribeControlGroup: subscribeControlGroup({ stateContainer }),
updateControlPanels: updateControlPanels({ stateContainer }),
updateStateContainer: updateStateContainer({ stateContainer }),
validateSelection: validateSelection({ datasetsClient }),
},
});
export const initializeLogExplorerProfileStateService = (
deps: LogExplorerProfileStateMachineDependencies
) => {
const machine = createLogExplorerProfileStateMachine(deps);
return interpret(machine).start();
};
export type LogExplorerProfileStateService = InterpreterFrom<
typeof createLogExplorerProfileStateMachine
>;

View file

@ -1,128 +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 * as rt from 'io-ts';
import { ControlGroupAPI } from '@kbn/controls-plugin/public';
import { DoneInvokeEvent } from 'xstate';
import type { DatasetEncodingError, DatasetSelection } from '../../../../common/dataset_selection';
export interface WithDatasetSelection {
datasetSelection: DatasetSelection;
}
export interface WithControlPanelGroupAPI {
controlGroupAPI: ControlGroupAPI;
}
export interface WithControlPanels {
controlPanels: ControlPanels;
}
export type DefaultLogExplorerProfileState = WithDatasetSelection;
export type LogExplorerProfileTypeState =
| {
value: 'uninitialized';
context: WithDatasetSelection;
}
| {
value: 'initializingFromUrl';
context: WithDatasetSelection;
}
| {
value: 'initializingDataView';
context: WithDatasetSelection;
}
| {
value: 'initializingControlPanels';
context: WithDatasetSelection;
}
| {
value: 'initializingStateContainer';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.datasetSelection.validatingSelection';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.datasetSelection.idle';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.datasetSelection.updatingDataView';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.datasetSelection.updatingStateContainer';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.controlGroups.uninitialized';
context: WithDatasetSelection & WithControlPanels;
}
| {
value: 'initialized.controlGroups.idle';
context: WithDatasetSelection & WithControlPanelGroupAPI & WithControlPanels;
}
| {
value: 'initialized.controlGroups.updatingControlPanels';
context: WithDatasetSelection & WithControlPanelGroupAPI & WithControlPanels;
};
export type LogExplorerProfileContext = LogExplorerProfileTypeState['context'];
export type LogExplorerProfileStateValue = LogExplorerProfileTypeState['value'];
export type LogExplorerProfileEvent =
| {
type: 'LISTEN_TO_CHANGES';
}
| {
type: 'UPDATE_DATASET_SELECTION';
data: DatasetSelection;
}
| {
type: 'DATASET_SELECTION_RESTORE_FAILURE';
}
| {
type: 'INITIALIZE_CONTROL_GROUP_API';
controlGroupAPI: ControlGroupAPI | undefined;
}
| {
type: 'UPDATE_CONTROL_PANELS';
controlPanels: ControlPanels | null;
}
| DoneInvokeEvent<DatasetSelection>
| DoneInvokeEvent<ControlPanels>
| DoneInvokeEvent<ControlGroupAPI>
| DoneInvokeEvent<DatasetEncodingError>
| DoneInvokeEvent<Error>;
const PanelRT = rt.type({
order: rt.number,
width: rt.union([rt.literal('medium'), rt.literal('small'), rt.literal('large')]),
grow: rt.boolean,
type: rt.string,
explicitInput: rt.intersection([
rt.type({ id: rt.string }),
rt.partial({
dataViewId: rt.string,
fieldName: rt.string,
title: rt.union([rt.string, rt.undefined]),
selectedOptions: rt.array(rt.string),
}),
]),
});
export const ControlPanelRT = rt.record(rt.string, PanelRT);
export type ControlPanels = rt.TypeOf<typeof ControlPanelRT>;

View file

@ -1,284 +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 { InvokeCreator } from 'xstate';
import { pick, mapValues } from 'lodash';
import deepEqual from 'fast-deep-equal';
import { DiscoverAppState, DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { ROWS_HEIGHT_OPTIONS } from '@kbn/unified-data-table';
import {
AllDatasetSelection,
decodeDatasetSelectionId,
hydrateDatasetSelection,
isDatasetSelection,
} from '../../../../common/dataset_selection';
import {
DATA_GRID_COLUMNS_PREFERENCES,
DATA_GRID_DEFAULT_COLUMNS,
LOG_LEVEL_FIELD,
} from '../../../../common/constants';
import {
ControlPanelRT,
ControlPanels,
LogExplorerProfileContext,
LogExplorerProfileEvent,
} from './types';
import {
availableControlPanelFields,
controlPanelConfigs,
CONTROL_PANELS_URL_KEY,
} from './defaults';
interface LogExplorerProfileUrlStateDependencies {
stateContainer: DiscoverStateContainer;
}
export const listenUrlChange =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
(context) =>
(send) => {
const unsubscribe = stateContainer.appState.subscribe((nextState) => {
const { index } = nextState;
const prevIndex = stateContainer.appState.getPrevious().index;
// Preventing update if the index didn't change
if (prevIndex === index) return;
try {
const datasetSelection = extractDatasetSelectionFromIndex({ index, context });
if (isDatasetSelection(datasetSelection)) {
send({ type: 'UPDATE_DATASET_SELECTION', data: datasetSelection });
}
} catch (error) {
send({ type: 'DATASET_SELECTION_RESTORE_FAILURE' });
}
});
return () => unsubscribe();
};
export const initializeFromUrl =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
async (context) => {
const { index } = stateContainer.appState.getState();
return extractDatasetSelectionFromIndex({ index, context });
};
export const initializeControlPanels =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
async (context) => {
const urlPanels = stateContainer.stateStorage.get<ControlPanels>(CONTROL_PANELS_URL_KEY);
const controlPanelsWithId = constructControlPanelsWithDataViewId(stateContainer, urlPanels!);
return controlPanelsWithId;
};
const extractDatasetSelectionFromIndex = ({
index,
context,
}: {
index?: string;
context: LogExplorerProfileContext;
}) => {
// If the index parameter doesn't exists, use initialContext value or fallback to AllDatasetSelection
if (!index) {
return context.datasetSelection ?? AllDatasetSelection.create();
}
const rawDatasetSelection = decodeDatasetSelectionId(index);
const datasetSelection = hydrateDatasetSelection(rawDatasetSelection);
return datasetSelection;
};
export const subscribeControlGroup =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
(context) =>
(send) => {
if (!('controlGroupAPI' in context)) return;
const filtersSubscription = context.controlGroupAPI.onFiltersPublished$.subscribe(
(newFilters) => {
stateContainer.internalState.transitions.setCustomFilters(newFilters);
stateContainer.actions.fetchData();
}
);
// Keeps our state in sync with the url changes and makes sure it adheres to correct schema
const urlSubscription = stateContainer.stateStorage
.change$<ControlPanels>(CONTROL_PANELS_URL_KEY)
.subscribe((controlPanels) => {
if (!deepEqual(controlPanels, context.controlPanels)) {
send({ type: 'UPDATE_CONTROL_PANELS', controlPanels });
}
});
// Keeps the url in sync with the controls state after change
const inputSubscription = context.controlGroupAPI.getInput$().subscribe(({ panels }) => {
if (!deepEqual(panels, context.controlPanels)) {
send({ type: 'UPDATE_CONTROL_PANELS', controlPanels: panels });
}
});
return () => {
filtersSubscription.unsubscribe();
urlSubscription.unsubscribe();
inputSubscription.unsubscribe();
};
};
export const updateControlPanels =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
async (context, event) => {
if (!('controlGroupAPI' in context)) return;
const newControlPanels =
('controlPanels' in event && event.controlPanels) || context.controlPanels;
const controlPanelsWithId = constructControlPanelsWithDataViewId(
stateContainer,
newControlPanels!
);
context.controlGroupAPI.updateInput({ panels: controlPanelsWithId });
return controlPanelsWithId;
};
export const updateStateContainer =
({
stateContainer,
}: LogExplorerProfileUrlStateDependencies): InvokeCreator<
LogExplorerProfileContext,
LogExplorerProfileEvent
> =>
async () => {
const { breakdownField, columns, grid, rowHeight } = stateContainer.appState.getState();
const stateUpdates: DiscoverAppState = {};
// Update data grid columns list
const shouldSetDefaultColumns =
stateContainer.appState.isEmptyURL() || !columns || columns.length === 0;
if (shouldSetDefaultColumns) {
stateUpdates.columns = DATA_GRID_DEFAULT_COLUMNS;
}
// Configure DataGrid columns preferences
const initialColumnsPreferences = grid?.columns ?? {};
stateUpdates.grid = {
columns: { ...DATA_GRID_COLUMNS_PREFERENCES, ...initialColumnsPreferences },
};
// Configure rowHeight preference
stateUpdates.rowHeight = rowHeight ?? ROWS_HEIGHT_OPTIONS.single;
// Configure breakdown field preference
stateUpdates.breakdownField = breakdownField ?? LOG_LEVEL_FIELD;
// Finally batch update state app state
stateContainer.appState.update(stateUpdates, true);
};
/**
* Utils
*/
const constructControlPanelsWithDataViewId = (
stateContainer: DiscoverStateContainer,
newControlPanels: ControlPanels
) => {
const dataView = stateContainer.internalState.getState().dataView!;
const validatedControlPanels = isValidState(newControlPanels)
? newControlPanels
: getVisibleControlPanelsConfig(dataView);
const controlsPanelsWithId = mergeDefaultPanelsWithUrlConfig(dataView, validatedControlPanels!);
if (!deepEqual(controlsPanelsWithId, stateContainer.stateStorage.get(CONTROL_PANELS_URL_KEY))) {
stateContainer.stateStorage.set(
CONTROL_PANELS_URL_KEY,
cleanControlPanels(controlsPanelsWithId),
{ replace: true }
);
}
return controlsPanelsWithId;
};
const isValidState = (state: ControlPanels | undefined | null): boolean => {
return Object.keys(state ?? {}).length > 0 && ControlPanelRT.is(state);
};
const getVisibleControlPanels = (dataView: DataView | undefined) =>
availableControlPanelFields.filter(
(panelKey) => dataView?.fields.getByName(panelKey) !== undefined
);
export const getVisibleControlPanelsConfig = (dataView?: DataView) => {
return getVisibleControlPanels(dataView).reduce((panelsMap, panelKey) => {
const config = controlPanelConfigs[panelKey];
return { ...panelsMap, [panelKey]: config };
}, {} as ControlPanels);
};
const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId: string = '') => {
return mapValues(controlPanels, (controlPanelConfig) => ({
...controlPanelConfig,
explicitInput: { ...controlPanelConfig.explicitInput, dataViewId },
}));
};
const cleanControlPanels = (controlPanels: ControlPanels) => {
return mapValues(controlPanels, (controlPanelConfig) => {
const { explicitInput } = controlPanelConfig;
const { dataViewId, ...rest } = explicitInput;
return { ...controlPanelConfig, explicitInput: rest };
});
};
const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlPanels) => {
// Get default panel configs from existing fields in data view
const visiblePanels = getVisibleControlPanelsConfig(dataView);
// Get list of panel which can be overridden to avoid merging additional config from url
const existingKeys = Object.keys(visiblePanels);
const controlPanelsToOverride = pick(urlPanels, existingKeys);
// Merge default and existing configs and add dataView.id to each of them
return addDataViewIdToControlPanels(
{ ...visiblePanels, ...controlPanelsToOverride },
dataView.id
);
};

View file

@ -1,23 +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 { LogExplorerProfileStateService } from './state_machine';
import { LogExplorerProfileStateValue } from './types';
export const waitForState = (
service: LogExplorerProfileStateService,
targetState: LogExplorerProfileStateValue
) => {
return new Promise((resolve) => {
const { unsubscribe } = service.subscribe((state) => {
if (state.matches(targetState)) {
resolve(state);
unsubscribe();
}
});
});
};

View file

@ -6,18 +6,22 @@
*/
import type { ComponentType } from 'react';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { LogExplorerLocators } from '../common/locators';
import type { SharePluginSetup } from '@kbn/share-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { LogExplorerLocators } from '../common/locators';
import type { LogExplorerProps } from './components/log_explorer';
import type { CreateLogExplorerController } from './controller';
export interface LogExplorerPluginSetup {
locators: LogExplorerLocators;
}
export interface LogExplorerPluginStart {
LogExplorer: ComponentType<LogExplorerProps>;
createLogExplorerController: CreateLogExplorerController;
}
export interface LogExplorerSetupDeps {
@ -30,4 +34,6 @@ export interface LogExplorerStartDeps {
dataViews: DataViewsPublicPluginStart;
discover: DiscoverStart;
fieldFormats: FieldFormatsStart;
navigation: NavigationPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
}

View file

@ -0,0 +1,73 @@
/*
* Copyright 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 { QueryState } from '@kbn/data-plugin/public';
import { DiscoverAppState } from '@kbn/discover-plugin/public';
import { cloneDeep } from 'lodash';
import {
ChartDisplayOptions,
DisplayOptions,
GridColumnDisplayOptions,
GridRowsDisplayOptions,
} from '../../common';
export const getGridColumnDisplayOptionsFromDiscoverAppState = (
discoverAppState: DiscoverAppState
): GridColumnDisplayOptions[] | undefined =>
discoverAppState.columns?.map((field) => ({
field,
width: discoverAppState.grid?.columns?.[field]?.width,
}));
export const getGridRowsDisplayOptionsFromDiscoverAppState = (
discoverAppState: DiscoverAppState
): Partial<GridRowsDisplayOptions> => ({
...(discoverAppState.rowHeight != null ? { rowHeight: discoverAppState.rowHeight } : {}),
...(discoverAppState.rowsPerPage != null ? { rowsPerPage: discoverAppState.rowsPerPage } : {}),
});
export const getChartDisplayOptionsFromDiscoverAppState = (
discoverAppState: DiscoverAppState
): Partial<ChartDisplayOptions> => ({
breakdownField: discoverAppState.breakdownField ?? null,
});
export const getQueryStateFromDiscoverAppState = (
discoverAppState: DiscoverAppState
): QueryState => ({
query: discoverAppState.query,
filters: discoverAppState.filters,
});
export const getDiscoverAppStateFromContext = (
displayOptions: DisplayOptions & QueryState
): Partial<DiscoverAppState> => ({
breakdownField: displayOptions.chart.breakdownField ?? undefined,
columns: getDiscoverColumnsFromDisplayOptions(displayOptions),
grid: getDiscoverGridFromDisplayOptions(displayOptions),
rowHeight: displayOptions.grid.rows.rowHeight,
rowsPerPage: displayOptions.grid.rows.rowsPerPage,
query: cloneDeep(displayOptions.query),
filters: cloneDeep(displayOptions.filters),
});
export const getDiscoverColumnsFromDisplayOptions = (
displayOptions: DisplayOptions
): DiscoverAppState['columns'] => displayOptions.grid.columns.map(({ field }) => field);
export const getDiscoverGridFromDisplayOptions = (
displayOptions: DisplayOptions
): DiscoverAppState['grid'] => ({
columns: displayOptions.grid.columns.reduce<
NonNullable<NonNullable<DiscoverAppState['grid']>['columns']>
>((gridColumns, { field, width }) => {
if (width != null) {
gridColumns[field] = { width };
}
return gridColumns;
}, {}),
});

View file

@ -3,31 +3,43 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["../../../typings/**/*", "common/**/*", "public/**/*", "server/**/*", ".storybook/**/*.tsx"],
"include": [
"../../../typings/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
".storybook/**/*.tsx"
],
"kbn_references": [
"@kbn/core",
"@kbn/discover-plugin",
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/fleet-plugin",
"@kbn/io-ts-utils",
"@kbn/data-views-plugin",
"@kbn/rison",
"@kbn/controls-plugin",
"@kbn/core",
"@kbn/core-application-browser",
"@kbn/core-http-browser",
"@kbn/core-ui-settings-browser",
"@kbn/custom-icons",
"@kbn/data-plugin",
"@kbn/data-views-plugin",
"@kbn/deeplinks-observability",
"@kbn/discover-plugin",
"@kbn/discover-utils",
"@kbn/elastic-agent-utils",
"@kbn/embeddable-plugin",
"@kbn/es-query",
"@kbn/field-formats-plugin",
"@kbn/fleet-plugin",
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/io-ts-utils",
"@kbn/kibana-react-plugin",
"@kbn/data-plugin",
"@kbn/unified-field-list",
"@kbn/core-application-browser",
"@kbn/kibana-utils-plugin",
"@kbn/navigation-plugin",
"@kbn/share-plugin",
"@kbn/unified-data-table",
"@kbn/core-ui-settings-browser",
"@kbn/discover-utils",
"@kbn/deeplinks-observability",
"@kbn/field-formats-plugin",
"@kbn/custom-icons",
"@kbn/elastic-agent-utils"
"@kbn/unified-field-list",
"@kbn/unified-search-plugin",
"@kbn/xstate-utils"
],
"exclude": ["target/**/*"]
"exclude": [
"target/**/*"
]
}

View file

@ -128,6 +128,8 @@ describe('SLO Edit Page', () => {
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
const history = createBrowserHistory();
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
@ -136,9 +138,8 @@ describe('SLO Edit Page', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
const history = createBrowserHistory();
history.replace('');
jest.spyOn(Router, 'useHistory').mockReturnValueOnce(history);
jest.spyOn(Router, 'useHistory').mockReturnValue(history);
useFetchDataViewsMock.mockReturnValue({
isLoading: false,
@ -256,11 +257,9 @@ describe('SLO Edit Page', () => {
it('prefills the form with values from URL', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
const history = createBrowserHistory();
history.replace(
'/slos/create?_a=(indicator:(params:(environment:prod,service:cartService),type:sli.apm.transactionDuration))'
);
jest.spyOn(Router, 'useHistory').mockReturnValueOnce(history);
jest
.spyOn(Router, 'useLocation')
.mockReturnValue({ pathname: 'foo', search: '', state: '', hash: '' });
@ -336,11 +335,9 @@ describe('SLO Edit Page', () => {
const slo = buildSlo({ id: '123' });
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
const history = createBrowserHistory();
history.push(
'/slos/123/edit?_a=(name:%27updated-name%27,indicator:(params:(environment:prod,service:cartService),type:sli.apm.transactionDuration),objective:(target:0.92))'
);
jest.spyOn(Router, 'useHistory').mockReturnValueOnce(history);
jest
.spyOn(Router, 'useLocation')
.mockReturnValue({ pathname: 'foo', search: '', state: '', hash: '' });

View file

@ -10,3 +10,5 @@ export {
SingleDatasetLocatorDefinition,
AllDatasetsLocatorDefinition,
} from './locators';
export { OBSERVABILITY_LOG_EXPLORER_URL_STATE_KEY, urlSchemaV1 } from './url_schema';
export { deepCompactObject } from './utils/deep_compact_object';

View file

@ -23,11 +23,10 @@ export class AllDatasetsLocatorDefinition implements LocatorDefinition<AllDatase
public readonly getLocation = (params: AllDatasetsLocatorParams) => {
const { useHash } = this.deps;
const index = AllDatasetSelection.create().toDataviewSpec().id;
return constructLocatorPath({
datasetSelection: AllDatasetSelection.create().toPlainSelection(),
locatorParams: params,
index,
useHash,
});
};

View file

@ -5,13 +5,11 @@
* 2.0.
*/
import { FilterStateStore } from '@kbn/es-query';
import { getStatesFromKbnUrl } from '@kbn/kibana-utils-plugin/public';
import { OBSERVABILITY_LOG_EXPLORER_APP_ID } from '@kbn/deeplinks-observability';
import {
AllDatasetsLocatorParams,
SingleDatasetLocatorParams,
} from '@kbn/deeplinks-observability/locators';
import { OBSERVABILITY_LOG_EXPLORER } from '@kbn/deeplinks-observability';
import { AllDatasetsLocatorDefinition } from './all_datasets/all_datasets_locator';
import { SingleDatasetLocatorDefinition } from './single_dataset';
import { DatasetLocatorDependencies } from './types';
@ -38,8 +36,8 @@ describe('Observability Logs Explorer Locators', () => {
const location = await allDatasetsLocator.getLocation({});
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: '/?_a=(index:BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA)',
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: '/?pageState=(datasetSelection:(selectionType:all),v:1)',
state: {},
});
});
@ -53,8 +51,8 @@ describe('Observability Logs Explorer Locators', () => {
const location = await allDatasetsLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: '/?_g=(time:(from:now-30m,to:now))&_a=(index:BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA)',
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: '/?pageState=(datasetSelection:(selectionType:all),time:(from:now-30m,to:now),v:1)',
state: {},
});
});
@ -70,8 +68,8 @@ describe('Observability Logs Explorer Locators', () => {
const location = await allDatasetsLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: '/?_a=(index:BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA,query:(language:kuery,query:foo))',
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: '/?pageState=(datasetSelection:(selectionType:all),query:(language:kuery,query:foo),v:1)',
state: {},
});
});
@ -88,29 +86,28 @@ describe('Observability Logs Explorer Locators', () => {
const location = await allDatasetsLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: '/?_g=(refreshInterval:(pause:!f,value:666))&_a=(index:BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA)',
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: '/?pageState=(datasetSelection:(selectionType:all),refreshInterval:(pause:!f,value:666),v:1)',
state: {},
});
});
it('should allow specifiying columns and sort', async () => {
it('should allow specifiying columns', async () => {
const params: AllDatasetsLocatorParams = {
columns: ['_source'],
sort: [['timestamp, asc']] as string[][],
};
const { allDatasetsLocator } = await setup();
const location = await allDatasetsLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: `/?_a=(columns:!(_source),index:BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA,sort:!(!('timestamp,%20asc')))`,
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: `/?pageState=(columns:!((field:_source)),datasetSelection:(selectionType:all),v:1)`,
state: {},
});
});
it('should allow specifiying filters', async () => {
it('should allow specifying filters', async () => {
const params: AllDatasetsLocatorParams = {
filters: [
{
@ -119,9 +116,6 @@ describe('Observability Logs Explorer Locators', () => {
disabled: false,
negate: false,
},
$state: {
store: FilterStateStore.APP_STATE,
},
},
{
meta: {
@ -129,47 +123,16 @@ describe('Observability Logs Explorer Locators', () => {
disabled: false,
negate: false,
},
$state: {
store: FilterStateStore.GLOBAL_STATE,
},
},
],
};
const { allDatasetsLocator } = await setup();
const { path } = await allDatasetsLocator.getLocation(params);
const location = await allDatasetsLocator.getLocation(params);
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g'], { getFromHashQuery: false });
expect(_a).toEqual({
filters: [
{
$state: {
store: 'appState',
},
meta: {
alias: 'foo',
disabled: false,
negate: false,
},
},
],
index: 'BQZwpgNmDGAuCWB7AdgFQJ4AcwC4CGEEAlEA',
});
expect(_g).toEqual({
filters: [
{
$state: {
store: 'globalState',
},
meta: {
alias: 'bar',
disabled: false,
negate: false,
},
},
],
});
expect(location.path).toMatchInlineSnapshot(
`"/?pageState=(datasetSelection:(selectionType:all),filters:!((meta:(alias:foo,disabled:!f,negate:!f)),(meta:(alias:bar,disabled:!f,negate:!f))),v:1)"`
);
});
});
@ -184,8 +147,8 @@ describe('Observability Logs Explorer Locators', () => {
});
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: `/?_a=(index:BQZwpgNmDGAuCWB7AdgLmAEwIay%2BW6yWAtmKgOQSIDmIAtLGCLHQFRvkA0CsUqjzAJScipVABUmsYeChwkycQE8ADmQCuyAE5NEEAG5gMgoA)`,
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: `/?pageState=(datasetSelection:(selection:(dataset:(name:'logs-test-*-*',title:test),name:Test),selectionType:unresolved),v:1)`,
state: {},
});
});
@ -201,8 +164,8 @@ describe('Observability Logs Explorer Locators', () => {
const location = await singleDatasetLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: `/?_g=(time:(from:now-30m,to:now))&_a=(index:BQZwpgNmDGAuCWB7AdgLmAEwIay%2BW6yWAtmKgOQSIDmIAtLGCLHQFRvkA0CsUqjzAJScipVABUmsYeChwkycQE8ADmQCuyAE5NEEAG5gMgoA)`,
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: `/?pageState=(datasetSelection:(selection:(dataset:(name:'logs-test-*-*',title:test),name:Test),selectionType:unresolved),time:(from:now-30m,to:now),v:1)`,
state: {},
});
});
@ -221,8 +184,8 @@ describe('Observability Logs Explorer Locators', () => {
const location = await singleDatasetLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: `/?_a=(index:BQZwpgNmDGAuCWB7AdgLmAEwIay%2BW6yWAtmKgOQSIDmIAtLGCLHQFRvkA0CsUqjzAJScipVABUmsYeChwkycQE8ADmQCuyAE5NEEAG5gMgoA,query:(language:kuery,query:foo))`,
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: `/?pageState=(datasetSelection:(selection:(dataset:(name:'logs-test-*-*',title:test),name:Test),selectionType:unresolved),query:(language:kuery,query:foo),v:1)`,
state: {},
});
});
@ -241,26 +204,25 @@ describe('Observability Logs Explorer Locators', () => {
const location = await singleDatasetLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: `/?_g=(refreshInterval:(pause:!f,value:666))&_a=(index:BQZwpgNmDGAuCWB7AdgLmAEwIay%2BW6yWAtmKgOQSIDmIAtLGCLHQFRvkA0CsUqjzAJScipVABUmsYeChwkycQE8ADmQCuyAE5NEEAG5gMgoA)`,
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: `/?pageState=(datasetSelection:(selection:(dataset:(name:'logs-test-*-*',title:test),name:Test),selectionType:unresolved),refreshInterval:(pause:!f,value:666),v:1)`,
state: {},
});
});
it('should allow specifiying columns and sort', async () => {
it('should allow specifiying columns', async () => {
const params: SingleDatasetLocatorParams = {
integration,
dataset,
columns: ['_source'],
sort: [['timestamp, asc']] as string[][],
};
const { singleDatasetLocator } = await setup();
const location = await singleDatasetLocator.getLocation(params);
expect(location).toMatchObject({
app: OBSERVABILITY_LOG_EXPLORER,
path: `/?_a=(columns:!(_source),index:BQZwpgNmDGAuCWB7AdgLmAEwIay%2BW6yWAtmKgOQSIDmIAtLGCLHQFRvkA0CsUqjzAJScipVABUmsYeChwkycQE8ADmQCuyAE5NEEAG5gMgoA,sort:!(!('timestamp,%20asc')))`,
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path: `/?pageState=(columns:!((field:_source)),datasetSelection:(selection:(dataset:(name:'logs-test-*-*',title:test),name:Test),selectionType:unresolved),v:1)`,
state: {},
});
});
@ -276,9 +238,6 @@ describe('Observability Logs Explorer Locators', () => {
disabled: false,
negate: false,
},
$state: {
store: FilterStateStore.APP_STATE,
},
},
{
meta: {
@ -286,48 +245,16 @@ describe('Observability Logs Explorer Locators', () => {
disabled: false,
negate: false,
},
$state: {
store: FilterStateStore.GLOBAL_STATE,
},
},
],
};
const { singleDatasetLocator } = await setup();
const { path } = await singleDatasetLocator.getLocation(params);
const location = await singleDatasetLocator.getLocation(params);
const { _a, _g } = getStatesFromKbnUrl(path, ['_a', '_g'], { getFromHashQuery: false });
expect(_a).toEqual({
filters: [
{
$state: {
store: 'appState',
},
meta: {
alias: 'foo',
disabled: false,
negate: false,
},
},
],
index:
'BQZwpgNmDGAuCWB7AdgLmAEwIay+W6yWAtmKgOQSIDmIAtLGCLHQFRvkA0CsUqjzAJScipVABUmsYeChwkycQE8ADmQCuyAE5NEEAG5gMgoA',
});
expect(_g).toEqual({
filters: [
{
$state: {
store: 'globalState',
},
meta: {
alias: 'bar',
disabled: false,
negate: false,
},
},
],
});
expect(location.path).toMatchInlineSnapshot(
`"/?pageState=(datasetSelection:(selection:(dataset:(name:'logs-test-*-*',title:test),name:Test),selectionType:unresolved),filters:!((meta:(alias:foo,disabled:!f,negate:!f)),(meta:(alias:bar,disabled:!f,negate:!f))),v:1)"`
);
});
});
});

View file

@ -35,11 +35,9 @@ export class SingleDatasetLocatorDefinition
},
});
const index = unresolvedDatasetSelection.toDataviewSpec().id;
return constructLocatorPath({
datasetSelection: unresolvedDatasetSelection.toPlainSelection(),
locatorParams: params,
index,
useHash,
});
};

View file

@ -5,16 +5,6 @@
* 2.0.
*/
import { AggregateQuery, Filter, Query } from '@kbn/es-query';
export interface AppState {
index?: string;
query?: Query | AggregateQuery;
filters?: Filter[];
columns?: string[];
sort?: string[][];
}
export interface DatasetLocatorDependencies {
useHash: boolean;
}

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
DatasetLocatorParams,
FilterControls,
ListFilterControl,
} from '@kbn/deeplinks-observability/locators';
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/common';
import {
AvailableControlPanels,
availableControlsPanels,
DatasetSelectionPlain,
} from '@kbn/log-explorer-plugin/common';
import { OBSERVABILITY_LOG_EXPLORER_APP_ID } from '@kbn/deeplinks-observability';
import { OBSERVABILITY_LOG_EXPLORER_URL_STATE_KEY, urlSchemaV1 } from '../../url_schema';
import { deepCompactObject } from '../../utils/deep_compact_object';
type ControlsPageState = NonNullable<urlSchemaV1.UrlSchema['controls']>;
interface LocatorPathConstructionParams {
datasetSelection: DatasetSelectionPlain;
locatorParams: DatasetLocatorParams;
useHash: boolean;
}
export const constructLocatorPath = async (params: LocatorPathConstructionParams) => {
const {
datasetSelection,
locatorParams: { filterControls, filters, query, refreshInterval, timeRange, columns, origin },
useHash,
} = params;
const pageState = urlSchemaV1.urlSchemaRT.encode(
deepCompactObject({
v: 1,
datasetSelection,
filters,
query,
refreshInterval,
time: timeRange,
columns: columns?.map((field) => ({ field })),
controls: getControlsPageStateFromFilterControlsParams(filterControls ?? {}),
})
);
const path = setStateToKbnUrl(
OBSERVABILITY_LOG_EXPLORER_URL_STATE_KEY,
pageState,
{ useHash, storeInHashQuery: false },
'/'
);
return {
app: OBSERVABILITY_LOG_EXPLORER_APP_ID,
path,
state: {
...(origin ? { origin } : {}),
},
};
};
const getControlsPageStateFromFilterControlsParams = (
filterControls: FilterControls
): ControlsPageState => ({
...(filterControls.namespace != null
? getFilterControlPageStateFromListFilterControlsParams(
availableControlsPanels.NAMESPACE,
filterControls.namespace
)
: {}),
});
const getFilterControlPageStateFromListFilterControlsParams = (
controlId: AvailableControlPanels[keyof AvailableControlPanels],
listFilterControl: ListFilterControl
): ControlsPageState => ({
[controlId]: {
mode: listFilterControl.mode,
selection: {
type: 'options',
selectedOptions: listFilterControl.values,
},
},
});

View file

@ -1,62 +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 type { GlobalQueryStateFromUrl } from '@kbn/data-plugin/public';
import { setStateToKbnUrl } from '@kbn/kibana-utils-plugin/common';
import { DatasetLocatorParams } from '@kbn/deeplinks-observability/locators';
import { AppState } from '../types';
interface LocatorPathCosntructionParams {
locatorParams: DatasetLocatorParams;
index: string;
useHash: boolean;
}
export const constructLocatorPath = async (params: LocatorPathCosntructionParams) => {
const { isFilterPinned } = await import('@kbn/es-query');
const {
locatorParams: { filters, query, refreshInterval, timeRange, columns, sort, origin },
index,
useHash,
} = params;
const appState: AppState = {};
const queryState: GlobalQueryStateFromUrl = {};
// App state
if (index) appState.index = index;
if (query) appState.query = query;
if (filters && filters.length) appState.filters = filters?.filter((f) => !isFilterPinned(f));
if (columns) appState.columns = columns;
if (sort) appState.sort = sort;
// Global State
if (timeRange) queryState.time = timeRange;
if (filters && filters.length) queryState.filters = filters?.filter((f) => isFilterPinned(f));
if (refreshInterval) queryState.refreshInterval = refreshInterval;
let path = '/';
if (Object.keys(queryState).length) {
path = setStateToKbnUrl<GlobalQueryStateFromUrl>(
'_g',
queryState,
{ useHash, storeInHashQuery: false },
path
);
}
path = setStateToKbnUrl('_a', appState, { useHash, storeInHashQuery: false }, path);
return {
app: 'observability-log-explorer',
path,
state: {
...(origin ? { origin } : {}),
},
};
};

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export * from './helpers';
export * from './construct_locator_path';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const OBSERVABILITY_LOG_EXPLORER_URL_STATE_KEY = 'pageState';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { OBSERVABILITY_LOG_EXPLORER_URL_STATE_KEY } from './common';
export * as urlSchemaV1 from './url_schema_v1';

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { availableControlsPanels, datasetSelectionPlainRT } from '@kbn/log-explorer-plugin/common';
import * as rt from 'io-ts';
export const columnRT = rt.intersection([
rt.strict({
field: rt.string,
}),
rt.exact(
rt.partial({
width: rt.number,
})
),
]);
export const columnsRT = rt.array(columnRT);
export const optionsListControlRT = rt.strict({
mode: rt.keyof({
exclude: null,
include: null,
}),
selection: rt.union([
rt.strict({
type: rt.literal('exists'),
}),
rt.strict({
type: rt.literal('options'),
selectedOptions: rt.array(rt.string),
}),
]),
});
export const controlsRT = rt.exact(
rt.partial({
[availableControlsPanels.NAMESPACE]: optionsListControlRT,
})
);
export const filterMetaRT = rt.partial({
alias: rt.union([rt.string, rt.null]),
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,
});
export const filterRT = rt.intersection([
rt.strict({
meta: filterMetaRT,
}),
rt.exact(
rt.partial({
query: rt.UnknownRecord,
})
),
]);
export const filtersRT = rt.array(filterRT);
const queryRT = rt.union([
rt.strict({
language: rt.string,
query: rt.union([rt.string, rt.record(rt.string, rt.unknown)]),
}),
rt.strict({
sql: rt.string,
}),
rt.strict({
esql: rt.string,
}),
]);
const timeRangeRT = rt.intersection([
rt.strict({
from: rt.string,
to: rt.string,
}),
rt.exact(
rt.partial({
mode: rt.keyof({
absolute: null,
relative: null,
}),
})
),
]);
const refreshIntervalRT = rt.strict({
pause: rt.boolean,
value: rt.number,
});
export const urlSchemaRT = rt.exact(
rt.partial({
v: rt.literal(1),
breakdownField: rt.union([rt.string, rt.null]),
columns: columnsRT,
datasetSelection: datasetSelectionPlainRT,
filters: filtersRT,
query: queryRT,
refreshInterval: refreshIntervalRT,
rowHeight: rt.number,
rowsPerPage: rt.number,
time: timeRangeRT,
controls: controlsRT,
})
);
export type UrlSchema = rt.TypeOf<typeof urlSchemaRT>;

View file

@ -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.
*/
import { isEmpty, isPlainObject, isUndefined } from 'lodash';
export const deepCompactObject = <Value extends Record<string, any>>(obj: Value): Value =>
Object.fromEntries(
Object.entries(obj)
.map(([key, value]) => [key, isPlainObject(value) ? deepCompactObject(value) : value])
.filter(([, value]) => !isUndefined(value) && !(isPlainObject(value) && isEmpty(value)))
);

View file

@ -10,12 +10,13 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { Route, Router, Routes } from '@kbn/shared-ux-router';
import React from 'react';
import ReactDOM from 'react-dom';
import { DatasetQualityRoute, ObservablityLogExplorerMainRoute } from '../routes/main';
import { DatasetQualityRoute, ObservabilityLogExplorerMainRoute } from '../routes/main';
import {
ObservabilityLogExplorerAppMountParameters,
ObservabilityLogExplorerPluginStart,
ObservabilityLogExplorerStartDeps,
} from '../types';
import { KbnUrlStateStorageFromRouterProvider } from '../utils/kbn_url_state_context';
import { useKibanaContextForPluginProvider } from '../utils/use_kibana';
export const renderObservabilityLogExplorer = (
@ -59,26 +60,25 @@ export const ObservabilityLogExplorerApp = ({
const KibanaContextProviderForPlugin = useKibanaContextForPluginProvider(
core,
plugins,
pluginStart
pluginStart,
appParams
);
return (
<KibanaRenderContextProvider i18n={core.i18n} theme={core.theme}>
<KibanaContextProviderForPlugin>
<Router history={appParams.history}>
<Routes>
<Route
path="/"
exact={true}
render={() => <ObservablityLogExplorerMainRoute appParams={appParams} core={core} />}
/>
<Route
path="/dataset-quality"
exact={true}
render={() => <DatasetQualityRoute core={core} />}
/>
</Routes>
</Router>
<KbnUrlStateStorageFromRouterProvider>
<Router history={appParams.history}>
<Routes>
<Route path="/" exact={true} render={() => <ObservabilityLogExplorerMainRoute />} />
<Route
path="/dataset-quality"
exact={true}
render={() => <DatasetQualityRoute core={core} />}
/>
</Routes>
</Router>
</KbnUrlStateStorageFromRouterProvider>
</KibanaContextProviderForPlugin>
</KibanaRenderContextProvider>
);

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHeaderLink } from '@elastic/eui';
import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common';
import { DiscoverStart } from '@kbn/discover-plugin/public';
import { hydrateDatasetSelection } from '@kbn/log-explorer-plugin/common';
import { getDiscoverColumnsFromDisplayOptions } from '@kbn/log-explorer-plugin/public';
import { MatchedStateFromActor } from '@kbn/xstate-utils';
import { useActor } from '@xstate/react';
import React, { useMemo } from 'react';
import { discoverLinkTitle } from '../../common/translations';
import {
ObservabilityLogExplorerService,
useObservabilityLogExplorerPageStateContext,
} from '../state_machines/observability_log_explorer/src';
import { getRouterLinkProps } from '../utils/get_router_link_props';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
export const ConnectedDiscoverLink = React.memo(() => {
const {
services: { discover },
} = useKibanaContextForPlugin();
const [pageState] = useActor(useObservabilityLogExplorerPageStateContext());
if (pageState.matches({ initialized: 'validLogExplorerState' })) {
return <DiscoverLinkForValidState discover={discover} pageState={pageState} />;
} else {
return <DiscoverLinkForUnknownState />;
}
});
type InitializedPageState = MatchedStateFromActor<
ObservabilityLogExplorerService,
{ initialized: 'validLogExplorerState' }
>;
export const DiscoverLinkForValidState = React.memo(
({
discover,
pageState: {
context: { logExplorerState },
},
}: {
discover: DiscoverStart;
pageState: InitializedPageState;
}) => {
const discoverLinkParams = useMemo<DiscoverAppLocatorParams>(
() => ({
breakdownField: logExplorerState.chart.breakdownField ?? undefined,
columns: getDiscoverColumnsFromDisplayOptions(logExplorerState),
filters: logExplorerState.filters,
query: logExplorerState.query,
refreshInterval: logExplorerState.refreshInterval,
timeRange: logExplorerState.time,
dataViewSpec: hydrateDatasetSelection(logExplorerState.datasetSelection).toDataviewSpec(),
}),
[logExplorerState]
);
return <DiscoverLink discover={discover} discoverLinkParams={discoverLinkParams} />;
}
);
export const DiscoverLinkForUnknownState = React.memo(() => (
<EuiHeaderLink
color="primary"
iconType="discoverApp"
data-test-subj="logExplorerDiscoverFallbackLink"
disabled
>
{discoverLinkTitle}
</EuiHeaderLink>
));
export const DiscoverLink = React.memo(
({
discover,
discoverLinkParams,
}: {
discover: DiscoverStart;
discoverLinkParams: DiscoverAppLocatorParams;
}) => {
const discoverUrl = discover.locator?.getRedirectUrl(discoverLinkParams);
const navigateToDiscover = () => {
discover.locator?.navigate(discoverLinkParams);
};
const discoverLinkProps = getRouterLinkProps({
href: discoverUrl,
onClick: navigateToDiscover,
});
return (
<EuiHeaderLink
{...discoverLinkProps}
color="primary"
iconType="discoverApp"
data-test-subj="logExplorerDiscoverFallbackLink"
>
{discoverLinkTitle}
</EuiHeaderLink>
);
}
);

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHeaderLink } from '@elastic/eui';
import { LOG_EXPLORER_FEEDBACK_LINK } from '@kbn/observability-shared-plugin/common';
import React from 'react';
import { feedbackLinkTitle } from '../../common/translations';
export const FeedbackLink = React.memo(() => {
return (
<EuiHeaderLink
color="primary"
href={LOG_EXPLORER_FEEDBACK_LINK}
iconType="popout"
iconSide="right"
iconSize="s"
target="_blank"
>
{feedbackLinkTitle}
</EuiHeaderLink>
);
});

View file

@ -5,74 +5,39 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import deepEqual from 'fast-deep-equal';
import useObservable from 'react-use/lib/useObservable';
import { type BehaviorSubject, distinctUntilChanged, filter, take } from 'rxjs';
import styled from '@emotion/styled';
import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public';
import {
EuiBetaBadge,
EuiButton,
EuiHeader,
EuiHeaderLink,
EuiHeaderLinks,
EuiHeaderSection,
EuiHeaderSectionItem,
} from '@elastic/eui';
import { LogExplorerStateContainer } from '@kbn/log-explorer-plugin/public';
import {
OBSERVABILITY_ONBOARDING_LOCATOR,
ObservabilityOnboardingLocatorParams,
} from '@kbn/deeplinks-observability/locators';
import { KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { css } from '@emotion/react';
import { LOG_EXPLORER_FEEDBACK_LINK } from '@kbn/observability-shared-plugin/common';
import styled from '@emotion/styled';
import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { euiThemeVars } from '@kbn/ui-theme';
import { LogExplorerTabs } from '@kbn/discover-plugin/public';
import { PluginKibanaContextValue } from '../utils/use_kibana';
import {
betaBadgeDescription,
betaBadgeTitle,
discoverLinkTitle,
feedbackLinkTitle,
onboardingLinkTitle,
} from '../../common/translations';
import { getRouterLinkProps } from '../utils/get_router_link_props';
import { ObservabilityLogExplorerAppMountParameters } from '../types';
import React, { useEffect, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { filter, take } from 'rxjs';
import { betaBadgeDescription, betaBadgeTitle } from '../../common/translations';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
import { ConnectedDiscoverLink } from './discover_link';
import { FeedbackLink } from './feedback_link';
import { ConnectedOnboardingLink } from './onboarding_link';
interface LogExplorerTopNavMenuProps {
setHeaderActionMenu: ObservabilityLogExplorerAppMountParameters['setHeaderActionMenu'];
services: KibanaReactContextValue<PluginKibanaContextValue>['services'];
state$: BehaviorSubject<LogExplorerStateContainer>;
theme$: ObservabilityLogExplorerAppMountParameters['theme$'];
}
export const LogExplorerTopNavMenu = () => {
const {
services: { serverless },
} = useKibanaContextForPlugin();
export const LogExplorerTopNavMenu = ({
setHeaderActionMenu,
services,
state$,
theme$,
}: LogExplorerTopNavMenuProps) => {
const { serverless } = services;
return Boolean(serverless) ? (
<ServerlessTopNav services={services} state$={state$} />
) : (
<StatefulTopNav
services={services}
setHeaderActionMenu={setHeaderActionMenu}
state$={state$}
theme$={theme$}
/>
);
return Boolean(serverless) ? <ServerlessTopNav /> : <StatefulTopNav />;
};
const ServerlessTopNav = ({
services,
state$,
}: Pick<LogExplorerTopNavMenuProps, 'services' | 'state$'>) => {
const ServerlessTopNav = () => {
const { services } = useKibanaContextForPlugin();
return (
<EuiHeader data-test-subj="logExplorerHeaderMenu" css={{ boxShadow: 'none' }}>
<EuiHeaderSection>
@ -97,38 +62,40 @@ const ServerlessTopNav = ({
</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
<EuiHeaderLinks gutterSize="xs">
<DiscoverLink services={services} state$={state$} />
<ConnectedDiscoverLink />
<FeedbackLink />
</EuiHeaderLinks>
<VerticalRule />
</EuiHeaderSectionItem>
<EuiHeaderSectionItem>
<OnboardingLink services={services} />
<ConnectedOnboardingLink />
</EuiHeaderSectionItem>
</EuiHeaderSection>
</EuiHeader>
);
};
const StatefulTopNav = ({
setHeaderActionMenu,
services,
state$,
theme$,
}: LogExplorerTopNavMenuProps) => {
const StatefulTopNav = () => {
const {
services: {
appParams: { setHeaderActionMenu },
chrome,
i18n,
theme,
},
} = useKibanaContextForPlugin();
/**
* Since the breadcrumbsAppendExtension might be set only during a plugin start (e.g. search session)
* we retrieve the latest valid extension in order to restore it once we unmount the beta badge.
*/
const [previousAppendExtension$] = useState(() =>
services.chrome.getBreadcrumbsAppendExtension$().pipe(filter(Boolean), take(1))
chrome.getBreadcrumbsAppendExtension$().pipe(filter(Boolean), take(1))
);
const previousAppendExtension = useObservable(previousAppendExtension$);
useEffect(() => {
const { chrome, i18n, theme } = services;
if (chrome) {
chrome.setBreadcrumbsAppendExtension({
content: toMountPoint(
@ -161,15 +128,15 @@ const StatefulTopNav = ({
chrome.setBreadcrumbsAppendExtension(previousAppendExtension);
}
};
}, [services, previousAppendExtension]);
}, [chrome, i18n, previousAppendExtension, theme]);
return (
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme.theme$}>
<EuiHeaderSection data-test-subj="logExplorerHeaderMenu">
<EuiHeaderSectionItem>
<EuiHeaderLinks gutterSize="xs">
<DiscoverLink services={services} state$={state$} />
<OnboardingLink services={services} />
<ConnectedDiscoverLink />
<ConnectedOnboardingLink />
</EuiHeaderLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>
@ -177,113 +144,8 @@ const StatefulTopNav = ({
);
};
const DiscoverLink = React.memo(
({ services, state$ }: Pick<LogExplorerTopNavMenuProps, 'services' | 'state$'>) => {
const discoverLinkParams = useDiscoverLinkParams(state$);
const discoverUrl = services.discover.locator?.getRedirectUrl(discoverLinkParams);
const navigateToDiscover = () => {
services.discover.locator?.navigate(discoverLinkParams);
};
const discoverLinkProps = getRouterLinkProps({
href: discoverUrl,
onClick: navigateToDiscover,
});
return (
<EuiHeaderLink
{...discoverLinkProps}
color="primary"
data-test-subj="logExplorerDiscoverFallbackLink"
>
{discoverLinkTitle}
</EuiHeaderLink>
);
}
);
const OnboardingLink = React.memo(({ services }: Pick<LogExplorerTopNavMenuProps, 'services'>) => {
const locator = services.share.url.locators.get<ObservabilityOnboardingLocatorParams>(
OBSERVABILITY_ONBOARDING_LOCATOR
);
const onboardingUrl = locator?.useUrl({});
const navigateToOnboarding = () => {
locator?.navigate({});
};
const onboardingLinkProps = getRouterLinkProps({
href: onboardingUrl,
onClick: navigateToOnboarding,
});
return (
<EuiButton
{...onboardingLinkProps}
fill
size="s"
iconType="indexOpen"
data-test-subj="logExplorerOnboardingLink"
>
{onboardingLinkTitle}
</EuiButton>
);
});
const FeedbackLink = React.memo(() => {
return (
<EuiHeaderLink
color="primary"
href={LOG_EXPLORER_FEEDBACK_LINK}
iconType="popout"
iconSide="right"
iconSize="s"
target="_blank"
>
{feedbackLinkTitle}
</EuiHeaderLink>
);
});
const VerticalRule = styled.span`
width: 1px;
height: 20px;
background-color: ${euiThemeVars.euiColorLightShade};
`;
const useDiscoverLinkParams = (state$: BehaviorSubject<LogExplorerStateContainer>) => {
const { appState, logExplorerState } = useObservable<LogExplorerStateContainer>(
state$.pipe(
distinctUntilChanged<LogExplorerStateContainer>((prev, curr) => {
if (!prev.appState || !curr.appState) return false;
return deepEqual(
[
prev.appState.columns,
prev.appState.sort,
prev.appState.filters,
prev.appState.index,
prev.appState.query,
],
[
curr.appState.columns,
curr.appState.sort,
curr.appState.filters,
curr.appState.index,
curr.appState.query,
]
);
})
),
{ appState: {}, logExplorerState: {} }
);
return {
columns: appState?.columns,
sort: appState?.sort,
filters: appState?.filters,
query: appState?.query,
dataViewSpec: logExplorerState?.datasetSelection?.selection.dataset.toDataviewSpec(),
};
};

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton } from '@elastic/eui';
import {
ObservabilityOnboardingLocatorParams,
OBSERVABILITY_ONBOARDING_LOCATOR,
} from '@kbn/deeplinks-observability/locators';
import { BrowserUrlService } from '@kbn/share-plugin/public';
import React from 'react';
import { onboardingLinkTitle } from '../../common/translations';
import { getRouterLinkProps } from '../utils/get_router_link_props';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
export const ConnectedOnboardingLink = React.memo(() => {
const {
services: {
share: { url },
},
} = useKibanaContextForPlugin();
return <OnboardingLink urlService={url} />;
});
export const OnboardingLink = React.memo(({ urlService }: { urlService: BrowserUrlService }) => {
const locator = urlService.locators.get<ObservabilityOnboardingLocatorParams>(
OBSERVABILITY_ONBOARDING_LOCATOR
);
const onboardingUrl = locator?.useUrl({});
const navigateToOnboarding = () => {
locator?.navigate({});
};
const onboardingLinkProps = getRouterLinkProps({
href: onboardingUrl,
onClick: navigateToOnboarding,
});
return (
<EuiButton
{...onboardingLinkProps}
fill
size="s"
iconType="indexOpen"
data-test-subj="logExplorerOnboardingLink"
>
{onboardingLinkTitle}
</EuiButton>
);
});

View file

@ -7,23 +7,27 @@
import { EuiPageSectionProps } from '@elastic/eui';
import { css } from '@emotion/react';
import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public';
import React from 'react';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
export const ObservabilityLogExplorerPageTemplate = ({
children,
observabilityShared,
pageProps,
}: React.PropsWithChildren<{
observabilityShared: ObservabilitySharedPluginStart;
pageProps?: EuiPageSectionProps;
}>) => (
<observabilityShared.navigation.PageTemplate
pageSectionProps={{ ...pageSectionProps, ...pageProps }}
>
{children}
</observabilityShared.navigation.PageTemplate>
);
}>) => {
const {
services: { observabilityShared },
} = useKibanaContextForPlugin();
return (
<observabilityShared.navigation.PageTemplate
pageSectionProps={{ ...pageSectionProps, ...pageProps }}
>
{children}
</observabilityShared.navigation.PageTemplate>
);
};
const fullHeightContentStyles = css`
display: flex;

View file

@ -13,6 +13,9 @@ import type { LogAIAssistantDocument } from '@kbn/logs-shared-plugin/public';
import React, { useMemo } from 'react';
import { useKibanaContextForPlugin } from '../utils/use_kibana';
type RenderFlyoutContentCustomization =
Required<LogExplorerCustomizations>['flyout']['renderContent'];
const ObservabilityLogAIAssistant = ({ doc }: LogExplorerFlyoutContentProps) => {
const { services } = useKibanaContextForPlugin();
const { LogAIAssistant } = services.logsShared;
@ -22,19 +25,17 @@ const ObservabilityLogAIAssistant = ({ doc }: LogExplorerFlyoutContentProps) =>
return <LogAIAssistant key={doc.id} doc={mappedDoc} />;
};
export const renderFlyoutContent: Required<LogExplorerCustomizations>['flyout']['renderContent'] = (
renderPreviousContent,
props
) => {
return (
<>
{renderPreviousContent()}
<EuiFlexItem>
<ObservabilityLogAIAssistant {...props} />
</EuiFlexItem>
</>
);
};
export const renderFlyoutContent: RenderFlyoutContentCustomization =
(renderPreviousContent) => (props) => {
return (
<>
{renderPreviousContent(props)}
<EuiFlexItem>
<ObservabilityLogAIAssistant {...props} />
</EuiFlexItem>
</>
);
};
/**
* Utils

View file

@ -5,11 +5,18 @@
* 2.0.
*/
import { LogExplorerCustomizations } from '@kbn/log-explorer-plugin/public';
import { CreateLogExplorerController } from '@kbn/log-explorer-plugin/public';
import { renderFlyoutContent } from './flyout_content';
export const createLogExplorerCustomizations = (): LogExplorerCustomizations => ({
flyout: {
renderContent: renderFlyoutContent,
},
});
export const createLogExplorerControllerWithCustomizations =
(createLogExplorerController: CreateLogExplorerController): CreateLogExplorerController =>
(args) =>
createLogExplorerController({
...args,
customizations: {
...args.customizations,
flyout: {
renderContent: renderFlyoutContent,
},
},
});

View file

@ -13,15 +13,14 @@ import {
Plugin,
PluginInitializerContext,
} from '@kbn/core/public';
import { OBSERVABILITY_LOG_EXPLORER } from '@kbn/deeplinks-observability';
import { OBSERVABILITY_LOG_EXPLORER_APP_ID } from '@kbn/deeplinks-observability';
import {
AllDatasetsLocatorDefinition,
ObservabilityLogExplorerLocators,
SingleDatasetLocatorDefinition,
AllDatasetsLocatorDefinition,
} from '../common/locators';
import { type ObservabilityLogExplorerConfig } from '../common/plugin_config';
import { logExplorerAppTitle } from '../common/translations';
import { renderObservabilityLogExplorer } from './applications/observability_log_explorer';
import type {
ObservabilityLogExplorerAppMountParameters,
ObservabilityLogExplorerPluginSetup,
@ -48,7 +47,7 @@ export class ObservabilityLogExplorerPlugin
const useHash = core.uiSettings.get('state:storeInSessionStorage');
core.application.register({
id: OBSERVABILITY_LOG_EXPLORER,
id: OBSERVABILITY_LOG_EXPLORER_APP_ID,
title: logExplorerAppTitle,
category: DEFAULT_APP_CATEGORIES.observability,
euiIconType: 'logoLogging',
@ -59,6 +58,9 @@ export class ObservabilityLogExplorerPlugin
keywords: ['logs', 'log', 'explorer', 'logs explorer'],
mount: async (appMountParams: ObservabilityLogExplorerAppMountParameters) => {
const [coreStart, pluginsStart, ownPluginStart] = await core.getStartServices();
const { renderObservabilityLogExplorer } = await import(
'./applications/observability_log_explorer'
);
return renderObservabilityLogExplorer(
coreStart,

View file

@ -19,7 +19,7 @@ export interface DatasetQualityRouteProps {
export const DatasetQualityRoute = ({ core }: DatasetQualityRouteProps) => {
const { services } = useKibanaContextForPlugin();
const { observabilityShared, serverless, datasetQuality: DatasetQuality } = services;
const { serverless, datasetQuality: DatasetQuality } = services;
const breadcrumb: EuiBreadcrumb[] = [
{
text: datasetQualityAppTitle,
@ -29,13 +29,8 @@ export const DatasetQualityRoute = ({ core }: DatasetQualityRouteProps) => {
useBreadcrumbs(breadcrumb, core.chrome, serverless);
return (
<>
<ObservabilityLogExplorerPageTemplate
observabilityShared={observabilityShared}
pageProps={{ paddingSize: 'l' }}
>
<DatasetQuality.DatasetQuality />
</ObservabilityLogExplorerPageTemplate>
</>
<ObservabilityLogExplorerPageTemplate pageProps={{ paddingSize: 'l' }}>
<DatasetQuality.DatasetQuality />
</ObservabilityLogExplorerPageTemplate>
);
};

View file

@ -4,52 +4,106 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreStart } from '@kbn/core/public';
import React, { useMemo, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type {
LogExplorerController,
LogExplorerPluginStart,
} from '@kbn/log-explorer-plugin/public';
import { useActor } from '@xstate/react';
import React, { useMemo } from 'react';
import { LogExplorerTopNavMenu } from '../../components/log_explorer_top_nav_menu';
import { ObservabilityLogExplorerPageTemplate } from '../../components/page_template';
import { noBreadcrumbs, useBreadcrumbs } from '../../utils/breadcrumbs';
import { useKibanaContextForPlugin } from '../../utils/use_kibana';
import { ObservabilityLogExplorerAppMountParameters } from '../../types';
import { createLogExplorerControllerWithCustomizations } from '../../log_explorer_customizations';
import {
ObservabilityLogExplorerPageStateProvider,
useObservabilityLogExplorerPageStateContext,
} from '../../state_machines/observability_log_explorer/src';
import { LazyOriginInterpreter } from '../../state_machines/origin_interpreter/src/lazy_component';
import { createLogExplorerCustomizations } from '../../log_explorer_customizations';
export interface ObservablityLogExplorerMainRouteProps {
appParams: ObservabilityLogExplorerAppMountParameters;
core: CoreStart;
}
import { ObservabilityLogExplorerHistory } from '../../types';
import { noBreadcrumbs, useBreadcrumbs } from '../../utils/breadcrumbs';
import { useKbnUrlStateStorageFromRouterContext } from '../../utils/kbn_url_state_context';
import { useKibanaContextForPlugin } from '../../utils/use_kibana';
export const ObservablityLogExplorerMainRoute = ({
appParams,
core,
}: ObservablityLogExplorerMainRouteProps) => {
export const ObservabilityLogExplorerMainRoute = () => {
const { services } = useKibanaContextForPlugin();
const { logExplorer, observabilityShared, serverless } = services;
useBreadcrumbs(noBreadcrumbs, core.chrome, serverless);
const { logExplorer, serverless, chrome, notifications, appParams } = services;
const { history } = appParams;
const { history, setHeaderActionMenu, theme$ } = appParams;
useBreadcrumbs(noBreadcrumbs, chrome, serverless);
const [state$] = useState(() => new BehaviorSubject({}));
const urlStateStorageContainer = useKbnUrlStateStorageFromRouterContext();
const customizations = useMemo(() => createLogExplorerCustomizations(), []);
const createLogExplorerController = useMemo(
() => createLogExplorerControllerWithCustomizations(logExplorer.createLogExplorerController),
[logExplorer.createLogExplorerController]
);
return (
<>
<LogExplorerTopNavMenu
setHeaderActionMenu={setHeaderActionMenu}
services={services}
state$={state$}
theme$={theme$}
/>
<LazyOriginInterpreter history={history} toasts={core.notifications.toasts} />
<ObservabilityLogExplorerPageTemplate observabilityShared={observabilityShared}>
<logExplorer.LogExplorer
customizations={customizations}
scopedHistory={history}
state$={state$}
/>
</ObservabilityLogExplorerPageTemplate>
</>
<ObservabilityLogExplorerPageStateProvider
createLogExplorerController={createLogExplorerController}
toasts={notifications.toasts}
urlStateStorageContainer={urlStateStorageContainer}
timeFilterService={services.data.query.timefilter.timefilter}
>
<LogExplorerTopNavMenu />
<LazyOriginInterpreter history={history} toasts={notifications.toasts} />
<ConnectedContent />
</ObservabilityLogExplorerPageStateProvider>
);
};
const ConnectedContent = React.memo(() => {
const {
services: {
appParams: { history },
logExplorer,
},
} = useKibanaContextForPlugin();
const [state] = useActor(useObservabilityLogExplorerPageStateContext());
if (state.matches('initialized')) {
return (
<InitializedContent
logExplorerController={state.context.controller}
history={history}
logExplorer={logExplorer}
/>
);
} else {
return <InitializingContent />;
}
});
const InitializingContent = React.memo(() => (
<ObservabilityLogExplorerPageTemplate>
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoKibana" size="xl" />}
title={
<FormattedMessage
id="xpack.observabilityLogExplorer.InitializingTitle"
defaultMessage="Initializing the Log Explorer"
/>
}
/>
</ObservabilityLogExplorerPageTemplate>
));
const InitializedContent = React.memo(
({
history,
logExplorer,
logExplorerController,
}: {
history: ObservabilityLogExplorerHistory;
logExplorer: LogExplorerPluginStart;
logExplorerController: LogExplorerController;
}) => {
return (
<ObservabilityLogExplorerPageTemplate>
<logExplorer.LogExplorer controller={logExplorerController} scopedHistory={history} />
</ObservabilityLogExplorerPageTemplate>
);
}
);

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CreateLogExplorerController } from '@kbn/log-explorer-plugin/public';
import type { InvokeCreator } from 'xstate';
import type { ObservabilityLogExplorerContext, ObservabilityLogExplorerEvent } from './types';
export const createController =
({
createLogExplorerController,
}: {
createLogExplorerController: CreateLogExplorerController;
}): InvokeCreator<ObservabilityLogExplorerContext, ObservabilityLogExplorerEvent> =>
(context, event) =>
(send) => {
createLogExplorerController({
initialState: context.initialLogExplorerState,
}).then((controller) => {
send({
type: 'CONTROLLER_CREATED',
controller,
});
});
};
export const subscribeToLogExplorerState: InvokeCreator<
ObservabilityLogExplorerContext,
ObservabilityLogExplorerEvent
> = (context, event) => (send) => {
if (!('controller' in context)) {
throw new Error('Failed to subscribe to controller: no controller in context');
}
const { controller } = context;
const subscription = controller.state$.subscribe({
next: (state) => {
send({ type: 'LOG_EXPLORER_STATE_CHANGED', state });
},
});
controller.service.start();
return () => {
subscription.unsubscribe();
controller.service.stop();
};
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CommonObservabilityLogExplorerContext } from './types';
export const DEFAULT_CONTEXT: CommonObservabilityLogExplorerContext = {
initialLogExplorerState: {},
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './provider';
export * from './state_machine';
export * from './types';

Some files were not shown because too many files have changed in this diff Show more