mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
chore(investigate): Add investigate-app plugin from poc (#188122)
This commit is contained in:
parent
3c97fbaac5
commit
aa67c800ce
97 changed files with 6100 additions and 1 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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)) }),
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -86,6 +86,7 @@ pageLoadAssetSize:
|
|||
integrationAssistant: 19524
|
||||
interactiveSetup: 80000
|
||||
investigate: 17970
|
||||
investigateApp: 91898
|
||||
kibanaOverview: 56279
|
||||
kibanaReact: 74422
|
||||
kibanaUsageCollection: 16463
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -152,6 +152,7 @@ export const applicationUsageSchema = {
|
|||
fleet: commonSchema,
|
||||
integrations: commonSchema,
|
||||
ingestManager: commonSchema,
|
||||
investigate: commonSchema,
|
||||
lens: commonSchema,
|
||||
maps: commonSchema,
|
||||
ml: commonSchema,
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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()
|
||||
);
|
|
@ -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];
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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'],
|
||||
};
|
|
@ -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": []
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@import '../../../../../../../src/plugins/dashboard/public/dashboard_container/dashboard_container';
|
|
@ -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',
|
||||
};
|
|
@ -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>;
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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]);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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);
|
|
@ -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 {};
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 },
|
||||
})) ?? []
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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} />;
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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, {}>;
|
|
@ -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;
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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={() => {}} />;
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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>;
|
|
@ -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);
|
||||
}
|
|
@ -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>;
|
|
@ -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);
|
|
@ -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 {};
|
||||
}
|
||||
}
|
|
@ -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
|
||||
>();
|
|
@ -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
|
||||
>;
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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: [];
|
||||
};
|
||||
}
|
|
@ -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 {}
|
|
@ -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"
|
||||
],
|
||||
}
|
38
yarn.lock
38
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue