chore(investigate): Add investigate-app plugin from poc (#188122)

This commit is contained in:
Kevin Delemme 2024-07-23 11:44:32 -04:00 committed by GitHub
parent 3c97fbaac5
commit aa67c800ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 6100 additions and 1 deletions

1
.github/CODEOWNERS vendored
View file

@ -507,6 +507,7 @@ x-pack/plugins/integration_assistant @elastic/security-solution
src/plugins/interactive_setup @elastic/kibana-security
test/interactive_setup_api_integration/plugins/test_endpoints @elastic/kibana-security
packages/kbn-interpreter @elastic/kibana-visualizations
x-pack/plugins/observability_solution/investigate_app @elastic/obs-ai-assistant
x-pack/plugins/observability_solution/investigate @elastic/obs-ux-management-team
packages/kbn-io-ts-utils @elastic/obs-knowledge-team
packages/kbn-ipynb @elastic/search-kibana

View file

@ -647,6 +647,10 @@ the infrastructure monitoring use-case within Kibana.
|undefined
|{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/investigate_app/README.md[investigateApp]
|undefined
|{kib-repo}blob/{branch}/x-pack/plugins/kubernetes_security/README.md[kubernetesSecurity]
|This plugin provides interactive visualizations of your Kubernetes workload and session data.

View file

@ -545,6 +545,7 @@
"@kbn/interactive-setup-plugin": "link:src/plugins/interactive_setup",
"@kbn/interactive-setup-test-endpoints-plugin": "link:test/interactive_setup_api_integration/plugins/test_endpoints",
"@kbn/interpreter": "link:packages/kbn-interpreter",
"@kbn/investigate-app-plugin": "link:x-pack/plugins/observability_solution/investigate_app",
"@kbn/investigate-plugin": "link:x-pack/plugins/observability_solution/investigate",
"@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils",
"@kbn/ipynb": "link:packages/kbn-ipynb",
@ -1058,6 +1059,7 @@
"he": "^1.2.0",
"history": "^4.9.0",
"hjson": "3.2.1",
"html2canvas": "^1.4.1",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.1",
"i18n-iso-countries": "^4.3.1",

View file

@ -24,6 +24,8 @@ export const SLO_APP_ID = 'slo';
export const AI_ASSISTANT_APP_ID = 'observabilityAIAssistant';
export const INVESTIGATE_APP_ID = 'investigate';
export const OBLT_UX_APP_ID = 'ux';
export const OBLT_PROFILING_APP_ID = 'profiling';

View file

@ -12,6 +12,7 @@ import { strictKeysRt } from '.';
import { jsonRt } from '../json_rt';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { isoToEpochRt } from '../iso_to_epoch_rt';
import { toBooleanRt } from '../to_boolean_rt';
describe('strictKeysRt', () => {
it('correctly and deeply validates object keys', () => {
@ -238,6 +239,33 @@ describe('strictKeysRt', () => {
});
});
it('deals with union types', () => {
const type = t.intersection([
t.type({
required: t.string,
}),
t.partial({
disable: t.union([
toBooleanRt,
t.type({
except: t.array(t.string),
}),
]),
}),
]);
const value = {
required: 'required',
disable: {
except: ['foo'],
},
};
const asStrictType = strictKeysRt(type);
expect(isRight(asStrictType.decode(value))).toBe(true);
});
it('does not support piped types', () => {
const typeA = t.type({
query: t.type({ filterNames: jsonRt.pipe(t.array(t.string)) }),

View file

@ -8,10 +8,23 @@
import * as t from 'io-ts';
export function isPrimitive(value: unknown): value is string | number | boolean | null | undefined {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
);
}
export const toBooleanRt = new t.Type<boolean, boolean, unknown>(
'ToBoolean',
t.boolean.is,
(input) => {
(input, context) => {
if (!isPrimitive(input)) {
return t.failure(input, context);
}
let value: boolean;
if (typeof input === 'string') {
value = input === 'true';

View file

@ -86,6 +86,7 @@ pageLoadAssetSize:
integrationAssistant: 19524
interactiveSetup: 80000
investigate: 17970
investigateApp: 91898
kibanaOverview: 56279
kibanaReact: 74422
kibanaUsageCollection: 16463

View file

@ -44,6 +44,7 @@ export const storybookAliases = {
grouping: 'packages/kbn-grouping/.storybook',
home: 'src/plugins/home/.storybook',
infra: 'x-pack/plugins/observability_solution/infra/.storybook',
investigate: 'x-pack/plugins/observability_solution/investigate_app/.storybook',
kibana_react: 'src/plugins/kibana_react/.storybook',
lists: 'x-pack/plugins/lists/.storybook',
logs_explorer: 'x-pack/plugins/observability_solution/logs_explorer/.storybook',

View file

@ -152,6 +152,7 @@ export const applicationUsageSchema = {
fleet: commonSchema,
integrations: commonSchema,
ingestManager: commonSchema,
investigate: commonSchema,
lens: commonSchema,
maps: commonSchema,
ml: commonSchema,

View file

@ -4325,6 +4325,137 @@
}
}
},
"investigate": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"lens": {
"properties": {
"appId": {

View file

@ -1008,6 +1008,8 @@
"@kbn/interactive-setup-test-endpoints-plugin/*": ["test/interactive_setup_api_integration/plugins/test_endpoints/*"],
"@kbn/interpreter": ["packages/kbn-interpreter"],
"@kbn/interpreter/*": ["packages/kbn-interpreter/*"],
"@kbn/investigate-app-plugin": ["x-pack/plugins/observability_solution/investigate_app"],
"@kbn/investigate-app-plugin/*": ["x-pack/plugins/observability_solution/investigate_app/*"],
"@kbn/investigate-plugin": ["x-pack/plugins/observability_solution/investigate"],
"@kbn/investigate-plugin/*": ["x-pack/plugins/observability_solution/investigate/*"],
"@kbn/io-ts-utils": ["packages/kbn-io-ts-utils"],

View file

@ -55,6 +55,7 @@
"xpack.ingestPipelines": "plugins/ingest_pipelines",
"xpack.integrationAssistant": "plugins/integration_assistant",
"xpack.investigate": "plugins/observability_solution/investigate",
"xpack.investigateApp": "plugins/observability_solution/investigate_app",
"xpack.kubernetesSecurity": "plugins/kubernetes_security",
"xpack.lens": "plugins/lens",
"xpack.licenseApiGuard": "plugins/license_api_guard",

View file

@ -0,0 +1,34 @@
/*
* 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 { isPlainObject, mergeWith } from 'lodash';
type DeepOverwrite<T, U> = T extends Record<string, any>
? Omit<T, keyof U> & {
[TKey in keyof U]: T extends Record<TKey, any> ? DeepOverwrite<T[TKey], U[TKey]> : U[TKey];
}
: U;
type DeepPartialPlainObjects<T> = T extends Record<string, any>
? Partial<{
[TKey in keyof T]: DeepPartialPlainObjects<T[TKey]>;
}>
: T;
function mergePlainObjectsOnly<T, U>(val: T, src: U): DeepOverwrite<T, U> {
if (isPlainObject(src)) {
return mergeWith({}, val, src, mergePlainObjectsOnly) as DeepOverwrite<T, U>;
}
return src as DeepOverwrite<T, U>;
}
export function extendProps<
T extends Record<string, any> | undefined,
U extends DeepPartialPlainObjects<T>
>(props: T, extension: U): DeepOverwrite<T, U> {
return mergePlainObjectsOnly(props, extension);
}

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 React, { useMemo } from 'react';
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import type { ESQLSearchResponse } from '@kbn/es-types';
import type { DataView } from '@kbn/data-views-plugin/common';
import { coreMock } from '@kbn/core/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { SearchBar, IUnifiedSearchPluginServices } from '@kbn/unified-search-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { merge } from 'lodash';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { EsqlQueryMeta } from '../public/services/esql';
import type { InvestigateAppServices } from '../public/services/types';
import { InvestigateAppKibanaContext } from '../public/hooks/use_kibana';
export function getMockInvestigateAppContext(): DeeplyMockedKeys<InvestigateAppKibanaContext> {
const services: DeeplyMockedKeys<InvestigateAppServices> = {
esql: {
meta: jest.fn().mockImplementation((): Promise<EsqlQueryMeta> => {
return Promise.resolve({
suggestions: [],
columns: [],
dataView: {} as DataView,
});
}),
query: jest.fn().mockImplementation((): Promise<ESQLSearchResponse> => {
return Promise.resolve({
values: [],
columns: [],
});
}),
queryWithMeta: jest
.fn()
.mockImplementation((): Promise<{ meta: EsqlQueryMeta; query: ESQLSearchResponse }> => {
return Promise.resolve({
meta: {
suggestions: [],
columns: [],
dataView: {} as DataView,
},
query: {
values: [],
columns: [],
},
});
}),
},
};
const core = coreMock.createStart();
const dataMock = merge({}, dataPluginMock.createStartContract(), {
query: {
savedQueries: {},
timefilter: {
timefilter: {
getTime: () => ({ from: 'now-15m', to: 'now', mode: 'relative' }),
},
},
},
});
return {
core: core as any,
dependencies: {
start: {
data: dataMock,
unifiedSearch: merge({}, unifiedSearchPluginMock.createStartContract(), {
ui: {
SearchBar: function SearchBarWithContext(props: {}) {
const unifiedSearchServices = useMemo(() => {
return {
data: dataMock,
storage: new Storage(window.localStorage),
uiSettings: core.uiSettings,
} as unknown as IUnifiedSearchPluginServices;
}, []);
return (
<KibanaContextProvider services={unifiedSearchServices}>
<SearchBar {...props} />
</KibanaContextProvider>
);
},
},
}),
embeddable: merge({}, embeddablePluginMock.createStartContract(), {
getEmbeddableFactories: () => [
{
canCreateNew: () => true,
getDisplayName: () => 'Alerts',
type: 'alerts',
},
],
}),
investigate: {},
lens: {},
observabilityShared: {},
dataViews: dataViewPluginMocks.createStartContract(),
},
} as any,
services,
};
}

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.
*/
import { setGlobalConfig } from '@storybook/testing-react';
import * as globalStorybookConfig from './preview';
setGlobalConfig(globalStorybookConfig);

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.
*/
module.exports = require('@kbn/storybook').defaultConfig;

View file

@ -0,0 +1,16 @@
/*
* 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 { setKibanaServices } from '@kbn/esql/public/kibana_services';
import { coreMock } from '@kbn/core/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
setKibanaServices(
coreMock.createStart(),
dataViewPluginMocks.createStartContract(),
expressionsPluginMock.createStartContract()
);

View file

@ -0,0 +1,13 @@
/*
* 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 { EuiThemeProviderDecorator } from '@kbn/kibana-react-plugin/common';
import * as jest from 'jest-mock';
window.jest = jest;
export const decorators = [EuiThemeProviderDecorator];

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ComponentType, useMemo } from 'react';
import { InvestigateAppContextProvider } from '../public/components/investigate_app_context_provider';
import { getMockInvestigateAppContext } from './get_mock_investigate_app_services';
export function KibanaReactStorybookDecorator(Story: ComponentType) {
const context = useMemo(() => getMockInvestigateAppContext(), []);
return (
<InvestigateAppContextProvider context={context}>
<Story />
</InvestigateAppContextProvider>
);
}

View file

@ -0,0 +1,24 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: [
'<rootDir>/x-pack/plugins/observability_solution/investigate_app/public',
'<rootDir>/x-pack/plugins/observability_solution/investigate_app/server',
],
setupFiles: [
'<rootDir>/x-pack/plugins/observability_solution/investigate_app/.storybook/jest_setup.js',
],
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/observability_solution/investigate_app/{public,server}/**/*.{js,ts,tsx}',
],
coverageReporters: ['html'],
};

View file

@ -0,0 +1,32 @@
{
"type": "plugin",
"id": "@kbn/investigate-app-plugin",
"owner": "@elastic/obs-ai-assistant",
"plugin": {
"id": "investigateApp",
"server": true,
"browser": true,
"configPath": ["xpack", "investigateApp"],
"requiredPlugins": [
"investigate",
"observabilityAIAssistant",
"observabilityShared",
"lens",
"dataViews",
"data",
"embeddable",
"contentManagement",
"datasetQuality",
"unifiedSearch",
"security",
],
"requiredBundles": [
"kibanaReact",
"kibanaUtils",
"esql",
"esqlDataGrid",
],
"optionalPlugins": [],
"extraPublicDirs": []
}
}

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 type { CoreSetup, CoreStart, HttpFetchOptions } from '@kbn/core/public';
import type {
ClientRequestParamsOf,
ReturnOf,
RouteRepositoryClient,
} from '@kbn/server-route-repository';
import { formatRequest } from '@kbn/server-route-repository/src/format_request';
import type { InvestigateAppServerRouteRepository } from '../../server';
type FetchOptions = Omit<HttpFetchOptions, 'body'> & {
body?: any;
};
export type InvestigateAppAPIClientOptions = Omit<
FetchOptions,
'query' | 'body' | 'pathname' | 'signal'
> & {
signal: AbortSignal | null;
};
export type InvestigateAppAPIClient = RouteRepositoryClient<
InvestigateAppServerRouteRepository,
InvestigateAppAPIClientOptions
>;
export type AutoAbortedInvestigateAppAPIClient = RouteRepositoryClient<
InvestigateAppServerRouteRepository,
Omit<InvestigateAppAPIClientOptions, 'signal'>
>;
export type InvestigateAppAPIEndpoint = keyof InvestigateAppServerRouteRepository;
export type APIReturnType<TEndpoint extends InvestigateAppAPIEndpoint> = ReturnOf<
InvestigateAppServerRouteRepository,
TEndpoint
>;
export type InvestigateAppAPIClientRequestParamsOf<TEndpoint extends InvestigateAppAPIEndpoint> =
ClientRequestParamsOf<InvestigateAppServerRouteRepository, TEndpoint>;
export function createCallInvestigateAppAPI(core: CoreStart | CoreSetup) {
return ((endpoint, options) => {
const { params } = options as unknown as {
params?: Partial<Record<string, any>>;
};
const { method, pathname, version } = formatRequest(endpoint, params?.path);
return core.http[method](pathname, {
...options,
body: params && params.body ? JSON.stringify(params.body) : undefined,
query: params?.query,
version,
});
}) as InvestigateAppAPIClient;
}

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 type { CoreStart, CoreTheme } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { History } from 'history';
import React, { useMemo } from 'react';
import type { Observable } from 'rxjs';
import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config';
import type { InvestigateAppStartDependencies } from './types';
import { investigateRouter } from './routes/config';
import { InvestigateAppKibanaContext } from './hooks/use_kibana';
import { InvestigateAppServices } from './services/types';
import { InvestigateAppContextProvider } from './components/investigate_app_context_provider';
function Application({
coreStart,
history,
pluginsStart,
theme$,
services,
}: {
coreStart: CoreStart;
history: History;
pluginsStart: InvestigateAppStartDependencies;
theme$: Observable<CoreTheme>;
services: InvestigateAppServices;
}) {
const theme = useMemo(() => {
return { theme$ };
}, [theme$]);
const context: InvestigateAppKibanaContext = useMemo(
() => ({
core: coreStart,
dependencies: {
start: pluginsStart,
},
services,
}),
[coreStart, pluginsStart, services]
);
return (
<KibanaThemeProvider theme={theme}>
<InvestigateAppContextProvider context={context}>
<RedirectAppLinks coreStart={coreStart}>
<coreStart.i18n.Context>
<RouterProvider history={history} router={investigateRouter as any}>
<RouteRenderer />
</RouterProvider>
</coreStart.i18n.Context>
</RedirectAppLinks>
</InvestigateAppContextProvider>
</KibanaThemeProvider>
);
}
export { Application };

View file

@ -0,0 +1,66 @@
/*
* 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 { Meta, StoryObj } from '@storybook/react';
import moment from 'moment';
import React from 'react';
import { InvestigationRevision } from '@kbn/investigate-plugin/common';
import { AddWidgetUI as Component } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
interface Args {
props: React.ComponentProps<typeof Component>;
}
type StoryMeta = Meta<Args>;
type Story = StoryObj<Args>;
const meta: StoryMeta = {
component: Component,
title: 'app/Molecules/AddWidgetUI',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultStory: Story = {
args: {
props: {
start: moment().subtract(15, 'minutes'),
end: moment(),
onWidgetAdd: async () => {},
revision: {
items: [],
} as unknown as InvestigationRevision,
user: {
username: 'johndoe',
full_name: 'John Doe',
},
filters: [],
query: {
language: 'kuery',
query: '',
},
timeRange: {
from: moment().subtract(15, 'minutes').toISOString(),
to: moment().toISOString(),
},
workflowBlocks: [],
},
},
render: function Render(args) {
return <Component {...args.props} />;
},
};
export const InvestigateSearchBarStory: Story = {
...defaultStory,
args: {
...defaultStory.args,
},
name: 'default',
};

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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/core/public';
import type {
GlobalWidgetParameters,
InvestigateWidgetCreate,
InvestigationRevision,
OnWidgetAdd,
WorkflowBlock,
} from '@kbn/investigate-plugin/public';
import { assertNever } from '@kbn/std';
import { Moment } from 'moment';
import React, { useState } from 'react';
import { AddWidgetMode } from '../../constants/add_widget_mode';
import { useWorkflowBlocks } from '../../hooks/workflow_blocks/use_workflow_blocks';
import { EsqlWidgetControl } from '../esql_widget_control';
import { NoteWidgetControl } from '../note_widget_control';
type AddWidgetUIProps = {
user: Pick<AuthenticatedUser, 'full_name' | 'username'>;
onWidgetAdd: OnWidgetAdd;
revision: InvestigationRevision;
start: Moment;
end: Moment;
workflowBlocks: WorkflowBlock[];
} & GlobalWidgetParameters;
function getControlsForMode({
user,
mode,
onWidgetAdd,
revision,
start,
end,
query,
timeRange,
filters,
}: {
user: Pick<AuthenticatedUser, 'full_name' | 'username'>;
mode: AddWidgetMode;
onWidgetAdd: (widget: InvestigateWidgetCreate) => Promise<void>;
revision: InvestigationRevision;
start: Moment;
end: Moment;
} & GlobalWidgetParameters) {
switch (mode) {
case AddWidgetMode.Esql:
return (
<EsqlWidgetControl
onWidgetAdd={onWidgetAdd}
timeRange={timeRange}
query={query}
filters={filters}
/>
);
case AddWidgetMode.Note:
return <NoteWidgetControl user={user} onWidgetAdd={onWidgetAdd} />;
default:
assertNever(mode);
}
}
export function AddWidgetUI({
user,
onWidgetAdd,
revision,
start,
end,
query,
filters,
timeRange,
workflowBlocks,
}: AddWidgetUIProps) {
const [mode] = useState(AddWidgetMode.Note);
const workflowBlocksControl = useWorkflowBlocks({
start: start.toISOString(),
end: end.toISOString(),
dynamicBlocks: workflowBlocks,
isTimelineEmpty: revision.items.length === 0,
onWidgetAdd,
});
return (
<EuiFlexGroup direction="column" gutterSize="s">
{workflowBlocksControl ? (
<EuiFlexItem grow={false}>{workflowBlocksControl}</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
{getControlsForMode({
mode,
onWidgetAdd,
revision,
start,
end,
query,
filters,
timeRange,
user,
})}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiErrorBoundary } from '@elastic/eui';
import React from 'react';
function ThrowError({ error }: { error: Error }) {
throw error;
return <></>;
}
export function ErrorMessage({ error }: { error: Error }) {
return (
<EuiErrorBoundary>
<ThrowError error={error} />
</EuiErrorBoundary>
);
}

View file

@ -0,0 +1,245 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import {
InvestigateWidgetColumnSpan,
InvestigateWidgetCreate,
WorkflowBlock,
} from '@kbn/investigate-plugin/public';
import {
createEsqlWidget,
ESQL_WIDGET_NAME,
GlobalWidgetParameters,
OnWidgetAdd,
} from '@kbn/investigate-plugin/public';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public';
import { noop } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import type { ESQLColumn, ESQLRow } from '@kbn/es-types';
import { css } from '@emotion/css';
import type { DataView } from '@kbn/data-views-plugin/common';
import { useKibana } from '../../hooks/use_kibana';
import { getEsFilterFromOverrides } from '../../utils/get_es_filter_from_overrides';
import { EsqlWidget } from '../../widgets/esql_widget/register_esql_widget';
import { SuggestVisualizationList } from '../suggest_visualization_list';
import { ErrorMessage } from '../error_message';
import { getDateHistogramResults } from '../../widgets/esql_widget/get_date_histogram_results';
function getWidgetFromSuggestion({
query,
suggestion,
}: {
query: string;
suggestion: Suggestion;
}): InvestigateWidgetCreate {
const makeItWide = suggestion.visualizationId !== 'lnsMetric';
const makeItTall = suggestion.visualizationId !== 'lnsMetric';
let rows = makeItTall ? 12 : 4;
if (suggestion.visualizationId === 'lnsDatatable') {
rows = 18;
}
return createEsqlWidget({
title: suggestion.title,
type: ESQL_WIDGET_NAME,
parameters: {
esql: query,
suggestion,
},
columns: makeItWide ? InvestigateWidgetColumnSpan.Four : InvestigateWidgetColumnSpan.One,
rows,
locked: false,
});
}
function PreviewContainer({ children }: { children: React.ReactNode }) {
return (
<EuiFlexGroup
direction="row"
alignItems="center"
justifyContent="center"
className={css`
width: 100%;
overflow: auto;
> div {
width: 100%;
}
`}
>
{children}
</EuiFlexGroup>
);
}
export function EsqlWidgetPreview({
esqlQuery,
onWidgetAdd,
filters,
timeRange,
query,
}: {
esqlQuery: string;
onWidgetAdd: OnWidgetAdd;
} & GlobalWidgetParameters) {
const {
services: { esql },
} = useKibana();
const filter = useMemo(() => {
return getEsFilterFromOverrides({
filters,
timeRange,
query,
});
}, [filters, timeRange, query]);
const [selectedSuggestion, setSelectedSuggestion] = useState<Suggestion | undefined>(undefined);
const queryResult = useAbortableAsync(
async ({ signal }) => {
return await esql.queryWithMeta({ signal, query: esqlQuery, filter }).then((result) => {
setSelectedSuggestion((prevSuggestion) => {
const mostSimilarSuggestion =
result.meta.suggestions.find(
(suggestion) => suggestion.visualizationId === prevSuggestion?.visualizationId
) || result.meta.suggestions[0];
return mostSimilarSuggestion;
});
return result;
});
},
[esqlQuery, filter, esql]
);
const dateHistoResponse = useAbortableAsync(
({ signal }) => {
if (!queryResult.value || queryResult.loading || !selectedSuggestion) {
return undefined;
}
return getDateHistogramResults({
columns: queryResult.value.query.columns,
esql,
filter,
query: esqlQuery,
signal,
suggestion: selectedSuggestion,
timeRange,
});
},
[queryResult, esql, filter, esqlQuery, selectedSuggestion, timeRange]
);
const fakeRenderApi = useMemo(() => {
return {
blocks: {
publish: (_blocks: WorkflowBlock[]) => {
return noop;
},
},
};
}, []);
const [displayedProps, setDisplayedProps] = useState<
{
error: Error | undefined;
loading: boolean;
} & (
| {
value: {
columns: ESQLColumn[];
values: ESQLRow[];
allColumns?: ESQLColumn[];
dataView: DataView;
suggestions: Array<Suggestion & { id: string }>;
};
}
| { value: undefined }
)
>({
loading: true,
value: undefined,
error: undefined,
});
useEffect(() => {
setDisplayedProps((prevDisplayedProps) => {
if (queryResult.loading) {
return {
...prevDisplayedProps,
loading: true,
error: undefined,
};
}
return {
error: queryResult.error,
loading: queryResult.loading,
value: queryResult.value
? {
columns: queryResult.value.query.columns,
values: queryResult.value.query.values,
allColumns: queryResult.value.query.all_columns,
dataView: queryResult.value.meta.dataView,
suggestions: queryResult.value.meta.suggestions,
}
: undefined,
};
});
}, [queryResult]);
if (displayedProps.error) {
return (
<PreviewContainer>
<ErrorMessage error={displayedProps.error} />
</PreviewContainer>
);
}
if (!displayedProps.value || !selectedSuggestion) {
return (
<PreviewContainer>
<EuiLoadingSpinner />
</PreviewContainer>
);
}
return (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<PreviewContainer>
<EsqlWidget
blocks={fakeRenderApi.blocks}
suggestion={selectedSuggestion}
columns={displayedProps.value.columns}
allColumns={displayedProps.value.allColumns}
values={displayedProps.value.values}
dataView={displayedProps.value.dataView}
esqlQuery={esqlQuery}
dateHistogramResults={dateHistoResponse.value}
/>
</PreviewContainer>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SuggestVisualizationList
suggestions={displayedProps.value.suggestions}
onSuggestionClick={(suggestion) => {
onWidgetAdd(getWidgetFromSuggestion({ query: esqlQuery, suggestion }));
}}
loading={queryResult.loading}
onMouseLeave={() => {}}
onSuggestionRollOver={(suggestion) => {
setSelectedSuggestion(suggestion);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { EsqlWidgetControl as Component } from '.';
import '../../../.storybook/mock_kibana_services';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Organisms/EsqlControlWidget',
decorators: [KibanaReactStorybookDecorator],
};
function WithContainer(props: React.ComponentProps<typeof Component>) {
return (
<div style={{ display: 'flex', flex: 1 }}>
<Component {...props} />
</div>
);
}
export default meta;
const defaultProps: ComponentStoryObj<typeof Component> = {
render: WithContainer,
};
export const EsqlControlStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
name: 'default',
};

View file

@ -0,0 +1,107 @@
/*
* 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 { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui';
import { css } from '@emotion/css';
import { GlobalWidgetParameters, OnWidgetAdd } from '@kbn/investigate-plugin/public';
import { TextBasedLangEditor } from '@kbn/esql/public';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EsqlWidgetPreview } from './esql_widget_preview';
const editorContainerClassName = css`
.kibanaCodeEditor {
width: 100%;
}
.monaco-editor {
position: absolute !important;
}
> div {
margin: 0;
}
`;
type EsqlWidgetControlProps = {
onWidgetAdd: OnWidgetAdd;
} & GlobalWidgetParameters;
export function EsqlWidgetControl({
onWidgetAdd,
filters,
timeRange,
query,
}: EsqlWidgetControlProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [esqlQuery, setEsqlQuery] = useState('FROM *');
const [submittedEsqlQuery, setSubmittedEsqlQuery] = useState(esqlQuery);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPanel hasBorder hasShadow={false}>
<EuiAccordion
id="esql_widget_preview"
forceState={isPreviewOpen ? 'open' : 'closed'}
onToggle={(nextIsOpen) => {
setIsPreviewOpen(nextIsOpen);
}}
buttonContent={
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.investigateApp.esqlWidgetControl.previewResultsLabel', {
defaultMessage: 'Preview results',
})}
</h2>
</EuiTitle>
}
>
<EsqlWidgetPreview
filters={filters}
esqlQuery={submittedEsqlQuery}
timeRange={timeRange}
query={query}
onWidgetAdd={(widget) => {
setIsPreviewOpen(false);
return onWidgetAdd(widget);
}}
/>
</EuiAccordion>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false} className={editorContainerClassName}>
<TextBasedLangEditor
query={{ esql: esqlQuery }}
onTextLangQueryChange={(nextQuery) => {
setIsPreviewOpen(true);
setEsqlQuery(nextQuery.esql);
}}
onTextLangQuerySubmit={async (nextSubmittedQuery) => {
setSubmittedEsqlQuery(nextSubmittedQuery?.esql ?? '');
}}
errors={undefined}
warning={undefined}
expandCodeEditor={(expanded: boolean) => {
setIsExpanded(() => expanded);
}}
isCodeEditorExpanded={isExpanded}
hideMinimizeButton={false}
editorIsInline
hideRunQueryText
isLoading={false}
disableSubmitAction
isDisabled={false}
hideQueryHistory
hideTimeFilterInfo
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,79 @@
/*
* 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 { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { v4 } from 'uuid';
import { GridItem as Component } from '.';
import { extendProps } from '../../../.storybook/extend_props';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
type Props = React.ComponentProps<typeof Component>;
interface Args {
props: Partial<Props> & { id: string; children: React.ReactNode };
}
type StoryMeta = Meta<Args>;
type Story = StoryObj<Args>;
const meta: StoryMeta = {
component: Component,
title: 'app/Molecules/GridItem',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultProps: Story = {
args: {
props: {
id: v4(),
children: <>TODO</>,
},
},
render: ({ props }) => {
return (
<div style={{ width: 800, height: 600 }}>
<Component
faded={false}
locked={false}
loading={false}
onCopy={() => {}}
onDelete={() => {}}
onLockToggle={() => {}}
onOverrideRemove={async () => {}}
onTitleChange={() => {}}
overrides={[]}
title="My visualization"
description="A long description"
onEditClick={() => {}}
{...props}
/>
</div>
);
},
};
export const GridItemStory: Story = {
...defaultProps,
args: {
props: extendProps(defaultProps.args!.props!, {
title: 'A widget title',
children: <>TODO</>,
description:
'An even longer description that should flow off screen especially if there are overrides defined',
overrides: [
{
id: 'query',
label: `service.name:opbeans-java AND service.environment:(production OR development)`,
},
],
}),
},
name: 'default',
};

View file

@ -0,0 +1,219 @@
/*
* 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import classNames from 'classnames';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useTheme } from '../../hooks/use_theme';
import { InvestigateTextButton } from '../investigate_text_button';
import { InvestigateWidgetGridItemOverride } from '../investigate_widget_grid';
export const GRID_ITEM_HEADER_HEIGHT = 40;
interface GridItemProps {
id: string;
title: string;
description: string;
children: React.ReactNode;
locked: boolean;
onCopy: () => void;
onTitleChange: (title: string) => void;
onDelete: () => void;
onLockToggle: () => void;
loading: boolean;
faded: boolean;
onOverrideRemove: (override: InvestigateWidgetGridItemOverride) => Promise<void>;
onEditClick: () => void;
overrides: InvestigateWidgetGridItemOverride[];
}
const editTitleButtonClassName = `investigateGridItemTitleEditButton`;
const titleContainerClassName = css`
overflow: hidden;
`;
const titleItemClassName = css`
max-width: 100%;
.euiText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
const fadedClassName = css`
opacity: 0.5 !important;
`;
const lockedControlClassName = css`
opacity: 0.9 !important;
&:hover {
opacity: 1 !important;
}
`;
const panelContainerClassName = css`
overflow: clip;
overflow-clip-margin: 20px;
`;
const panelClassName = css`
overflow-y: auto;
`;
const panelContentClassName = css`
overflow-y: auto;
height: 100%;
> [data-shared-item] {
height: 100%;
}
`;
const headerClassName = css`
height: ${GRID_ITEM_HEADER_HEIGHT}px;
`;
const changeBadgeClassName = css`
max-width: 96px;
.euiText {
text-overflow: ellipsis;
overflow: hidden;
}
`;
export function GridItem({
id,
title,
description,
children,
locked,
onLockToggle,
onDelete,
onCopy,
loading,
faded,
overrides,
onOverrideRemove,
onEditClick,
}: GridItemProps) {
const theme = useTheme();
const containerClassName = css`
height: 100%;
max-width: 100%;
transition: opacity ${theme.animation.normal} ${theme.animation.resistance};
overflow: auto;
&:not(:hover) .${editTitleButtonClassName} {
opacity: 0;
}
`;
return (
<EuiFlexGroup
direction="column"
gutterSize="none"
className={faded ? classNames(containerClassName, fadedClassName) : containerClassName}
alignItems="stretch"
>
<EuiFlexItem grow={false}>
<EuiFlexGroup
direction="row"
gutterSize="m"
alignItems="center"
className={headerClassName}
>
<EuiFlexItem className={titleContainerClassName}>
<EuiText size="s" className={titleItemClassName}>
{title}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{overrides.length ? (
<EuiFlexGroup direction="row" gutterSize="xs" justifyContent="flexStart">
{overrides.map((override) => (
<EuiFlexItem key={override.id} grow={false}>
<EuiBadge
color="primary"
className={changeBadgeClassName}
iconType="cross"
iconSide="right"
iconOnClick={() => {
onOverrideRemove(override);
}}
iconOnClickAriaLabel={i18n.translate(
'xpack.investigateApp.gridItem.removeOverrideButtonAriaLabel',
{
defaultMessage: 'Remove filter',
}
)}
>
<EuiText size="xs">{override.label}</EuiText>
</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false} className="gridItemControls">
<EuiFlexGroup
direction="row"
gutterSize="xs"
alignItems="center"
justifyContent="flexEnd"
>
<EuiFlexItem grow={false}>
<InvestigateTextButton
iconType="copy"
onClick={() => {
onCopy();
}}
disabled={loading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InvestigateTextButton
iconType="trash"
onClick={() => {
onDelete();
}}
disabled={loading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InvestigateTextButton
iconType="pencil"
onClick={() => {
onEditClick();
}}
disabled={loading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InvestigateTextButton
iconType={locked ? 'lock' : 'lockOpen'}
className={locked ? lockedControlClassName : ''}
color={locked ? 'primary' : 'text'}
onClick={() => {
onLockToggle();
}}
disabled={loading}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow className={panelContainerClassName}>
<EuiPanel hasBorder hasShadow={false} className={panelClassName}>
<div className={panelContentClassName}>{children}</div>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { InvestigateAppKibanaContext } from '../../hooks/use_kibana';
export function InvestigateAppContextProvider({
context,
children,
}: {
context: InvestigateAppKibanaContext;
children: React.ReactNode;
}) {
return <KibanaContextProvider services={context}>{children}</KibanaContextProvider>;
}

View file

@ -0,0 +1,39 @@
/*
* 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 { css } from '@emotion/css';
import React from 'react';
import { useKibana } from '../hooks/use_kibana';
const pageSectionContentClassName = css`
width: 100%;
display: flex;
flex-grow: 1;
max-block-size: calc(100vh - 96px);
`;
export function InvestigatePageTemplate({ children }: { children: React.ReactNode }) {
const {
dependencies: {
start: { observabilityShared },
},
} = useKibana();
const { PageTemplate } = observabilityShared.navigation;
return (
<PageTemplate
children={children}
pageSectionProps={{
alignment: 'horizontalCenter',
contentProps: {
className: pageSectionContentClassName,
},
paddingSize: 'none',
}}
/>
);
}

View file

@ -0,0 +1,74 @@
/*
* 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 { EuiButtonEmpty, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import classNames from 'classnames';
import React from 'react';
const buttonClassName = css`
opacity: 0.5;
&:disabled,
&:hover {
opacity: 1;
}
&:disabled {
color: inherit;
}
`;
const buttonOnlyClassName = css`
.euiButtonEmpty__content {
gap: 0;
}
`;
interface InvestigateTextButtonProps {
iconType: string;
disabled?: boolean;
onClick: () => void;
onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>;
onMouseLeave?: React.MouseEventHandler<HTMLButtonElement>;
children?: string;
className?: string;
type?: 'submit' | 'reset' | 'button';
color?: React.ComponentProps<typeof EuiButtonEmpty>['color'];
size?: 'xs' | 's' | 'm';
iconSize?: 's' | 'm';
}
export function InvestigateTextButton({
iconType,
disabled,
onClick,
children,
onMouseEnter,
onMouseLeave,
className,
type,
color = 'text',
size = 's',
iconSize = 's',
}: InvestigateTextButtonProps) {
const props = {
size,
iconSize,
iconType,
color,
disabled,
className: classNames(buttonClassName, className, {
[buttonOnlyClassName]: !children,
}),
onClick,
onMouseEnter,
onMouseLeave,
type,
children: children ? <EuiText size="xs">{children}</EuiText> : undefined,
};
return <EuiButtonEmpty data-test-subj="investigateTextButton" {...props} />;
}

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 { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { InvestigateView as Component } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Organisms/InvestigateView',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultProps: ComponentStoryObj<typeof Component> = {
args: {},
render: (props) => <Component {...props} />,
};
export const InvestigateViewStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
name: 'default',
};

View file

@ -0,0 +1,246 @@
/*
* 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, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import type { InvestigateWidget, InvestigateWidgetCreate } from '@kbn/investigate-plugin/public';
import { DATE_FORMAT_ID } from '@kbn/management-settings-ids';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
import { keyBy, omit, pick } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { AddWidgetMode } from '../../constants/add_widget_mode';
import { useDateRange } from '../../hooks/use_date_range';
import { useKibana } from '../../hooks/use_kibana';
import { getOverridesFromGlobalParameters } from '../../utils/get_overrides_from_global_parameters';
import { AddWidgetUI } from '../add_widget_ui';
import { InvestigateWidgetGrid } from '../investigate_widget_grid';
const containerClassName = css`
overflow: auto;
padding: 24px 24px 0px 24px;
`;
const scrollContainerClassName = css`
min-width: 1px;
`;
const gridContainerClassName = css`
position: relative;
`;
const sideBarClassName = css`
width: 240px;
position: sticky;
top: 0;
padding: 0px 12px 32px 12px;
`;
function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
const {
core: { uiSettings },
dependencies: {
start: { investigate },
},
} = useKibana();
const [_displayedKuery, setDisplayedKuery] = useState('');
const widgetDefinitions = useMemo(() => investigate.getWidgetDefinitions(), [investigate]);
const [range, setRange] = useDateRange();
const {
addItem,
setItemPositions,
setItemTitle,
blocks,
copyItem,
deleteItem,
investigation,
lockItem,
setItemParameters,
unlockItem,
revision,
} = investigate.useInvestigation({
user,
from: range.start.toISOString(),
to: range.end.toISOString(),
});
const [_editingItem, setEditingItem] = useState<InvestigateWidget | undefined>(undefined);
const createWidget = (widgetCreate: InvestigateWidgetCreate) => {
return addItem(widgetCreate);
};
const createWidgetRef = useRef(createWidget);
createWidgetRef.current = createWidget;
useEffect(() => {
const itemIds = revision?.items.map((item) => item.id) ?? [];
setEditingItem((prevEditingItem) => {
if (prevEditingItem && !itemIds.includes(prevEditingItem.id)) {
return undefined;
}
return prevEditingItem;
});
}, [revision]);
useEffect(() => {
setDisplayedKuery(revision?.parameters.query.query ?? '');
}, [revision?.parameters.query.query]);
useEffect(() => {
if (
revision?.parameters.timeRange.from &&
revision?.parameters.timeRange.to &&
range.start.toISOString() !== revision.parameters.timeRange.from &&
range.end.toISOString() !== revision.parameters.timeRange.to
) {
setRange({
from: revision.parameters.timeRange.from,
to: revision.parameters.timeRange.to,
});
}
}, [
revision?.parameters.timeRange.from,
revision?.parameters.timeRange.to,
range.start,
range.end,
setRange,
]);
const gridItems = useMemo(() => {
const widgetDefinitionsByType = keyBy(widgetDefinitions, 'type');
return revision?.items.map((item) => {
const definitionForType = widgetDefinitionsByType[item.type];
return (
{
title: item.title,
description: item.description ?? '',
id: item.id,
element: item.element,
columns: item.columns,
rows: item.rows,
chrome: definitionForType.chrome,
locked: item.locked,
loading: item.loading,
overrides: item.locked
? getOverridesFromGlobalParameters(
pick(item.parameters, 'filters', 'query', 'timeRange'),
revision.parameters,
uiSettings.get<string>(DATE_FORMAT_ID) ?? 'Browser'
)
: [],
} ?? []
);
});
}, [revision, widgetDefinitions, uiSettings]);
const [searchBarFocused] = useState(false);
if (!investigation || !revision || !gridItems) {
return <EuiLoadingSpinner />;
}
return (
<EuiFlexGroup direction="row" className={containerClassName}>
<EuiFlexItem grow className={scrollContainerClassName}>
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="flexEnd">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem className={gridContainerClassName} grow={false}>
<InvestigateWidgetGrid
items={gridItems}
onItemsChange={async (nextGridItems) => {
return setItemPositions(
nextGridItems.map((gridItem) => ({
columns: gridItem.columns,
rows: gridItem.rows,
id: gridItem.id,
}))
);
}}
onItemTitleChange={async (item, title) => {
return setItemTitle(item.id, title);
}}
onItemCopy={async (copiedItem) => {
return copyItem(copiedItem.id);
}}
onItemDelete={async (deletedItem) => {
return deleteItem(deletedItem.id);
}}
onItemLockToggle={async (toggledItem) => {
return toggledItem.locked ? unlockItem(toggledItem.id) : lockItem(toggledItem.id);
}}
fadeLockedItems={searchBarFocused}
onItemOverrideRemove={async (updatedItem, override) => {
// TODO: remove filters
const itemToUpdate = revision.items.find((item) => item.id === updatedItem.id);
if (itemToUpdate) {
return setItemParameters(updatedItem.id, {
...revision.parameters,
...omit(itemToUpdate.parameters, override.id),
});
}
}}
onItemEditClick={(itemToEdit) => {
setEditingItem(revision.items.find((item) => item.id === itemToEdit.id));
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddWidgetUI
workflowBlocks={blocks}
user={user}
revision={revision}
start={range.start}
end={range.end}
filters={revision.parameters.filters}
query={revision.parameters.query}
timeRange={revision.parameters.timeRange}
onWidgetAdd={(widget) => {
return createWidgetRef.current(widget);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={false} key={AddWidgetMode.Esql}>
<EuiButton data-test-subj="investigateAppInvestigateViewWithUserAddAnObservationChartButton">
{i18n.translate(
'xpack.investigateApp.investigateViewWithUser.addAnObservationChartButtonLabel',
{ defaultMessage: 'Add an observation chart' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false} className={sideBarClassName}>
{i18n.translate(
'xpack.investigateApp.investigateViewWithUser.placeholderForRightSidebarFlexItemLabel',
{ defaultMessage: 'placeholder for right sidebar' }
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
export function InvestigateView({}: {}) {
const {
core: { security },
} = useKibana();
const user = useAsync(() => {
return security.authc.getCurrentUser();
}, [security]);
return user.value ? <InvestigateViewWithUser user={user.value} /> : null;
}

View file

@ -0,0 +1,144 @@
/*
* 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 { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { v4 } from 'uuid';
import { ChromeOption } from '@kbn/investigate-plugin/public';
import { InvestigateWidgetGrid as Component, InvestigateWidgetGridItem } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
import { TimelineUserPrompt, TimelineAssistantResponse } from '../timeline_message';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Organisms/InvestigateWidgetGrid',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
function WithPersistedChanges(props: React.ComponentProps<typeof Component>) {
const [items, setItems] = useState(props.items);
return (
<Component
{...props}
onItemsChange={async (nextItems) => {
setItems(() => nextItems);
}}
onItemCopy={async (item) => {
setItems((prevItems) =>
prevItems.concat({
...item,
id: v4(),
})
);
}}
onItemDelete={async (item) => {
setItems((prevItems) => prevItems.filter((currentItem) => currentItem.id !== item.id));
}}
items={items}
/>
);
}
const defaultProps: ComponentStoryObj<typeof Component> = {
args: {},
render: (props) => (
<div style={{ maxWidth: 1200 }}>
<WithPersistedChanges {...props} />
</div>
),
};
function createItem<T extends Partial<InvestigateWidgetGridItem>>(overrides: T) {
return {
...overrides,
id: v4(),
columns: 4,
rows: 2,
description: '',
locked: false,
loading: false,
overrides: [],
};
}
export const InvestigateWidgetGridStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
...defaultProps.args,
items: [
createItem({
title: '5',
description: '',
element: (
<TimelineUserPrompt
prompt="I asked for something"
user={{ username: 'me' }}
onDelete={() => {}}
/>
),
columns: 4,
rows: 2,
chrome: ChromeOption.disabled,
}),
createItem({
title: '1',
element: (
<div style={{ backgroundColor: 'red', height: 1200, width: 1200 }}>
This should not overflow
</div>
),
columns: 4,
rows: 12,
locked: true,
}),
createItem({
title: '5',
element: (
<TimelineAssistantResponse
content="I gave you something in response"
onDelete={() => {}}
/>
),
columns: 4,
rows: 2,
chrome: ChromeOption.disabled,
}),
createItem({
title: '2',
element: <>TODO</>,
columns: 2,
rows: 3,
overrides: [
{
id: v4(),
label: '4 hours earlier',
},
{
id: v4(),
label: 'service.name:opbeans-java AND service.enviroment:(production OR development)',
},
],
}),
createItem({
title: '3',
element: <>TODO</>,
columns: 2,
rows: 3,
}),
createItem({
title: '4',
element: <>TODO</>,
columns: 4,
rows: 3,
}),
],
},
name: 'default',
};

View file

@ -0,0 +1,390 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/css';
import { ChromeOption, InvestigateWidgetColumnSpan } from '@kbn/investigate-plugin/public';
import { keyBy, mapValues, orderBy } from 'lodash';
import React, { useCallback, useMemo, useRef } from 'react';
import { ItemCallback, Layout, Responsive, WidthProvider } from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { EuiBreakpoint, EUI_BREAKPOINTS, useBreakpoints } from '../../hooks/use_breakpoints';
import { useTheme } from '../../hooks/use_theme';
import { GridItem, GRID_ITEM_HEADER_HEIGHT } from '../grid_item';
import './styles.scss';
const gridContainerClassName = css`
position: relative;
.react-resizable-handle-ne,
.react-resizable-handle-nw {
top: calc(${GRID_ITEM_HEADER_HEIGHT}px) !important;
}
`;
interface SingleComponentSection {
item: InvestigateWidgetGridItem;
}
interface GridSection {
items: InvestigateWidgetGridItem[];
}
type Section = SingleComponentSection | GridSection;
export interface InvestigateWidgetGridItemOverride {
id: string;
label: React.ReactNode;
}
export interface InvestigateWidgetGridItem {
title: string;
description: string;
element: React.ReactNode;
id: string;
columns: number;
rows: number;
locked: boolean;
chrome?: ChromeOption;
loading: boolean;
overrides: InvestigateWidgetGridItemOverride[];
}
interface InvestigateWidgetGridProps {
items: InvestigateWidgetGridItem[];
onItemsChange: (items: InvestigateWidgetGridItem[]) => Promise<void>;
onItemCopy: (item: InvestigateWidgetGridItem) => Promise<void>;
onItemDelete: (item: InvestigateWidgetGridItem) => Promise<void>;
onItemLockToggle: (item: InvestigateWidgetGridItem) => Promise<void>;
onItemOverrideRemove: (
item: InvestigateWidgetGridItem,
override: InvestigateWidgetGridItemOverride
) => Promise<void>;
onItemTitleChange: (item: InvestigateWidgetGridItem, title: string) => Promise<void>;
onItemEditClick: (item: InvestigateWidgetGridItem) => void;
fadeLockedItems: boolean;
}
const ROW_HEIGHT = 32;
const BREAKPOINT_COLUMNS: Record<EuiBreakpoint, InvestigateWidgetColumnSpan> = {
[EUI_BREAKPOINTS.xs]: 1,
[EUI_BREAKPOINTS.s]: 1,
[EUI_BREAKPOINTS.m]: 4,
[EUI_BREAKPOINTS.l]: 4,
[EUI_BREAKPOINTS.xl]: 4,
};
const panelContainerClassName = css`
display: flex;
`;
function getResponsiveLayouts(
items: InvestigateWidgetGridItem[],
currentBreakpoint: EuiBreakpoint
) {
const nextLayouts: Layout[] = [];
let atColumn = 0;
let atRow = 0;
let rowHeight = 0;
const maxColumns = BREAKPOINT_COLUMNS[currentBreakpoint];
items.forEach((item) => {
const itemColumns = item.columns;
const itemRows = item.rows;
if (atColumn + itemColumns > maxColumns) {
atColumn = 0;
atRow += rowHeight;
rowHeight = 0;
}
nextLayouts.push({
i: item.id,
w: itemColumns,
h: itemRows,
x: atColumn,
y: atRow,
resizeHandles: ['ne', 'se'],
});
atColumn += itemColumns;
rowHeight = Math.max(itemRows, rowHeight);
});
return mapValues(EUI_BREAKPOINTS, () => nextLayouts);
}
const CONTAINER_PADDING: [number, number] = [0, 0];
function GridSectionRenderer({
items,
onItemsChange,
onItemDelete,
onItemCopy,
onItemLockToggle,
onItemOverrideRemove,
onItemTitleChange,
onItemEditClick,
fadeLockedItems,
}: InvestigateWidgetGridProps) {
const WithFixedWidth = useMemo(() => WidthProvider(Responsive), []);
const theme = useTheme();
const callbacks = {
onItemsChange,
onItemCopy,
onItemDelete,
onItemLockToggle,
onItemOverrideRemove,
onItemTitleChange,
onItemEditClick,
};
const itemCallbacksRef = useRef(callbacks);
itemCallbacksRef.current = callbacks;
const { currentBreakpoint } = useBreakpoints();
const layouts = useMemo(() => {
return getResponsiveLayouts(items, currentBreakpoint);
}, [items, currentBreakpoint]);
const gridElements = useMemo(() => {
return items.map((item) => (
<div key={item.id} className={panelContainerClassName}>
<GridItem
id={item.id}
title={item.title}
description={item.description}
onTitleChange={(title) => {
return itemCallbacksRef.current.onItemTitleChange(item, title);
}}
onCopy={() => {
return itemCallbacksRef.current.onItemCopy(item);
}}
onDelete={() => {
return itemCallbacksRef.current.onItemDelete(item);
}}
locked={item.locked}
onLockToggle={() => {
itemCallbacksRef.current.onItemLockToggle(item);
}}
onOverrideRemove={(override) => {
return itemCallbacksRef.current.onItemOverrideRemove(item, override);
}}
onEditClick={() => {
return itemCallbacksRef.current.onItemEditClick(item);
}}
overrides={item.overrides}
loading={item.loading}
faded={fadeLockedItems && item.locked}
>
{item.element}
</GridItem>
</div>
));
}, [items, fadeLockedItems]);
// react-grid calls `onLayoutChange` every time
// `layouts` changes, except when on mount. So...
// we do some gymnastics to skip the first call
// after a layout change
const prevLayouts = useRef(layouts);
const expectLayoutChangeCall = prevLayouts.current !== layouts;
prevLayouts.current = layouts;
const onLayoutChange = useMemo(() => {
let skipCall = expectLayoutChangeCall;
return (nextLayouts: Layout[]) => {
if (skipCall) {
skipCall = false;
return;
}
const itemsById = keyBy(items, (item) => item.id);
const sortedLayouts = orderBy(nextLayouts, ['y', 'x']);
const itemsInOrder = sortedLayouts.map((layout) => {
return itemsById[layout.i];
});
itemCallbacksRef.current.onItemsChange(itemsInOrder);
};
}, [items, expectLayoutChangeCall]);
const onResize: ItemCallback = useCallback(
(layout) => {
const itemsById = keyBy(items, (item) => item.id);
const itemsAfterResize = layout.map((layoutItem) => {
const gridItem = itemsById[layoutItem.i];
return {
...gridItem,
columns: Math.max(1, layoutItem.w),
rows: Math.max(1, layoutItem.h),
};
});
itemCallbacksRef.current.onItemsChange(itemsAfterResize);
},
[items]
);
return (
<WithFixedWidth
className={gridContainerClassName}
layouts={layouts}
breakpoints={theme.breakpoint}
breakpoint={currentBreakpoint || EUI_BREAKPOINTS.xl}
rowHeight={ROW_HEIGHT}
cols={BREAKPOINT_COLUMNS}
allowOverlap={false}
onLayoutChange={onLayoutChange}
onResizeStop={onResize}
compactType="vertical"
isBounded
containerPadding={CONTAINER_PADDING}
isDraggable={false}
isDroppable={false}
>
{gridElements}
</WithFixedWidth>
);
}
export function InvestigateWidgetGrid({
items,
onItemsChange,
onItemDelete,
onItemCopy,
onItemLockToggle,
fadeLockedItems,
onItemOverrideRemove,
onItemTitleChange,
onItemEditClick,
}: InvestigateWidgetGridProps) {
const sections = useMemo<Section[]>(() => {
let currentGrid: GridSection = { items: [] };
const allSections: Section[] = [currentGrid];
for (const item of items) {
if (item.chrome === ChromeOption.disabled || item.chrome === ChromeOption.static) {
const elementSection: SingleComponentSection = {
item,
};
allSections.push(elementSection);
currentGrid = { items: [] };
allSections.push(currentGrid);
} else {
currentGrid.items.push(item);
}
}
return allSections.filter((grid) => 'item' in grid || grid.items.length > 0);
}, [items]);
if (!sections.length) {
return null;
}
return (
<EuiFlexGroup direction="column" gutterSize="m">
{sections.map((section, index) => {
if ('items' in section) {
return (
<EuiFlexItem key={index} grow={false}>
<GridSectionRenderer
items={section.items}
onItemCopy={(copiedItem) => {
return onItemCopy(copiedItem);
}}
onItemDelete={(deletedItem) => {
return onItemDelete(deletedItem);
}}
onItemLockToggle={(toggledItem) => {
return onItemLockToggle(toggledItem);
}}
onItemsChange={(itemsInSection) => {
const nextItems = sections.flatMap((sectionAtIndex) => {
if ('item' in sectionAtIndex) {
return sectionAtIndex.item;
}
if (sectionAtIndex !== section) {
return sectionAtIndex.items;
}
return itemsInSection;
});
return onItemsChange(nextItems);
}}
onItemOverrideRemove={(item, override) => {
return onItemOverrideRemove(item, override);
}}
onItemTitleChange={(item, title) => {
return onItemTitleChange(item, title);
}}
onItemEditClick={(item) => {
return onItemEditClick(item);
}}
fadeLockedItems={fadeLockedItems}
/>
</EuiFlexItem>
);
}
return (
<EuiFlexItem grow={false} key={index}>
{section.item.chrome === ChromeOption.disabled ? (
section.item.element
) : (
<GridItem
id={section.item.id}
title={section.item.title}
description={section.item.description}
faded={section.item.locked && fadeLockedItems}
loading={section.item.loading}
locked={section.item.locked}
overrides={section.item.overrides}
onCopy={() => {
return onItemCopy(section.item);
}}
onDelete={() => {
return onItemDelete(section.item);
}}
onOverrideRemove={(override) => {
return onItemOverrideRemove(section.item, override);
}}
onTitleChange={(nextTitle) => {
return onItemTitleChange(section.item, nextTitle);
}}
onLockToggle={() => {
return onItemLockToggle(section.item);
}}
onEditClick={() => {
return onItemEditClick(section.item);
}}
>
{section.item.element}
</GridItem>
)}
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1 @@
@import '../../../../../../../src/plugins/dashboard/public/dashboard_container/dashboard_container';

View file

@ -0,0 +1,96 @@
/*
* 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 { Meta, StoryObj } from '@storybook/react';
import { merge } from 'lodash';
import React from 'react';
import { InvestigationHistory as Component } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
interface Args {
props: React.ComponentProps<typeof Component>;
}
type StoryMeta = Meta<Args>;
type Story = StoryObj<Args>;
const meta: StoryMeta = {
component: Component,
title: 'app/Molecules/InvestigationHistory',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultStory: Story = {
args: {
props: {
investigations: [],
error: undefined,
loading: false,
onDeleteInvestigationClick: () => {},
onInvestigationClick: () => {},
onStartNewInvestigationClick: () => {},
},
},
render: function Render(args) {
return (
<div style={{ width: 240 }}>
<Component {...args.props} />
</div>
);
},
};
export const WithInvestigationsStory: Story = {
...defaultStory,
args: merge({}, defaultStory.args, {
props: {
loading: false,
investigations: [
{
id: 'one',
title: 'My previous investigation',
},
{
id: 'two',
title: 'Another investigation',
},
{
id: 'three',
title: 'Blabla',
},
{
id: 'four',
title: 'A really really long title that shows how this component deals with overflow',
},
],
},
}),
name: 'default',
};
export const LoadingEmptyStory: Story = {
...defaultStory,
args: merge({}, defaultStory.args, {
props: {
loading: true,
},
}),
name: 'loading empty',
};
export const ErrorStory: Story = {
...defaultStory,
args: merge({}, defaultStory.args, {
props: {
loading: false,
error: new Error('Failed to load investigations'),
},
}),
name: 'error',
};

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiLink,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
import classNames from 'classnames';
import { Investigation } from '@kbn/investigate-plugin/common';
import { useTheme } from '../../hooks/use_theme';
import { InvestigateTextButton } from '../investigate_text_button';
const headerClassName = css`
text-transform: uppercase;
font-weight: 600;
`;
const investigationItemClassName = css`
max-width: 100%;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
`;
const newInvestigationItemClassName = css`
.euiText {
font-weight: 500 !important;
}
`;
function WrapWithHeader({ children, loading }: { children: React.ReactElement; loading: boolean }) {
return (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText size="xs" className={headerClassName} color="subdued">
{i18n.translate('xpack.investigateApp.investigationHistory.previously', {
defaultMessage: 'Previously',
})}
</EuiText>
</EuiFlexItem>
{loading ? (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}
export function InvestigationHistory({
investigations,
loading,
error,
onInvestigationClick,
onStartNewInvestigationClick,
onDeleteInvestigationClick,
}: {
investigations?: Array<Pick<Investigation, 'id' | 'title'>>;
loading: boolean;
error?: Error;
onInvestigationClick: (id: string) => void;
onStartNewInvestigationClick: () => void;
onDeleteInvestigationClick: (id: string) => void;
}) {
const theme = useTheme();
const investigationsList = (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem
grow={false}
className={classNames(investigationItemClassName, newInvestigationItemClassName)}
>
{}
<EuiLink
data-test-subj="investigateAppInvestigationHistoryLink"
onClick={() => {
onStartNewInvestigationClick();
}}
>
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="newChat" size="s" color={theme.colors.text} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color={theme.colors.text}>
{i18n.translate('xpack.investigateApp.investigationHistory.new', {
defaultMessage: 'New investigation',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</EuiFlexItem>
{investigations?.length ? (
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
</EuiFlexItem>
) : null}
{investigations?.map((investigation) => (
<EuiFlexItem key={investigation.id} grow={false} className={investigationItemClassName}>
<EuiFlexGroup direction="row" alignItems="center">
<EuiFlexItem
grow
className={css`
.deleteinvestigationbutton: {
visibility: hidden;
}
&:hover .deleteinvestigationbutton {
visibility: visible;
}
`}
>
<EuiLink
data-test-subj="investigateAppInvestigationsListLink"
onClick={() => {
onInvestigationClick(investigation.id);
}}
>
<EuiText size="s" color={theme.colors.text}>
{investigation.title}
</EuiText>
</EuiLink>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InvestigateTextButton
className="deleteinvestigationbutton"
iconType="trash"
onClick={() => {
onDeleteInvestigationClick(investigation.id);
}}
size="xs"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
if (error) {
return (
<WrapWithHeader loading={loading}>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type="warning" color="danger" size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="danger">
{error.message}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{investigationsList}</EuiFlexItem>
</EuiFlexGroup>
</WrapWithHeader>
);
}
return <WrapWithHeader loading={loading}>{investigationsList}</WrapWithHeader>;
}

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 { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { NoteWidget as Component } from '.';
import { extendProps } from '../../../.storybook/extend_props';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
interface Args {
props: Omit<React.ComponentProps<typeof Component>, 'onChange' | 'onDelete'>;
}
type StoryMeta = Meta<Args>;
type Story = StoryObj<Args>;
const meta: StoryMeta = {
component: Component,
title: 'app/Molecules/NoteWidget',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultStory: Story = {
args: {
props: {
user: {
username: 'johndoe',
full_name: 'John Doe',
},
note: 'A short note',
},
},
render: function Render(args) {
return (
<div style={{ width: 800, height: 600 }}>
<Component {...args.props} onChange={() => {}} onDelete={() => {}} />
</div>
);
},
};
export const ShortNoteStory: Story = {
...defaultStory,
args: {
props: extendProps(defaultStory.args!.props!, {
note: 'A short note',
}),
},
name: 'default',
};

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 { EuiAvatar } from '@elastic/eui';
import { AuthenticatedUser } from '@kbn/core/public';
import React from 'react';
import { useTheme } from '../../hooks/use_theme';
import { TimelineMessage } from '../timeline_message';
export function NoteWidget({
user,
note,
onDelete,
}: {
user: Pick<AuthenticatedUser, 'username' | 'full_name'>;
note: string;
onChange: (note: string) => void;
onDelete: () => void;
}) {
const theme = useTheme();
return (
<TimelineMessage
icon={<EuiAvatar name={user.username} size="s" />}
color={theme.colors.emptyShade}
content={note}
onDelete={onDelete}
/>
);
}

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 React, { useState } from 'react';
import { InvestigateWidgetCreate } from '@kbn/investigate-plugin/common';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { AuthenticatedUser } from '@kbn/core/public';
import { ResizableTextInput } from '../resizable_text_input';
import { createNoteWidget } from '../../widgets/note_widget/create_note_widget';
interface NoteWidgetControlProps {
user: Pick<AuthenticatedUser, 'full_name' | 'username'>;
onWidgetAdd: (widget: InvestigateWidgetCreate) => Promise<void>;
}
export function NoteWidgetControl({ user, onWidgetAdd }: NoteWidgetControlProps) {
const [note, setNote] = useState('');
const [loading, setLoading] = useState(false);
function submit() {
setLoading(false);
onWidgetAdd(
createNoteWidget({
title: note,
parameters: {
note,
user: {
username: user.username,
full_name: user.full_name,
},
},
})
)
.then(() => {
setNote('');
})
.finally(() => {
setLoading(false);
});
}
return (
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow>
<ResizableTextInput
placeholder={i18n.translate('xpack.investigateApp.noteWidgetControl.placeholder', {
defaultMessage: 'Add a note to the investigation',
})}
disabled={loading}
value={note}
onChange={(value) => {
setNote(value);
}}
onSubmit={() => {
submit();
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="investigateAppNoteWidgetControlButton"
aria-label={i18n.translate('xpack.investigateApp.noteWidgetControl.submitLabel', {
defaultMessage: 'Submit',
})}
disabled={loading || note.trim() === ''}
display="base"
iconType="kqlFunction"
isLoading={loading}
size="m"
onClick={() => {
submit();
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,30 @@
/*
* 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 { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { PreviewLensSuggestion as Component } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Molecules/PreviewLensSuggestion',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultProps: ComponentStoryObj<typeof Component> = {
args: {},
render: (props) => <Component {...props} />,
};
export const PreviewLensSuggestionStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {},
name: 'default',
};

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 React from 'react';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import { css } from '@emotion/css';
import { useKibana } from '../../hooks/use_kibana';
function Container({ children }: { children: React.ReactNode }) {
return (
<EuiFlexGroup direction="row" alignItems="center" justifyContent="center">
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>
);
}
const panelContainerClassName = css`
overflow: clip auto;
height: 100%;
`;
const panelContentClassName = css`
height: 100%;
overflow: clip auto;
> div {
height: 100%;
}
`;
export function PreviewLensSuggestion({
input,
loading,
error,
}: {
input: TypedLensByValueInput;
loading: boolean;
error?: Error;
}) {
const {
dependencies: {
start: { lens },
},
} = useKibana();
if (loading) {
return (
<Container>
<EuiLoadingSpinner />
</Container>
);
}
return (
<EuiPanel hasBorder={false} hasShadow={true} className={panelContainerClassName}>
<div className={panelContentClassName}>
<lens.EmbeddableComponent {...input} />
</div>
</EuiPanel>
);
}

View file

@ -0,0 +1,79 @@
/*
* 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 { EuiTextArea, keys } from '@elastic/eui';
import { css } from '@emotion/css';
import React, { useCallback, useEffect, useRef } from 'react';
interface Props {
placeholder: string;
disabled: boolean;
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
}
export function ResizableTextInput({ disabled, value, onChange, onSubmit, placeholder }: Props) {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
handleResizeTextArea();
onChange(event.target.value);
};
const handleResizeTextArea = useCallback(() => {
if (textAreaRef.current) {
textAreaRef.current.style.minHeight = 'auto';
const cappedHeight = Math.min(textAreaRef.current?.scrollHeight, 350);
textAreaRef.current.style.minHeight = cappedHeight + 'px';
}
}, []);
useEffect(() => {
const textarea = textAreaRef.current;
if (textarea) {
textarea.focus();
}
}, []);
useEffect(() => {
handleResizeTextArea();
}, [handleResizeTextArea]);
useEffect(() => {
if (value === undefined) {
handleResizeTextArea();
}
}, [handleResizeTextArea, value]);
return (
<EuiTextArea
data-test-subj="investigateAppResizableTextInputTextArea"
className={css`
max-height: 200;
padding: 8px 12px;
`}
disabled={disabled}
fullWidth
inputRef={textAreaRef}
placeholder={placeholder}
resize="vertical"
rows={1}
value={value}
onChange={handleChange}
onKeyDown={(event) => {
if (!event.shiftKey && event.key === keys.ENTER) {
event.preventDefault();
onSubmit();
}
}}
/>
);
}

View file

@ -0,0 +1,78 @@
/*
* 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 { Suggestion } from '@kbn/lens-plugin/public';
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { v4 } from 'uuid';
import { SuggestVisualizationList as Component } from '.';
import '../../../.storybook/mock_kibana_services';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
import { metricSuggestion, tableSuggestion, treemapSuggestion } from './suggestions.mock';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Molecules/SuggestVisualizationList',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
function mapWithIds(suggestions: Suggestion[]) {
return suggestions.map((suggestion) => ({ id: v4(), ...suggestion }));
}
const defaultProps: ComponentStoryObj<typeof Component> = {
render: (props) => {
return <Component {...props} />;
},
};
export const WithSuggestions: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
loading: false,
suggestions: mapWithIds([tableSuggestion, treemapSuggestion]),
},
name: 'With suggestions',
};
export const WithoutSuggestions: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
loading: false,
suggestions: [],
},
name: 'Without suggestions',
};
export const LoadingStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
loading: true,
suggestions: [],
},
name: 'Loading without suggestions',
};
export const LoadingWithSuggestionsStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
loading: true,
suggestions: mapWithIds([metricSuggestion, treemapSuggestion]),
},
name: 'Loading with suggestions',
};
export const ErrorStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
error: new Error('Network error'),
suggestions: [],
},
name: 'Error',
};

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { Suggestion } from '@kbn/lens-plugin/public';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
const containerClassName = css`
min-height: 32px;
`;
const suggestionClassName = css`
.euiText {
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-align: left;
}
span {
justify-content: flex-start;
}
`;
const iconContainerClassName = css`
display: flex;
align-items: center;
width: 16px;
`;
export function SuggestVisualizationList({
suggestions,
loading,
error,
onSuggestionClick,
onSuggestionRollOver,
onMouseLeave,
}: {
suggestions?: Array<Suggestion & { id: string }>;
loading: boolean;
error?: Error;
onSuggestionClick: (suggestion: Suggestion) => void;
onSuggestionRollOver: (suggestion: Suggestion) => void;
onMouseLeave: () => void;
}) {
if (error) {
return (
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
className={containerClassName}
>
<EuiFlexItem grow={false}>
<EuiIcon color="danger" size="s" type="warning" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="danger">
{i18n.translate(
'xpack.investigateApp.suggestVisualizationList.errorLoadingSuggestionsLabel',
{
defaultMessage: 'Error loading suggestions: {message}',
values: { message: error.message },
}
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
const icon = loading ? <EuiLoadingSpinner size="s" /> : <EuiIcon type="sortRight" />;
let message: string = '';
if (loading && !suggestions?.length) {
message = i18n.translate(
'xpack.investigateApp.suggestVisualizationList.loadingSuggestionsLabel',
{
defaultMessage: 'Loading suggestions',
}
);
} else if (!loading && !suggestions?.length) {
message = i18n.translate('xpack.investigateApp.suggestVisualizationList.noSuggestionsLabel', {
defaultMessage: 'No suitable suggestions',
});
}
return (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center" className={containerClassName}>
<EuiFlexItem grow={false} className={iconContainerClassName}>
{icon}
</EuiFlexItem>
<EuiFlexItem grow>
{message ? (
<EuiText size="xs">{message}</EuiText>
) : (
<EuiFlexGroup direction="row" gutterSize="s">
{suggestions?.map((suggestion) => (
<EuiFlexItem key={suggestion.id} className={suggestionClassName} grow={false}>
<EuiButton
data-test-subj="investigateSuggestVisualizationListButton"
iconType={suggestion.previewIcon}
iconSize="s"
color="text"
size="s"
onClick={() => {
onSuggestionClick(suggestion);
}}
onMouseEnter={() => {
onSuggestionRollOver(suggestion);
}}
onMouseLeave={() => {
onMouseLeave();
}}
>
<EuiText size="xs">{suggestion.title}</EuiText>
</EuiButton>
</EuiFlexItem>
))}
</EuiFlexGroup>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,384 @@
/*
* 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 { Suggestion } from '@kbn/lens-plugin/public';
export const tableSuggestion: Suggestion = {
title:
'Table @timestamp & agent.activation_method & agent.ephemeral_id & agent.name & agent.version',
score: 0.2,
hide: true,
visualizationId: 'lnsDatatable',
previewIcon: 'visTable',
visualizationState: {
layerId: '5594a808-654b-4170-825d-26c58069bb27',
layerType: 'data',
columns: [
{
columnId: '@timestamp',
},
{
columnId: 'agent.activation_method',
},
{
columnId: 'agent.ephemeral_id',
},
{
columnId: 'agent.name',
},
{
columnId: 'agent.version',
},
],
},
keptLayerIds: ['5594a808-654b-4170-825d-26c58069bb27'],
datasourceState: {
layers: {
'5594a808-654b-4170-825d-26c58069bb27': {
index: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
query: {
esql: 'FROM logs-apm.error-default',
},
columns: [
{
columnId: '@timestamp',
fieldName: '@timestamp',
meta: {
type: 'date',
},
inMetricDimension: true,
},
{
columnId: 'agent.activation_method',
fieldName: 'agent.activation_method',
meta: {
type: 'string',
},
inMetricDimension: true,
},
{
columnId: 'agent.ephemeral_id',
fieldName: 'agent.ephemeral_id',
meta: {
type: 'string',
},
inMetricDimension: true,
},
{
columnId: 'agent.name',
fieldName: 'agent.name',
meta: {
type: 'string',
},
inMetricDimension: true,
},
{
columnId: 'agent.version',
fieldName: 'agent.version',
meta: {
type: 'string',
},
inMetricDimension: true,
},
],
},
},
indexPatternRefs: [
{
id: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
title: 'logs-apm.error-default',
},
],
},
datasourceId: 'textBased',
columns: 5,
changeType: 'initial',
};
export const metricSuggestion: Suggestion = {
title: 'Metric',
score: 0.51,
hide: true,
visualizationId: 'lnsMetric',
previewIcon: 'visMetric',
visualizationState: {
layerId: 'ecd36789-1acb-4278-b087-2e46cf459f89',
layerType: 'data',
metricAccessor: 'COUNT(*)',
},
keptLayerIds: ['ecd36789-1acb-4278-b087-2e46cf459f89'],
datasourceState: {
layers: {
'ecd36789-1acb-4278-b087-2e46cf459f89': {
index: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
query: {
esql: 'FROM logs-apm.error-default | STATS COUNT(*)',
},
columns: [
{
columnId: 'COUNT(*)',
fieldName: 'COUNT(*)',
meta: {
type: 'number',
},
inMetricDimension: true,
},
],
},
},
indexPatternRefs: [
{
id: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
title: 'logs-apm.error-default',
},
],
},
datasourceId: 'textBased',
columns: 1,
changeType: 'initial',
};
export const barSuggestion: Suggestion = {
title: 'Bar vertical stacked',
score: 0.16666666666666666,
hide: false,
incomplete: false,
visualizationId: 'lnsXY',
previewIcon: 'visBarVerticalStacked',
visualizationState: {
legend: {
isVisible: true,
position: 'right',
},
valueLabels: 'hide',
fittingFunction: 'None',
axisTitlesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
tickLabelsVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
labelsOrientation: {
x: 0,
yLeft: 0,
yRight: 0,
},
gridlinesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
preferredSeriesType: 'bar_stacked',
layers: [
{
layerId: '6aeee1c5-c080-4c22-8548-c887a213a433',
seriesType: 'bar_stacked',
xAccessor: 'BUCKET(@timestamp, 1 minute)',
accessors: ['COUNT(*)'],
layerType: 'data',
colorMapping: {
assignments: [],
specialAssignments: [
{
rule: {
type: 'other',
},
color: {
type: 'loop',
},
touched: false,
},
],
paletteId: 'eui_amsterdam_color_blind',
colorMode: {
type: 'categorical',
},
},
},
],
},
keptLayerIds: ['6aeee1c5-c080-4c22-8548-c887a213a433'],
datasourceState: {
layers: {
'6aeee1c5-c080-4c22-8548-c887a213a433': {
index: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
query: {
esql: 'FROM logs-apm.error-default | WHERE @timestamp >= NOW() - 15 minutes | STATS COUNT(*) BY BUCKET(@timestamp, 1 minute)',
},
columns: [
{
columnId: 'COUNT(*)',
fieldName: 'COUNT(*)',
meta: {
type: 'number',
},
inMetricDimension: true,
},
{
columnId: 'BUCKET(@timestamp, 1 minute)',
fieldName: 'BUCKET(@timestamp, 1 minute)',
meta: {
type: 'date',
},
},
],
},
},
indexPatternRefs: [
{
id: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
title: 'logs-apm.error-default',
},
],
},
datasourceId: 'textBased',
columns: 2,
changeType: 'unchanged',
};
export const treemapSuggestion: Suggestion = {
title: 'Treemap',
score: 0.56,
hide: false,
incomplete: false,
visualizationId: 'lnsPie',
previewIcon: 'namespace',
visualizationState: {
shape: 'treemap',
layers: [
{
layerId: '6aeee1c5-c080-4c22-8548-c887a213a433',
primaryGroups: ['BUCKET(@timestamp, 1 minute)'],
metrics: ['COUNT(*)'],
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
nestedLegend: false,
layerType: 'data',
},
],
},
keptLayerIds: ['6aeee1c5-c080-4c22-8548-c887a213a433'],
datasourceState: {
layers: {
'6aeee1c5-c080-4c22-8548-c887a213a433': {
index: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
query: {
esql: 'FROM logs-apm.error-default | WHERE @timestamp >= NOW() - 15 minutes | STATS COUNT(*) BY BUCKET(@timestamp, 1 minute)',
},
columns: [
{
columnId: 'COUNT(*)',
fieldName: 'COUNT(*)',
meta: {
type: 'number',
},
inMetricDimension: true,
},
{
columnId: 'BUCKET(@timestamp, 1 minute)',
fieldName: 'BUCKET(@timestamp, 1 minute)',
meta: {
type: 'date',
},
},
],
},
},
indexPatternRefs: [
{
id: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
title: 'logs-apm.error-default',
},
],
},
datasourceId: 'textBased',
columns: 2,
changeType: 'initial',
};
export const donutSuggestion: Suggestion = {
title: 'Donut',
score: 0.46,
hide: false,
incomplete: false,
visualizationId: 'lnsPie',
previewIcon: 'help',
visualizationState: {
shape: 'donut',
layers: [
{
layerId: '6aeee1c5-c080-4c22-8548-c887a213a433',
primaryGroups: ['BUCKET(@timestamp, 1 minute)'],
metrics: ['COUNT(*)'],
numberDisplay: 'percent',
categoryDisplay: 'default',
legendDisplay: 'default',
nestedLegend: false,
layerType: 'data',
colorMapping: {
assignments: [],
specialAssignments: [
{
rule: {
type: 'other',
},
color: {
type: 'loop',
},
touched: false,
},
],
paletteId: 'eui_amsterdam_color_blind',
colorMode: {
type: 'categorical',
},
},
},
],
},
keptLayerIds: ['6aeee1c5-c080-4c22-8548-c887a213a433'],
datasourceState: {
layers: {
'6aeee1c5-c080-4c22-8548-c887a213a433': {
index: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
query: {
esql: 'FROM logs-apm.error-default | WHERE @timestamp >= NOW() - 15 minutes | STATS COUNT(*) BY BUCKET(@timestamp, 1 minute)',
},
columns: [
{
columnId: 'COUNT(*)',
fieldName: 'COUNT(*)',
meta: {
type: 'number',
},
inMetricDimension: true,
},
{
columnId: 'BUCKET(@timestamp, 1 minute)',
fieldName: 'BUCKET(@timestamp, 1 minute)',
meta: {
type: 'date',
},
},
],
},
},
indexPatternRefs: [
{
id: '6176e654c875b9d3d9a3a69414fa44c561964cbb174a1f64f69e34eced7debac',
title: 'logs-apm.error-default',
},
],
},
datasourceId: 'textBased',
columns: 2,
changeType: 'unchanged',
};

View file

@ -0,0 +1,133 @@
/*
* 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 {
EuiAvatar,
EuiFlexGroup,
EuiFlexItem,
EuiMarkdownFormat,
EuiPanel,
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/css';
import React from 'react';
import { AssistantAvatar } from '@kbn/observability-ai-assistant-plugin/public';
import { AuthenticatedUser } from '@kbn/core/public';
import { shade } from 'polished';
import { useTheme } from '../../hooks/use_theme';
import { InvestigateTextButton } from '../investigate_text_button';
const textContainerClassName = css`
padding-top: 2px;
`;
const borderColor = shade(0.15);
export function TimelineMessage({
icon,
content,
color,
onDelete,
}: {
icon: React.ReactNode;
content: string;
color: string;
onDelete: () => void;
}) {
const theme = useTheme();
const panelClassName = css`
background-color: ${color};
border-radius: 16px;
padding: 12px;
border-width: 1px;
border-color: ${borderColor(color)};
`;
const containerClassName = css`
height: 100%;
.euiButtonIcon {
opacity: 0;
transition: opacity ${theme.animation.fast} ${theme.animation.resistance};
}
`;
return (
<EuiPanel hasBorder className={panelClassName}>
<EuiFlexGroup
direction="row"
gutterSize="m"
alignItems="flexStart"
className={containerClassName}
>
<EuiFlexItem grow={false}>{icon}</EuiFlexItem>
<EuiFlexItem className={textContainerClassName}>
<EuiText size="s" className={containerClassName}>
<EuiMarkdownFormat textSize="s">{content}</EuiMarkdownFormat>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InvestigateTextButton
data-test-subj="investigateAppTimelineMessageButton"
iconType="trash"
onClick={onDelete}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
export function TimelineUserPrompt({
user,
prompt,
onDelete,
}: {
user: Pick<AuthenticatedUser, 'username' | 'full_name'>;
prompt: string;
onDelete: () => void;
}) {
const theme = useTheme();
return (
<TimelineMessage
color={theme.colors.lightestShade}
content={prompt}
icon={<EuiAvatar name={user.full_name || user.username} size="m" />}
onDelete={onDelete}
/>
);
}
export function TimelineAssistantResponse({
content,
onDelete,
}: {
content: string;
onDelete: () => void;
}) {
const theme = useTheme();
const assistantAvatarContainer = css`
border-radius: 32px;
width: 32px;
height: 32px;
background: ${theme.colors.emptyShade};
padding: 7px;
border: 1px solid ${borderColor(theme.colors.highlight)};
`;
return (
<TimelineMessage
color={theme.colors.highlight}
content={content}
icon={
<div className={assistantAvatarContainer}>
<AssistantAvatar size="xs" />
</div>
}
onDelete={onDelete}
/>
);
}

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import React from 'react';
import { WorkflowBlock } from '@kbn/investigate-plugin/common';
import { WorkflowBlocksControl as Component } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
const meta: ComponentMeta<typeof Component> = {
component: Component,
title: 'app/Molecules/WorkflowsBlock',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
function createWorkflowBlocks(): WorkflowBlock[] {
return [
{
id: '0',
content: 'Investigate alerts',
description: '12 open alerts',
loading: false,
color: 'warning',
},
{
id: '1',
content: '',
description: '',
loading: true,
onClick: () => {},
},
{
id: '2',
content: 'Really really really long content to see how the component deals with wrapping',
description:
'I need a really long description too, because that one needs to deal with overflow as well, and should stay on a single line',
loading: false,
onClick: () => {},
},
];
}
const defaultProps: ComponentStoryObj<typeof Component> = {
render: (props) => {
return (
<div style={{ display: 'flex', width: '100%' }}>
<Component {...props} />
</div>
);
},
};
export const DefaultStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
...defaultProps.args,
blocks: createWorkflowBlocks(),
compressed: false,
},
name: 'default',
};
export const CompressedStory: ComponentStoryObj<typeof Component> = {
...defaultProps,
args: {
...defaultProps.args,
blocks: createWorkflowBlocks(),
compressed: true,
},
name: 'compressed',
};

View file

@ -0,0 +1,160 @@
/*
* 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 {
EuiErrorBoundary,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPanel,
EuiText,
} from '@elastic/eui';
// @ts-expect-error
import { getTextColor } from '@elastic/eui/lib/components/badge/color_utils';
import { css } from '@emotion/css';
import { WorkflowBlock } from '@kbn/investigate-plugin/common';
import classNames from 'classnames';
import { rgba } from 'polished';
import React from 'react';
import { useTheme } from '../../hooks/use_theme';
const groupClassName = css`
height: 100%;
`;
const textItemClassName = css`
max-width: 100%;
text-align: left;
`;
const descriptionClassName = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const itemClassName = css`
max-width: 320px;
`;
const loadingContainerClassName = css`
height: 100%;
`;
function WorkflowBlockControl({
content,
description,
loading,
onClick,
color = 'primary',
children,
compressed,
}: Omit<WorkflowBlock, 'id'> & { compressed: boolean }) {
const theme = useTheme();
const actualColor = theme.colors[loading ? 'lightestShade' : color];
const panelClassName = css`
background-color: ${rgba(actualColor, 0.75)};
height: ${compressed ? 32 : 128}px;
transition: all ${theme.animation.fast} ${theme.animation.resistance} !important;
`;
const contentClassName = css`
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: ${compressed ? 1 : 2};
-webkit-box-orient: vertical;
`;
const panelClickableClassName = onClick
? classNames(
panelClassName,
css`
cursor: pointer;
&:hover,
&:focus {
box-shadow: none;
background-color: ${rgba(actualColor, 1)};
transform: none;
border: 1px solid ${theme.colors.darkestShade};
}
`
)
: panelClassName;
const textColor = getTextColor({ euiTheme: theme }, actualColor);
if (loading) {
return (
<>
<EuiPanel hasBorder hasShadow={false} className={panelClassName}>
<EuiFlexGroup
alignItems="center"
justifyContent="center"
className={loadingContainerClassName}
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{children}
</>
);
}
return (
<>
<EuiPanel hasBorder hasShadow={false} className={panelClickableClassName} onClick={onClick}>
<EuiFlexGroup
direction="column"
gutterSize="xs"
alignItems="flexStart"
justifyContent="center"
className={groupClassName}
>
{description && !compressed && (
<EuiFlexItem grow={false} className={textItemClassName}>
<EuiText size="xs" color={textColor} className={descriptionClassName}>
{description}
</EuiText>
</EuiFlexItem>
)}
<EuiFlexItem grow={false} className={textItemClassName}>
<EuiText size={compressed ? 's' : 'm'} color={textColor} className={contentClassName}>
{content}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{children}
</>
);
}
export function WorkflowBlocksControl({
blocks,
compressed,
}: {
blocks: WorkflowBlock[];
compressed: boolean;
}) {
return (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="flexStart">
{blocks.map((block) => (
<EuiFlexItem key={block.id} className={itemClassName}>
<EuiErrorBoundary>
<WorkflowBlockControl {...block} compressed={compressed} />
</EuiErrorBoundary>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

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 enum AddWidgetMode {
Esql = 'esql',
Note = 'note',
}

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 const ESQL_WIDGET_NAME = 'esql';
export const EMBEDDABLE_WIDGET_NAME = 'embeddable';
export const NOTE_WIDGET_NAME = 'note';

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useRef } from 'react';
export function useAbortSignal() {
const controllerRef = useRef(new AbortController());
useEffect(() => {
const controller = controllerRef.current;
return () => {
controller.abort();
};
}, []);
return controllerRef.current.signal;
}

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiThemeBreakpoints } from '@elastic/eui';
import {
useCurrentEuiBreakpoint,
useIsWithinMaxBreakpoint,
useIsWithinMinBreakpoint,
} from '@elastic/eui';
import { useMemo } from 'react';
import { Values } from '@kbn/utility-types';
export type Breakpoints = Record<string, boolean>;
export const EUI_BREAKPOINTS = {
xs: EuiThemeBreakpoints[0],
s: EuiThemeBreakpoints[1],
m: EuiThemeBreakpoints[2],
l: EuiThemeBreakpoints[3],
xl: EuiThemeBreakpoints[4],
};
export type EuiBreakpoint = Values<typeof EUI_BREAKPOINTS>;
export function useBreakpoints() {
const isXSmall = useIsWithinMaxBreakpoint('xs');
const isSmall = useIsWithinMaxBreakpoint('s');
const isMedium = useIsWithinMaxBreakpoint('m');
const isLarge = useIsWithinMaxBreakpoint('l');
const isXl = useIsWithinMinBreakpoint('xl');
const currentBreakpoint = useCurrentEuiBreakpoint();
return useMemo(() => {
return {
isXSmall,
isSmall,
isMedium,
isLarge,
isXl,
currentBreakpoint: (currentBreakpoint ?? EUI_BREAKPOINTS.xl) as EuiBreakpoint,
};
}, [isXSmall, isSmall, isMedium, isLarge, isXl, currentBreakpoint]);
}

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import datemath from '@elastic/datemath';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import moment from 'moment';
import { useCallback, useEffect, useState } from 'react';
import type { InputTimeRange } from '@kbn/data-plugin/public/query';
import { useKibana } from './use_kibana';
function getDatesFromDataPluginStart(data: DataPublicPluginStart) {
const { from, to } = data.query.timefilter.timefilter.getTime();
return {
from,
to,
start: datemath.parse(from) ?? moment().subtract(15, 'minutes'),
end: datemath.parse(to, { roundUp: true }) ?? moment(),
};
}
export function useDateRange() {
const {
dependencies: {
start: { data },
},
} = useKibana();
const [time, setTime] = useState(() => {
return getDatesFromDataPluginStart(data);
});
useEffect(() => {
const subscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({
next: () => {
setTime(() => {
return getDatesFromDataPluginStart(data);
});
},
});
return () => {
subscription.unsubscribe();
};
}, [data]);
const setRange = useCallback(
(inputRange: InputTimeRange) => {
return data.query.timefilter.timefilter.setTime(inputRange);
},
[data]
);
return [time, setRange] as const;
}

View file

@ -0,0 +1,14 @@
/*
* 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 PathsOf, type TypeOf, useParams } from '@kbn/typed-react-router-config';
import type { InvestigateRoutes } from '../routes/config';
export function useInvestigateParams<TPath extends PathsOf<InvestigateRoutes>>(
path: TPath
): TypeOf<InvestigateRoutes, TPath> {
return useParams(path)! as TypeOf<InvestigateRoutes, TPath>;
}

View file

@ -0,0 +1,55 @@
/*
* 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 { PathsOf, TypeAsArgs, TypeOf } from '@kbn/typed-react-router-config';
import { useMemo } from 'react';
import { InvestigateRouter, InvestigateRoutes } from '../routes/config';
import { investigateRouter } from '../routes/config';
import { useKibana } from './use_kibana';
interface StatefulInvestigateRouter extends InvestigateRouter {
push<T extends PathsOf<InvestigateRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<InvestigateRoutes, T>>
): void;
replace<T extends PathsOf<InvestigateRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<InvestigateRoutes, T>>
): void;
}
export function useInvestigateRouter(): StatefulInvestigateRouter {
const {
core: {
http,
application: { navigateToApp },
},
} = useKibana();
const link = (...args: any[]) => {
// @ts-expect-error
return investigateRouter.link(...args);
};
return useMemo<StatefulInvestigateRouter>(
() => ({
...investigateRouter,
push: (...args) => {
const next = link(...args);
navigateToApp('investigate', { path: next, replace: false });
},
replace: (path, ...args) => {
const next = link(path, ...args);
navigateToApp('investigate', { path: next, replace: true });
},
link: (path, ...args) => {
return http.basePath.prepend('/app/investigate' + link(path, ...args));
},
}),
[navigateToApp, http.basePath]
);
}

View file

@ -0,0 +1,23 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import type { InvestigateAppStartDependencies } from '../types';
import { InvestigateAppServices } from '../services/types';
export interface InvestigateAppKibanaContext {
core: CoreStart;
dependencies: { start: InvestigateAppStartDependencies };
services: InvestigateAppServices;
}
const useTypedKibana = () => {
return useKibana<InvestigateAppKibanaContext>().services;
};
export { useTypedKibana as useKibana };

View file

@ -0,0 +1,50 @@
/*
* 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 { useEffect, useMemo, useState } from 'react';
function getFromStorage<T>(keyName: string, defaultValue: T) {
const storedItem = window.localStorage.getItem(keyName);
if (storedItem !== null) {
try {
return JSON.parse(storedItem) as T;
} catch (err) {
window.localStorage.removeItem(keyName);
// eslint-disable-next-line no-console
console.log(`Unable to decode: ${keyName}`);
}
}
return defaultValue;
}
export function useLocalStorage<T>(key: string, defaultValue: T | undefined) {
const [storedItem, setStoredItem] = useState(() => getFromStorage(key, defaultValue));
useEffect(() => {
function onStorageUpdate(e: StorageEvent) {
if (e.key === key) {
setStoredItem((prev) => getFromStorage(key, prev));
}
}
window.addEventListener('storage', onStorageUpdate);
return () => {
window.removeEventListener('storage', onStorageUpdate);
};
}, [key]);
return useMemo(() => {
return {
storedItem,
setStoredItem: (next: T) => {
window.localStorage.setItem(key, JSON.stringify(next));
setStoredItem(() => next);
},
};
}, [key, storedItem]);
}

View file

@ -0,0 +1,25 @@
/*
* 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 { useEffect, useMemo, useRef } from 'react';
export function useMemoWithAbortSignal<T>(cb: (signal: AbortSignal) => T, deps: any[]): T {
const controllerRef = useRef(new AbortController());
useEffect(() => {
const controller = controllerRef.current;
return () => {
controller.abort();
};
}, []);
return useMemo(() => {
controllerRef.current.abort();
controllerRef.current = new AbortController();
return cb(controllerRef.current.signal);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}

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 { useEuiTheme } from '@elastic/eui';
export function useTheme() {
return useEuiTheme().euiTheme;
}

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 type { InvestigateWidgetCreate, WorkflowBlock } from '@kbn/investigate-plugin/common';
import { compact } from 'lodash';
import React from 'react';
import { WorkflowBlocksControl } from '../../components/workflow_blocks_control';
export function useWorkflowBlocks({
isTimelineEmpty,
dynamicBlocks,
start,
end,
onWidgetAdd,
}: {
isTimelineEmpty: boolean;
dynamicBlocks: WorkflowBlock[];
start: string;
end: string;
onWidgetAdd: (create: InvestigateWidgetCreate) => Promise<void>;
}) {
const blocks = isTimelineEmpty ? compact([]) : dynamicBlocks;
if (!blocks.length) {
return null;
}
return <WorkflowBlocksControl blocks={blocks} compressed={!isTimelineEmpty} />;
}

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 type { PluginInitializer, PluginInitializerContext } from '@kbn/core/public';
import { InvestigateAppPlugin } from './plugin';
import type {
InvestigateAppPublicSetup,
InvestigateAppPublicStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies,
ConfigSchema,
} from './types';
export type { InvestigateAppPublicSetup, InvestigateAppPublicStart };
export const plugin: PluginInitializer<
InvestigateAppPublicSetup,
InvestigateAppPublicStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies
> = (pluginInitializerContext: PluginInitializerContext<ConfigSchema>) =>
new InvestigateAppPlugin(pluginInitializerContext);

View file

@ -0,0 +1,148 @@
/*
* 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 { css } from '@emotion/css';
import {
AppMountParameters,
APP_WRAPPER_CLASS,
CoreSetup,
CoreStart,
DEFAULT_APP_CATEGORIES,
Plugin,
PluginInitializerContext,
} from '@kbn/core/public';
import { INVESTIGATE_APP_ID } from '@kbn/deeplinks-observability/constants';
import { i18n } from '@kbn/i18n';
import type { Logger } from '@kbn/logging';
import { once } from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import type { InvestigateAppServices } from './services/types';
import type {
ConfigSchema,
InvestigateAppPublicSetup,
InvestigateAppPublicStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies,
} from './types';
const getCreateEsqlService = once(() => import('./services/esql').then((m) => m.createEsqlService));
export class InvestigateAppPlugin
implements
Plugin<
InvestigateAppPublicSetup,
InvestigateAppPublicStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies
>
{
logger: Logger;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<InvestigateAppStartDependencies, InvestigateAppPublicStart>,
pluginsSetup: InvestigateAppSetupDependencies
): InvestigateAppPublicSetup {
coreSetup.application.register({
id: INVESTIGATE_APP_ID,
title: i18n.translate('xpack.investigateApp.appTitle', {
defaultMessage: 'Observability AI Assistant',
}),
euiIconType: 'logoObservability',
appRoute: '/app/investigate',
category: DEFAULT_APP_CATEGORIES.observability,
visibleIn: [],
deepLinks: [
{
id: 'investigate',
title: i18n.translate('xpack.investigateApp.investigateDeepLinkTitle', {
defaultMessage: 'Investigate',
}),
path: '/new',
},
],
mount: async (appMountParameters: AppMountParameters<unknown>) => {
// Load application bundle and Get start services
const [{ Application }, [coreStart, pluginsStart], createEsqlService] = await Promise.all([
import('./application'),
coreSetup.getStartServices(),
getCreateEsqlService(),
]);
const services: InvestigateAppServices = {
esql: createEsqlService({
data: pluginsStart.data,
dataViews: pluginsStart.dataViews,
lens: pluginsStart.lens,
}),
};
ReactDOM.render(
<Application
coreStart={coreStart}
history={appMountParameters.history}
pluginsStart={pluginsStart}
theme$={appMountParameters.theme$}
services={services}
/>,
appMountParameters.element
);
const appWrapperClassName = css`
overflow: auto;
`;
const appWrapperElement = document.getElementsByClassName(APP_WRAPPER_CLASS)[1];
appWrapperElement.classList.add(appWrapperClassName);
return () => {
ReactDOM.unmountComponentAtNode(appMountParameters.element);
appWrapperElement.classList.remove(appWrapperClassName);
};
},
});
const pluginsStartPromise = coreSetup
.getStartServices()
.then(([, pluginsStart]) => pluginsStart);
pluginsSetup.investigate.register((registerWidget) =>
Promise.all([
pluginsStartPromise,
import('./widgets/register_widgets').then((m) => m.registerWidgets),
getCreateEsqlService(),
]).then(([pluginsStart, registerWidgets, createEsqlService]) => {
registerWidgets({
dependencies: {
setup: pluginsSetup,
start: pluginsStart,
},
services: {
esql: createEsqlService({
data: pluginsStart.data,
dataViews: pluginsStart.dataViews,
lens: pluginsStart.lens,
}),
},
registerWidget,
});
})
);
return {};
}
start(
coreStart: CoreStart,
pluginsStart: InvestigateAppStartDependencies
): InvestigateAppPublicStart {
return {};
}
}

View file

@ -0,0 +1,58 @@
/*
* 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 t from 'io-ts';
import { createRouter, Outlet } from '@kbn/typed-react-router-config';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { InvestigatePageTemplate } from '../components/investigate_page_template';
import { InvestigateView } from '../components/investigate_view';
/**
* The array of route definitions to be used when the application
* creates the routes.
*/
const investigateRoutes = {
'/': {
element: (
<InvestigatePageTemplate>
<Outlet />
</InvestigatePageTemplate>
),
children: {
'/new': {
element: <InvestigateView />,
params: t.partial({
query: t.partial({
revision: t.string,
}),
}),
},
'/{id}': {
element: <InvestigateView />,
params: t.intersection([
t.type({
path: t.type({ id: t.string }),
}),
t.partial({
query: t.partial({
revision: t.string,
}),
}),
]),
},
'/': {
element: <Redirect to="/new" />,
},
},
},
};
export type InvestigateRoutes = typeof investigateRoutes;
export const investigateRouter = createRouter(investigateRoutes);
export type InvestigateRouter = typeof investigateRouter;

View file

@ -0,0 +1,131 @@
/*
* 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 { lastValueFrom } from 'rxjs';
import { getESQLAdHocDataview, getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import { type DataView, ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import type { DatatableColumnType } from '@kbn/expressions-plugin/common';
import type { ESFilter, ESQLSearchResponse } from '@kbn/es-types';
import { AbortError } from '@kbn/kibana-utils-plugin/common';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { v4 } from 'uuid';
import type { InvestigateAppStartDependencies } from '../types';
import { getKibanaColumns } from '../utils/get_kibana_columns';
interface DefaultQueryParams {
query: string;
filter?: ESFilter;
signal: AbortSignal;
}
export interface EsqlColumnMeta {
id: string;
name: string;
meta: { type: DatatableColumnType };
}
export interface EsqlQueryMeta {
columns: EsqlColumnMeta[];
suggestions: Array<Suggestion & { id: string }>;
dataView: DataView;
}
export interface EsqlService {
query: (params: DefaultQueryParams) => Promise<ESQLSearchResponse>;
queryWithMeta: (
params: DefaultQueryParams
) => Promise<{ query: ESQLSearchResponse; meta: EsqlQueryMeta }>;
meta: (params: DefaultQueryParams) => Promise<EsqlQueryMeta>;
}
export function createEsqlService({
data,
dataViews,
lens,
}: Pick<InvestigateAppStartDependencies, 'data' | 'dataViews' | 'lens'>): EsqlService {
async function runQuery({
query,
signal,
dropNullColumns = true,
filter,
}: {
query: string;
signal: AbortSignal;
dropNullColumns?: boolean;
filter?: ESFilter;
}) {
const response = await lastValueFrom(
data.search.search(
{
params: {
query,
dropNullColumns,
filter,
},
},
{ strategy: ESQL_SEARCH_STRATEGY, abortSignal: signal }
)
).then((searchResponse) => {
return searchResponse.rawResponse as unknown as ESQLSearchResponse;
});
return response;
}
const esql: EsqlService = {
query: async ({ query, signal, filter }) => {
return await runQuery({ query, signal, filter });
},
queryWithMeta: async ({ query, signal, filter }) => {
const [meta, queryResult] = await Promise.all([
esql.meta({ query, signal, filter }),
esql.query({ query, signal, filter }),
]);
return {
query: queryResult,
meta,
};
},
meta: async ({ query, signal, filter }) => {
const indexPattern = getIndexPatternFromESQLQuery(query);
const [response, lensHelper, dataView] = await Promise.all([
runQuery({ query: `${query} | LIMIT 0`, signal, dropNullColumns: false, filter }),
lens.stateHelperApi(),
getESQLAdHocDataview(indexPattern, dataViews),
]);
const columns = getKibanaColumns(response.columns ?? []);
const suggestionsFromLensHelper = await lensHelper.suggestions(
{
dataViewSpec: dataView.toSpec(),
fieldName: '',
textBasedColumns: columns,
query: {
esql: query,
},
},
dataView
);
if (signal.aborted) {
throw new AbortError();
}
return {
columns,
suggestions:
suggestionsFromLensHelper?.map((suggestion) => ({ id: v4(), ...suggestion })) ?? [],
dataView,
};
},
};
return esql;
}

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 type { EsqlService } from './esql';
export interface InvestigateAppServices {
esql: EsqlService;
}

View file

@ -0,0 +1,65 @@
/*
* 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 { ContentManagementPublicStart } from '@kbn/content-management-plugin/public';
import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public';
import type {
DataViewsPublicPluginSetup,
DataViewsPublicPluginStart,
} from '@kbn/data-views-plugin/public';
import type {
DatasetQualityPluginSetup,
DatasetQualityPluginStart,
} from '@kbn/dataset-quality-plugin/public';
import type { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import type {
InvestigatePublicSetup,
InvestigatePublicStart,
} from '@kbn/investigate-plugin/public';
import type { LensPublicSetup, LensPublicStart } from '@kbn/lens-plugin/public';
import type {
ObservabilitySharedPluginSetup,
ObservabilitySharedPluginStart,
} from '@kbn/observability-shared-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ConfigSchema {}
export interface InvestigateAppSetupDependencies {
investigate: InvestigatePublicSetup;
observabilityShared: ObservabilitySharedPluginSetup;
lens: LensPublicSetup;
dataViews: DataViewsPublicPluginSetup;
data: DataPublicPluginSetup;
embeddable: EmbeddableSetup;
contentManagement: {};
datasetQuality: DatasetQualityPluginSetup;
unifiedSearch: {};
uiActions: UiActionsSetup;
security: SecurityPluginSetup;
}
export interface InvestigateAppStartDependencies {
investigate: InvestigatePublicStart;
observabilityShared: ObservabilitySharedPluginStart;
lens: LensPublicStart;
dataViews: DataViewsPublicPluginStart;
data: DataPublicPluginStart;
embeddable: EmbeddableStart;
contentManagement: ContentManagementPublicStart;
datasetQuality: DatasetQualityPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
uiActions: UiActionsStart;
security: SecurityPluginStart;
}
export interface InvestigateAppPublicSetup {}
export interface InvestigateAppPublicStart {}

View file

@ -0,0 +1,20 @@
/*
* 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 function findScrollableParent(parent: HTMLElement | null) {
while (parent && parent !== window.document.body) {
if (parent.scrollHeight > parent.clientHeight) {
const computed = getComputedStyle(parent);
if (computed.overflowY === 'auto' || computed.overflowY === 'scroll') {
return parent;
}
}
parent = parent.parentElement;
}
return window.document.documentElement;
}

View file

@ -0,0 +1,42 @@
/*
* 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 { Datatable } from '@kbn/expressions-plugin/common';
import type { ESQLColumn, ESQLRow } from '@kbn/es-types';
import type { EsqlColumnMeta } from '../services/esql';
import { getKibanaColumns } from './get_kibana_columns';
type Primitive = string | boolean | number | null;
export function getDatatableFromEsqlResponse({
columns,
values,
all_columns: allColumns,
}: {
all_columns?: ESQLColumn[];
columns: ESQLColumn[];
values: ESQLRow[];
}): Datatable {
const kibanaColumns: EsqlColumnMeta[] = getKibanaColumns(allColumns ?? columns);
const datatable: Datatable = {
columns: kibanaColumns,
rows: values.map((row) => {
return row.reduce<Record<string, Primitive | Primitive[]>>((prev, current, index) => {
const column = columns[index];
prev[column.name] = current as Primitive | Primitive[];
return prev;
}, {});
}),
type: 'datatable',
meta: {
type: 'esql',
},
};
return datatable;
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { type BoolQuery, buildEsQuery, type Query, type Filter } from '@kbn/es-query';
export function getEsFilterFromOverrides({
query,
filters,
timeRange,
}: {
query?: Query;
filters?: Filter[];
timeRange?: {
from: string;
to: string;
};
}): { bool: BoolQuery } {
const esFilter = buildEsQuery(undefined, query ?? [], filters ?? []);
if (timeRange) {
esFilter.bool.filter.push({
range: {
'@timestamp': {
gte: timeRange.from,
lte: timeRange.to,
},
},
});
}
return esFilter;
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ESQLColumn } from '@kbn/es-types';
import { esFieldTypeToKibanaFieldType } from '@kbn/field-types';
import { DatatableColumnType } from '@kbn/expressions-plugin/common';
import { EsqlColumnMeta } from '../services/esql';
export function getKibanaColumns(columns: ESQLColumn[]): EsqlColumnMeta[] {
return (
columns.map(({ name, type }) => ({
id: name,
name,
meta: { type: esFieldTypeToKibanaFieldType(type) as DatatableColumnType },
})) ?? []
);
}

View file

@ -0,0 +1,55 @@
/*
* 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/common';
import type { Datatable } from '@kbn/expressions-plugin/common';
import type { Suggestion, TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { getLensAttributesFromSuggestion } from '@kbn/visualization-utils';
import { mapValues } from 'lodash';
import { v4 } from 'uuid';
export function getLensAttrsForSuggestion({
query,
suggestion,
dataView,
table,
}: {
query: string;
suggestion: Suggestion;
dataView: DataView;
table?: Datatable;
}): TypedLensByValueInput {
const attrs = getLensAttributesFromSuggestion({
filters: [],
query: {
esql: query,
},
suggestion,
dataView,
}) as TypedLensByValueInput['attributes'];
const lensEmbeddableInput: TypedLensByValueInput = {
attributes: attrs,
id: v4(),
};
if (!table) {
return lensEmbeddableInput;
}
const textBased = attrs.state.datasourceStates.textBased;
if (!textBased?.layers) {
throw new Error('Expected layers to exist for datasourceStates.textBased');
}
textBased.layers = mapValues(textBased.layers, (value) => {
return { ...value, table };
});
return lensEmbeddableInput;
}

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { isEqual } from 'lodash';
import type { GlobalWidgetParameters } from '@kbn/investigate-plugin/public';
import type { Filter } from '@kbn/es-query';
import objectHash from 'object-hash';
import { i18n } from '@kbn/i18n';
import { PrettyDuration } from '@elastic/eui';
import type { InvestigateWidgetGridItemOverride } from '../components/investigate_widget_grid';
enum OverrideType {
query = 'query',
timeRange = 'timeRange',
filters = 'filters',
}
function getIdForFilter(filter: Filter) {
return objectHash({ meta: filter.meta, query: filter.query });
}
function getLabelForFilter(filter: Filter) {
return (
filter.meta.alias ??
filter.meta.key ??
JSON.stringify({ meta: filter.meta, query: filter.query })
);
}
export function getOverridesFromGlobalParameters(
itemParameters: GlobalWidgetParameters,
globalParameters: GlobalWidgetParameters,
uiSettingsDateFormat: string
) {
const overrides: InvestigateWidgetGridItemOverride[] = [];
if (!isEqual(itemParameters.query, globalParameters.query)) {
overrides.push({
id: OverrideType.query,
label: itemParameters.query.query
? itemParameters.query.query
: i18n.translate('xpack.investigateApp.overrides.noQuery', { defaultMessage: 'No query' }),
});
}
if (!isEqual(itemParameters.timeRange, globalParameters.timeRange)) {
overrides.push({
id: OverrideType.timeRange,
label: (
<PrettyDuration
timeFrom={itemParameters.timeRange.from}
timeTo={itemParameters.timeRange.to}
dateFormat={uiSettingsDateFormat}
/>
),
});
}
if (!isEqual(itemParameters.filters, globalParameters.filters)) {
if (!itemParameters.filters.length) {
overrides.push({
id: OverrideType.filters,
label: i18n.translate('xpack.investigateApp.overrides.noFilters', {
defaultMessage: 'No filters',
}),
});
}
itemParameters.filters.forEach((filter) => {
overrides.push({
id: `${OverrideType.filters}_${getIdForFilter(filter)}`,
label: getLabelForFilter(filter),
});
});
}
return overrides;
}

View file

@ -0,0 +1,13 @@
/*
* 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 { createWidgetFactory } from '@kbn/investigate-plugin/public';
import { EMBEDDABLE_WIDGET_NAME } from '../../constants';
import { EmbeddableWidgetParameters } from './types';
export const createEmbeddableWidget =
createWidgetFactory<EmbeddableWidgetParameters>(EMBEDDABLE_WIDGET_NAME);

View file

@ -0,0 +1,213 @@
/*
* 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 { EuiLoadingSpinner } from '@elastic/eui';
import { css } from '@emotion/css';
import type { GlobalWidgetParameters } from '@kbn/investigate-plugin/public';
import { useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid';
import { ReactEmbeddableRenderer } from '@kbn/embeddable-plugin/public';
import { EMBEDDABLE_WIDGET_NAME } from '../../constants';
import { useKibana } from '../../hooks/use_kibana';
import { RegisterWidgetOptions } from '../register_widgets';
import { EmbeddableWidgetParameters } from './types';
import { ErrorMessage } from '../../components/error_message';
const embeddableClassName = css`
height: 100%;
> [data-shared-item] {
height: 100%;
}
`;
type Props = EmbeddableWidgetParameters & GlobalWidgetParameters;
type ParentApi = ReturnType<React.ComponentProps<typeof ReactEmbeddableRenderer>['getParentApi']>;
function ReactEmbeddable({
type,
config,
query,
filters,
timeRange: { from, to },
savedObjectId,
}: Props) {
const configWithOverrides = useMemo(() => {
return {
...config,
query,
filters,
timeRange: {
from,
to,
},
};
}, [config, query, filters, from, to]);
const configWithOverridesRef = useRef(configWithOverrides);
configWithOverridesRef.current = configWithOverrides;
const api = useMemo<ParentApi>(() => {
return {
getSerializedStateForChild: () => ({ rawState: configWithOverridesRef.current }),
};
}, []);
return (
<ReactEmbeddableRenderer
type={type}
getParentApi={() => api}
maybeId={savedObjectId}
onAnyStateChange={(state) => {
// console.log('onAnyStateChange', state);
}}
onApiAvailable={(childApi) => {
// console.log('onApiAvailable', childApi);
}}
hidePanelChrome
/>
);
}
function LegacyEmbeddable({
type,
config,
query,
filters,
timeRange: { from, to },
savedObjectId,
}: Props) {
const {
dependencies: {
start: { embeddable },
},
} = useKibana();
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
const embeddableInstanceAsync = useAbortableAsync(async () => {
const factory = embeddable.getEmbeddableFactory(type);
if (!factory) {
throw new Error(`Cannot find embeddable factory for ${type}`);
}
const configWithId = {
id: savedObjectId ?? v4(),
...config,
};
const configWithOverrides = {
...configWithId,
query,
filters,
timeRange: {
from,
to,
},
};
if (savedObjectId) {
return factory.createFromSavedObject(configWithOverrides.id, configWithOverrides);
}
const instance = await factory.create(configWithOverrides);
return instance;
}, [type, savedObjectId, config, from, to, embeddable, filters, query]);
const embeddableInstance = embeddableInstanceAsync.value;
useEffect(() => {
if (!targetElement || !embeddableInstance) {
return;
}
embeddableInstance.render(targetElement);
return () => {};
}, [embeddableInstance, targetElement]);
useEffect(() => {
return () => {
if (embeddableInstance) {
embeddableInstance.destroy();
}
};
}, [embeddableInstance]);
if (embeddableInstanceAsync.error) {
return <ErrorMessage error={embeddableInstanceAsync.error} />;
}
if (!embeddableInstance) {
return <EuiLoadingSpinner />;
}
return (
<div
className={embeddableClassName}
ref={(element) => {
setTargetElement(element);
}}
/>
);
}
function EmbeddableWidget(props: Props) {
const {
dependencies: {
start: { embeddable },
},
} = useKibana();
if (embeddable.reactEmbeddableRegistryHasKey(props.type)) {
return <ReactEmbeddable {...props} />;
}
return <LegacyEmbeddable {...props} />;
}
export function registerEmbeddableWidget({ registerWidget }: RegisterWidgetOptions) {
registerWidget(
{
type: EMBEDDABLE_WIDGET_NAME,
description: 'Display a saved embeddable',
schema: {
type: 'object',
properties: {
type: {
type: 'string',
},
config: {
type: 'object',
},
savedObjectId: {
type: 'string',
},
},
required: ['type', 'config'],
} as const,
},
async ({ parameters, signal }) => {
return {};
},
({ widget }) => {
const parameters = {
type: widget.parameters.type,
config: widget.parameters.config,
savedObjectId: widget.parameters.savedObjectId,
timeRange: widget.parameters.timeRange,
filters: widget.parameters.filters,
query: widget.parameters.query,
};
return <EmbeddableWidget {...parameters} />;
}
);
}

View file

@ -0,0 +1,18 @@
/*
* 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 { InvestigateWidget, InvestigateWidgetCreate } from '@kbn/investigate-plugin/common';
export interface EmbeddableWidgetParameters {
type: string;
savedObjectId?: string;
config: Record<string, any>;
}
export type EmbeddableWidgetCreate = InvestigateWidgetCreate<EmbeddableWidgetParameters>;
export type EmbeddableWidget = InvestigateWidget<EmbeddableWidgetParameters, {}>;

View file

@ -0,0 +1,54 @@
/*
* 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 { Suggestion } from '@kbn/lens-plugin/public';
import type { ESQLColumn } from '@kbn/es-types';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import type { EsqlService } from '../../services/esql';
export async function getDateHistogramResults({
query,
esql,
timeRange,
filter,
suggestion,
signal,
columns,
}: {
query: string;
esql: EsqlService;
timeRange: {
from: string;
to: string;
};
filter: QueryDslQueryContainer;
suggestion: Suggestion;
signal: AbortSignal;
columns: ESQLColumn[];
}) {
const groupingExpression = `BUCKET(@timestamp, 50, "${timeRange.from}", "${timeRange.to}")`;
const dateHistoQuery = `${query} | STATS count = COUNT(*) BY ${groupingExpression}`;
const dateHistoResponse =
suggestion.visualizationId === 'lnsDatatable' &&
columns.find((column) => column.name === '@timestamp')
? await esql.queryWithMeta({
query: dateHistoQuery,
signal,
filter,
})
: undefined;
return dateHistoResponse
? {
columns: dateHistoResponse.query.columns,
values: dateHistoResponse.query.values,
query: dateHistoQuery,
groupingExpression,
}
: undefined;
}

View file

@ -0,0 +1,307 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo } from 'react';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { css } from '@emotion/css';
import type {
EsqlWidgetParameters,
GlobalWidgetParameters,
WidgetRenderAPI,
} from '@kbn/investigate-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { ESQLSearchResponse } from '@kbn/es-types';
import { ESQLDataGrid } from '@kbn/esql-datagrid/public';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { useAbortableAsync } from '@kbn/observability-ai-assistant-plugin/public';
import { ESQL_WIDGET_NAME } from '../../constants';
import type { RegisterWidgetOptions } from '../register_widgets';
import { useKibana } from '../../hooks/use_kibana';
import { getLensAttrsForSuggestion } from '../../utils/get_lens_attrs_for_suggestion';
import { getDatatableFromEsqlResponse } from '../../utils/get_data_table_from_esql_response';
import { getEsFilterFromOverrides } from '../../utils/get_es_filter_from_overrides';
import { ErrorMessage } from '../../components/error_message';
import { getDateHistogramResults } from './get_date_histogram_results';
const lensClassName = css`
height: 100%;
`;
export function EsqlWidget({
suggestion,
dataView,
esqlQuery,
columns,
allColumns,
values,
blocks,
dateHistogramResults,
}: {
suggestion: Suggestion;
dataView: DataView;
esqlQuery: string;
columns: ESQLSearchResponse['columns'];
allColumns: ESQLSearchResponse['all_columns'];
values: ESQLSearchResponse['values'];
blocks: WidgetRenderAPI['blocks'];
dateHistogramResults?: {
query: string;
columns: ESQLSearchResponse['columns'];
values: ESQLSearchResponse['values'];
groupingExpression: string;
};
}) {
const {
dependencies: {
start: { lens },
},
} = useKibana();
const datatable = useMemo(() => {
return getDatatableFromEsqlResponse({
columns,
values,
all_columns: allColumns,
});
}, [columns, values, allColumns]);
const input = useMemo(() => {
return getLensAttrsForSuggestion({
suggestion,
dataView,
query: esqlQuery,
table: datatable,
});
}, [suggestion, dataView, esqlQuery, datatable]);
const memoizedQueryObject = useMemo(() => {
return { esql: esqlQuery };
}, [esqlQuery]);
useEffect(() => {
if (datatable.columns.find((column) => column.name === 'message')) {
return blocks.publish([
{
id: 'pattern_analysis',
loading: false,
content: i18n.translate('xpack.investigateApp.esqlWidget.runPatternAnalysis', {
defaultMessage: 'Analyze log patterns',
}),
},
]);
}
}, [blocks, datatable]);
const initialColumns = useMemo(() => {
const timestampColumn = datatable.columns.find((column) => column.name === '@timestamp');
const messageColumn = datatable.columns.find((column) => column.name === 'message');
if (datatable.columns.length > 10 && timestampColumn && messageColumn) {
const hasDataForBothColumns = datatable.rows.every((row) => {
const timestampValue = row['@timestamp'];
const messageValue = row.message;
return timestampValue !== null && timestampValue !== undefined && !!messageValue;
});
if (hasDataForBothColumns) {
return [timestampColumn, messageColumn];
}
}
return undefined;
}, [datatable.columns, datatable.rows]);
const previewInput = useAbortableAsync(
async ({ signal }) => {
if (!dateHistogramResults) {
return undefined;
}
const lensHelper = await lens.stateHelperApi();
const suggestionsFromLensHelper = await lensHelper.suggestions(
{
dataViewSpec: dataView.toSpec(),
fieldName: '',
textBasedColumns: [
{
id: dateHistogramResults.groupingExpression,
name: i18n.translate('xpack.investigateApp.esqlWidget.groupedByDateLabel', {
defaultMessage: '@timestamp',
}),
meta: {
type: 'date',
},
},
{
id: 'count',
name: 'count',
meta: {
type: 'number',
},
},
],
query: {
esql: dateHistogramResults.query,
},
},
dataView,
['lnsDatatable']
);
const suggestionForHistogram = suggestionsFromLensHelper?.[0];
if (!suggestionForHistogram) {
return undefined;
}
return getLensAttrsForSuggestion({
suggestion: suggestionForHistogram,
dataView,
query: dateHistogramResults.query,
table: getDatatableFromEsqlResponse({
columns: dateHistogramResults.columns,
values: dateHistogramResults.values,
}),
});
},
[dataView, lens, dateHistogramResults]
);
if (input.attributes.visualizationType === 'lnsDatatable') {
let innerElement: React.ReactElement;
if (previewInput.error) {
innerElement = <ErrorMessage error={previewInput.error} />;
} else if (previewInput.value) {
innerElement = <lens.EmbeddableComponent {...previewInput.value} />;
} else {
innerElement = <EuiLoadingSpinner size="s" />;
}
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem
grow={false}
className={css`
> div {
height: 128px;
}
`}
>
{innerElement}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ESQLDataGrid
rows={values}
columns={datatable.columns}
dataView={dataView}
query={memoizedQueryObject}
flyoutType="overlay"
initialColumns={initialColumns}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return <lens.EmbeddableComponent {...input} className={lensClassName} />;
}
export function registerEsqlWidget({
dependencies: {
setup: { investigate },
},
services,
registerWidget,
}: RegisterWidgetOptions) {
registerWidget(
{
type: ESQL_WIDGET_NAME,
description: 'Visualize an ES|QL query',
schema: {
type: 'object',
properties: {
esql: {
description: 'The ES|QL query',
type: 'string',
},
},
required: ['esql'],
} as const,
},
async ({ parameters, signal }) => {
const {
esql: esqlQuery,
query,
filters,
timeRange,
suggestion: suggestionFromParameters,
} = parameters as EsqlWidgetParameters & GlobalWidgetParameters;
const esql = await services.esql;
const esFilters = [
getEsFilterFromOverrides({
query,
filters,
timeRange,
}),
];
const getFilter = () => ({
bool: {
filter: [...esFilters],
},
});
const mainResponse = await esql.queryWithMeta({
query: esqlQuery,
signal,
filter: getFilter(),
});
const suggestion = suggestionFromParameters || mainResponse.meta.suggestions[0];
const dateHistoResponse = await getDateHistogramResults({
query: esqlQuery,
columns: mainResponse.query.columns,
esql,
filter: getFilter(),
signal,
suggestion,
timeRange,
});
return {
main: {
columns: mainResponse.query.columns,
values: mainResponse.query.values,
suggestion,
dataView: mainResponse.meta.dataView,
},
dateHistogram: dateHistoResponse,
};
},
({ widget, blocks }) => {
const {
main: { dataView, columns, values, suggestion },
dateHistogram,
} = widget.data;
return (
<EsqlWidget
dataView={dataView}
columns={columns}
allColumns={undefined}
values={values}
suggestion={suggestion}
esqlQuery={widget.parameters.esql}
blocks={blocks}
dateHistogramResults={dateHistogram}
/>
);
}
);
}

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 { createWidgetFactory } from '@kbn/investigate-plugin/public';
import { NOTE_WIDGET_NAME } from '../../constants';
import type { NoteWidgetCreateParameters } from './types';
export const createNoteWidget = createWidgetFactory<NoteWidgetCreateParameters>(NOTE_WIDGET_NAME);

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ChromeOption } from '@kbn/investigate-plugin/public';
import { RegisterWidgetOptions } from '../register_widgets';
import { NOTE_WIDGET_NAME } from '../../constants';
import { NoteWidget } from '../../components/note_widget';
export function registerNoteWidget(options: RegisterWidgetOptions) {
options.registerWidget(
{
type: NOTE_WIDGET_NAME,
description: '',
chrome: ChromeOption.disabled,
schema: {
type: 'object',
properties: {
note: {
type: 'string',
},
user: {
type: 'object',
properties: {
username: {
type: 'string',
},
full_name: {
type: 'string',
},
},
required: ['username'],
},
},
required: ['note', 'user'],
} as const,
},
() => Promise.resolve({}),
({ widget, onDelete }) => {
const { user, note } = widget.parameters;
return <NoteWidget user={user} note={note} onDelete={onDelete} onChange={() => {}} />;
}
);
}

View file

@ -0,0 +1,16 @@
/*
* 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 { InvestigateWidget } from '@kbn/investigate-plugin/common';
import type { AuthenticatedUser } from '@kbn/core/public';
export interface NoteWidgetCreateParameters {
user: Pick<AuthenticatedUser, 'username' | 'full_name'>;
note: string;
}
export type NoteWidget = InvestigateWidget<NoteWidgetCreateParameters>;

View file

@ -0,0 +1,28 @@
/*
* 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 { RegisterWidget } from '@kbn/investigate-plugin/public/types';
import type { InvestigateAppServices } from '../services/types';
import type { InvestigateAppSetupDependencies, InvestigateAppStartDependencies } from '../types';
import { registerEmbeddableWidget } from './embeddable_widget/register_embeddable_widget';
import { registerEsqlWidget } from './esql_widget/register_esql_widget';
import { registerNoteWidget } from './note_widget';
export interface RegisterWidgetOptions {
dependencies: {
setup: InvestigateAppSetupDependencies;
start: InvestigateAppStartDependencies;
};
services: InvestigateAppServices;
registerWidget: RegisterWidget;
}
export function registerWidgets(options: RegisterWidgetOptions) {
registerEsqlWidget(options);
registerEmbeddableWidget(options);
registerNoteWidget(options);
}

View file

@ -0,0 +1,14 @@
/*
* 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 { schema, type TypeOf } from '@kbn/config-schema';
export const config = schema.object({
enabled: schema.boolean({ defaultValue: true }),
});
export type InvestigateAppConfig = TypeOf<typeof config>;

View file

@ -0,0 +1,28 @@
/*
* 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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/server';
import { InvestigateAppConfig } from './config';
import { InvestigateAppPlugin } from './plugin';
import type {
InvestigateAppServerSetup,
InvestigateAppServerStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies,
} from './types';
export type { InvestigateAppServerRouteRepository } from './routes/get_global_investigate_app_server_route_repository';
export type { InvestigateAppServerSetup, InvestigateAppServerStart };
export const plugin: PluginInitializer<
InvestigateAppServerSetup,
InvestigateAppServerStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies
> = async (pluginInitializerContext: PluginInitializerContext<InvestigateAppConfig>) =>
new InvestigateAppPlugin(pluginInitializerContext);

View file

@ -0,0 +1,64 @@
/*
* 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { mapValues } from 'lodash';
import { registerServerRoutes } from './routes/register_routes';
import { InvestigateAppRouteHandlerResources } from './routes/types';
import type {
ConfigSchema,
InvestigateAppServerSetup,
InvestigateAppServerStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies,
} from './types';
export class InvestigateAppPlugin
implements
Plugin<
InvestigateAppServerSetup,
InvestigateAppServerStart,
InvestigateAppSetupDependencies,
InvestigateAppStartDependencies
>
{
logger: Logger;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<InvestigateAppStartDependencies, InvestigateAppServerStart>,
pluginsSetup: InvestigateAppSetupDependencies
): InvestigateAppServerSetup {
const routeHandlerPlugins = mapValues(pluginsSetup, (value, key) => {
return {
setup: value,
start: () =>
coreSetup.getStartServices().then((services) => {
const [, pluginsStartContracts] = services;
return (pluginsStartContracts as any)[key];
}),
};
}) as InvestigateAppRouteHandlerResources['plugins'];
registerServerRoutes({
core: coreSetup,
logger: this.logger,
dependencies: {
plugins: routeHandlerPlugins,
},
});
return {};
}
start(core: CoreStart, pluginsStart: InvestigateAppStartDependencies): InvestigateAppServerStart {
return {};
}
}

View file

@ -0,0 +1,16 @@
/*
* 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 { createServerRouteFactory } from '@kbn/server-route-repository';
import type {
InvestigateAppRouteCreateOptions,
InvestigateAppRouteHandlerResources,
} from './types';
export const createInvestigateAppServerRoute = createServerRouteFactory<
InvestigateAppRouteHandlerResources,
InvestigateAppRouteCreateOptions
>();

View file

@ -0,0 +1,14 @@
/*
* 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 function getGlobalInvestigateAppServerRouteRepository() {
return {};
}
export type InvestigateAppServerRouteRepository = ReturnType<
typeof getGlobalInvestigateAppServerRouteRepository
>;

View file

@ -0,0 +1,31 @@
/*
* 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 { CoreSetup } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { registerRoutes } from '@kbn/server-route-repository';
import { getGlobalInvestigateAppServerRouteRepository } from './get_global_investigate_app_server_route_repository';
import type { InvestigateAppRouteHandlerResources } from './types';
export function registerServerRoutes({
core,
logger,
dependencies,
}: {
core: CoreSetup;
logger: Logger;
dependencies: Omit<
InvestigateAppRouteHandlerResources,
'request' | 'context' | 'logger' | 'params'
>;
}) {
registerRoutes({
core,
logger,
repository: getGlobalInvestigateAppServerRouteRepository(),
dependencies,
});
}

View file

@ -0,0 +1,60 @@
/*
* 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 {
CoreStart,
CustomRequestHandlerContext,
IScopedClusterClient,
IUiSettingsClient,
KibanaRequest,
SavedObjectsClientContract,
} from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import type { InvestigateAppSetupDependencies, InvestigateAppStartDependencies } from '../types';
export type InvestigateAppRequestHandlerContext = Omit<
CustomRequestHandlerContext<{}>,
'core' | 'resolve'
> & {
core: Promise<{
elasticsearch: {
client: IScopedClusterClient;
};
uiSettings: {
client: IUiSettingsClient;
globalClient: IUiSettingsClient;
};
savedObjects: {
client: SavedObjectsClientContract;
};
coreStart: CoreStart;
}>;
};
export interface InvestigateAppRouteHandlerResources {
request: KibanaRequest;
context: InvestigateAppRequestHandlerContext;
logger: Logger;
plugins: {
[key in keyof InvestigateAppSetupDependencies]: {
setup: Required<InvestigateAppSetupDependencies>[key];
};
} & {
[key in keyof InvestigateAppStartDependencies]: {
start: () => Promise<Required<InvestigateAppStartDependencies>[key]>;
};
};
}
export interface InvestigateAppRouteCreateOptions {
options: {
timeout?: {
idleSocket?: number;
};
tags: [];
};
}

View file

@ -0,0 +1,18 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ConfigSchema {}
export interface InvestigateAppSetupDependencies {}
export interface InvestigateAppStartDependencies {}
export interface InvestigateAppServerSetup {}
export interface InvestigateAppServerStart {}

View file

@ -0,0 +1,56 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"../../../typings/**/*",
"common/**/*",
"public/**/*",
"typings/**/*",
"public/**/*.json",
"server/**/*",
".storybook/**/*"
],
"exclude": [
"target/**/*",
".storybook/**/*.js"
],
"kbn_references": [
"@kbn/core",
"@kbn/react-kibana-context-theme",
"@kbn/shared-ux-link-redirect-app",
"@kbn/kibana-react-plugin",
"@kbn/typed-react-router-config",
"@kbn/i18n",
"@kbn/embeddable-plugin",
"@kbn/observability-ai-assistant-plugin",
"@kbn/lens-plugin",
"@kbn/utility-types",
"@kbn/esql",
"@kbn/esql-utils",
"@kbn/data-plugin",
"@kbn/es-types",
"@kbn/field-types",
"@kbn/expressions-plugin",
"@kbn/deeplinks-observability",
"@kbn/logging",
"@kbn/data-views-plugin",
"@kbn/observability-shared-plugin",
"@kbn/config-schema",
"@kbn/investigate-plugin",
"@kbn/dataset-quality-plugin",
"@kbn/utility-types-jest",
"@kbn/content-management-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/visualization-utils",
"@kbn/unified-search-plugin",
"@kbn/es-query",
"@kbn/server-route-repository",
"@kbn/management-settings-ids",
"@kbn/security-plugin",
"@kbn/ui-actions-plugin",
"@kbn/esql-datagrid",
"@kbn/std"
],
}

View file

@ -5266,6 +5266,10 @@
version "0.0.0"
uid ""
"@kbn/investigate-app-plugin@link:x-pack/plugins/observability_solution/investigate_app":
version "0.0.0"
uid ""
"@kbn/investigate-plugin@link:x-pack/plugins/observability_solution/investigate":
version "0.0.0"
uid ""
@ -13017,6 +13021,11 @@ bare-path@^2.0.0, bare-path@^2.1.0:
dependencies:
bare-os "^2.1.0"
base64-arraybuffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
base64-js@1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
@ -14884,6 +14893,13 @@ css-in-js-utils@^2.0.0:
hyphenate-style-name "^1.0.2"
isobject "^3.0.1"
css-line-break@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
dependencies:
utrie "^1.0.2"
css-loader@^3.4.2, css-loader@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.6.0.tgz#2e4b2c7e6e2d27f8c8f28f61bffcd2e6c91ef645"
@ -19467,6 +19483,14 @@ html-webpack-plugin@^4.0.0:
tapable "^1.1.3"
util.promisify "1.0.0"
html2canvas@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
dependencies:
css-line-break "^2.1.0"
text-segmentation "^1.0.3"
html@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/html/-/html-1.0.0.tgz#a544fa9ea5492bfb3a2cca8210a10be7b5af1f61"
@ -30012,6 +30036,13 @@ text-hex@1.0.x:
resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
text-segmentation@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
dependencies:
utrie "^1.0.2"
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@ -31129,6 +31160,13 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
utrie@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
dependencies:
base64-arraybuffer "^1.0.2"
uuid-browser@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/uuid-browser/-/uuid-browser-3.1.0.tgz#0f05a40aef74f9e5951e20efbf44b11871e56410"