diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 90bf3d3c29b41..a46e492db5e24 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -329,6 +329,10 @@ The plugin exposes the static DefaultEditorController class to consume. |WARNING: Missing README. +|{kib-repo}blob/{branch}/src/plugins/vis_type_script/README.md[visTypeScript] +|This plugin defines the script-based visualization type. Users can use JavaScript and the popular D3 library to build script-based custom visualizations directly in Kibana. + + |{kib-repo}blob/{branch}/src/plugins/vis_types/table/README.md[visTypeTable] |Contains the data table visualization, that allows presenting data in a simple table format. diff --git a/package.json b/package.json index d6d4a2225a08c..d3d91616f1d4c 100644 --- a/package.json +++ b/package.json @@ -198,6 +198,7 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mapbox/vector-tile": "1.3.1", "@reduxjs/toolkit": "^1.6.1", + "@remote-ui/rpc": "1.3.0", "@slack/webhook": "^5.0.4", "@turf/along": "6.0.1", "@turf/area": "6.0.1", diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 9291201237279..d02dcf917cb62 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -61,6 +61,7 @@ import { DeprecationsServiceStart } from './deprecations'; import type { ThemeServiceSetup, ThemeServiceStart } from './theme'; import { ExecutionContextSetup, ExecutionContextStart } from './execution_context'; import type { AnalyticsServiceSetup, AnalyticsServiceStart } from './analytics'; +import type { InjectedMetadataSetup } from './injected_metadata'; export type { PackageInfo, @@ -251,6 +252,7 @@ export interface CoreSetup unknown; + getCsp: () => ReturnType; }; /** {@link ThemeServiceSetup} */ theme: ThemeServiceSetup; diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 07f56b889fc79..f51b3026fb07f 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -161,6 +161,7 @@ export interface InjectedMetadataSetup { getKibanaVersion: () => string; getCspConfig: () => { warnLegacyBrowsers: boolean; + nonce: string; }; getExternalUrlConfig: () => { policy: IExternalUrlPolicy[]; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 7d288799a34c3..9c2044ef8f8fa 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -95,6 +95,7 @@ export function createPluginSetupContext< uiSettings: deps.uiSettings, injectedMetadata: { getInjectedVar: deps.injectedMetadata.getInjectedVar, + getCsp: deps.injectedMetadata.getCspConfig, }, theme: deps.theme, getStartServices: () => plugin.startDependencies, diff --git a/src/core/server/http_resources/http_resources_service.ts b/src/core/server/http_resources/http_resources_service.ts index 2d49cb17cf4c0..411bc4060cc69 100644 --- a/src/core/server/http_resources/http_resources_service.ts +++ b/src/core/server/http_resources/http_resources_service.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { randomBytes } from 'crypto'; import { RequestHandlerContext } from '..'; import { CoreContext } from '../core_context'; @@ -93,6 +94,7 @@ export class HttpResourcesService implements CoreService; diff --git a/src/plugins/vis_type_script/jest.config.js b/src/plugins/vis_type_script/jest.config.js new file mode 100644 index 0000000000000..835d37312eadd --- /dev/null +++ b/src/plugins/vis_type_script/jest.config.js @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/vis_type_markdown'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/vis_type_markdown', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/src/plugins/vis_type_markdown/{public,server}/**/*.{ts,tsx}'], +}; diff --git a/src/plugins/vis_type_script/kibana.json b/src/plugins/vis_type_script/kibana.json new file mode 100644 index 0000000000000..20a08aac9a48b --- /dev/null +++ b/src/plugins/vis_type_script/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "visTypeScript", + "owner": { + "name": "Kibana Presentation", + "githubTeam": "kibana-presentation" + }, + "description": "Adds a script-based visualization type", + "version": "kibana", + "ui": true, + "server": false, + "requiredPlugins": ["expressions", "visualizations", "data"], + "requiredBundles": ["expressions", "visualizations", "visDefaultEditor", "kibanaReact"] +} diff --git a/src/plugins/vis_type_script/public/expression/fn.ts b/src/plugins/vis_type_script/public/expression/fn.ts new file mode 100644 index 0000000000000..5d41e9f1a68b3 --- /dev/null +++ b/src/plugins/vis_type_script/public/expression/fn.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import type { ExpressionFunctionDefinition, Render } from '@kbn/expressions-plugin/public'; +import { ExpressionValueSearchContext } from '@kbn/data-plugin/common'; +import type { Arguments } from '../types'; +import type { RenderValue } from './renderer'; + +type ScriptVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'scriptVis', + ExpressionValueSearchContext | null, + Arguments, + Render +>; + +export const createScriptVisFn = (): ScriptVisExpressionFunctionDefinition => ({ + name: 'scriptVis', + type: 'render', + inputTypes: ['kibana_context', 'null'], + help: i18n.translate('visTypeMarkdown.function.help', { + defaultMessage: 'Script-based visualization', + }), + args: { + script: { + types: ['string'], + required: true, + help: i18n.translate('visTypeScript.function.markdown.help', { + defaultMessage: 'Visualization script', + }), + }, + dependencyUrls: { + types: ['string'], + multi: true, + required: true, + help: i18n.translate('visTypeScript.function.markdown.help', { + defaultMessage: 'List of script dependencies', + }), + }, + }, + fn(input, args) { + return { + type: 'render', + as: 'script_vis', + value: { + visType: 'script', + visParams: { + script: args.script, + dependencyUrls: args.dependencyUrls, + }, + visSearchContext: { + timeRange: input?.timeRange, + query: input?.query, + filter: input?.filters, + }, + }, + }; + }, +}); diff --git a/src/plugins/vis_type_script/public/expression/renderer.tsx b/src/plugins/vis_type_script/public/expression/renderer.tsx new file mode 100644 index 0000000000000..2e3f743813b67 --- /dev/null +++ b/src/plugins/vis_type_script/public/expression/renderer.tsx @@ -0,0 +1,57 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { IExternalUrl } from '@kbn/core/public'; +import type { ExpressionRenderDefinition } from '@kbn/expressions-plugin'; +import { VisualizationContainer } from '@kbn/visualizations-plugin/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { VisParams, VisSearchContext } from '../types'; +import { ScriptRenderer } from '../renderer'; +import { VisTypeScriptKibanaApi } from '../kibana_api'; + +export interface RenderValue { + visType: 'script'; + visParams: VisParams; + visSearchContext: VisSearchContext; +} + +export const scriptVisRenderer: ( + // TODO: not sure if this is correct way of passing deps to vis renderer + getDeps: () => Promise<{ + data: DataPublicPluginStart; + validateUrl: IExternalUrl['validateUrl']; + nonce: string; + }> +) => ExpressionRenderDefinition = (getDeps) => ({ + name: 'script_vis', + displayName: 'script-based visualization', + reuseDomNode: true, + render: async (domNode, { visParams, visSearchContext }, handlers) => { + const deps = await getDeps(); + const visTypeScriptKibanaApi = new VisTypeScriptKibanaApi(deps, visSearchContext); + + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render( + + + , + domNode + ); + }, +}); diff --git a/src/plugins/vis_type_script/public/expression/to_ast.ts b/src/plugins/vis_type_script/public/expression/to_ast.ts new file mode 100644 index 0000000000000..eb1d7ba9ff31d --- /dev/null +++ b/src/plugins/vis_type_script/public/expression/to_ast.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { VisToExpressionAst } from '@kbn/visualizations-plugin/public'; +import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/public'; +import type { ExpressionFunctionDefinition, Render } from '@kbn/expressions-plugin/public'; +import type { RenderValue } from './renderer'; +import { VisParams } from '../types'; + +type ScriptVisExpressionFunctionDefinition = ExpressionFunctionDefinition< + 'scriptVis', + unknown, + {}, + Render +>; + +export const toExpressionAst: VisToExpressionAst = (vis) => { + const scriptVis = buildExpressionFunction('scriptVis', { + script: vis.params.script, + dependencyUrls: vis.params.dependencyUrls, + }); + + const ast = buildExpression([scriptVis]); + + return ast.toAst(); +}; diff --git a/src/plugins/vis_type_script/public/index.ts b/src/plugins/vis_type_script/public/index.ts new file mode 100644 index 0000000000000..f5663344d469c --- /dev/null +++ b/src/plugins/vis_type_script/public/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginInitializerContext } from '@kbn/core/public'; +import { ScriptVisPlugin as Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} diff --git a/src/plugins/vis_type_script/public/kibana_api/index.ts b/src/plugins/vis_type_script/public/kibana_api/index.ts new file mode 100644 index 0000000000000..877de71ecd62c --- /dev/null +++ b/src/plugins/vis_type_script/public/kibana_api/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './kibana_api'; diff --git a/src/plugins/vis_type_script/public/kibana_api/kibana_api.ts b/src/plugins/vis_type_script/public/kibana_api/kibana_api.ts new file mode 100644 index 0000000000000..f376eb0236aa7 --- /dev/null +++ b/src/plugins/vis_type_script/public/kibana_api/kibana_api.ts @@ -0,0 +1,119 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type * as estypes from '@elastic/elasticsearch/lib/api/types'; +import { lastValueFrom } from 'rxjs'; +import { + ENHANCED_ES_SEARCH_STRATEGY, + SQL_SEARCH_STRATEGY, + SqlSearchStrategyRequest, + SqlSearchStrategyResponse, +} from '@kbn/data-plugin/common'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { VisSearchContext } from '../types'; + +export interface VisTypeScriptKibanaApiDeps { + data: DataPublicPluginStart; +} + +export interface ESSearchOptions { + useKibanaContext: boolean; + timeField?: string; +} + +// disallow transport properties that are handled by data_plugin and allow only query related properties +const allowedESSearchRequestKeys = [ + 'index', + 'aggregations', + 'aggs', + 'query', + 'q', + 'sort', + 'from', +] as const; +export type ESSearchRequest = Pick< + estypes.SearchRequest, + typeof allowedESSearchRequestKeys[number] +>; +export type ESSearchResponse = estypes.SearchResponse; + +export interface SQLSearchOptions { + useKibanaContext: boolean; + timeField?: string; +} + +const allowedSQLSearchRequestKeys = [ + 'query', + 'columnar', + 'cursor', + 'fetch_size', + 'filter', + 'time_zone', +] as const; +export type SQLSearchRequest = Pick< + estypes.SqlQueryRequest, + typeof allowedSQLSearchRequestKeys[number] +>; +export type SQLSearchResponse = estypes.SqlQueryResponse; + +function sanitizeRequest>(payload: T, allowedKeys: string[]): T { + if (payload == null || typeof payload !== 'object') return payload; + return allowedKeys.reduce((sanitized, allowedKey) => { + if (allowedKey in payload) { + sanitized[allowedKey] = payload[allowedKey]; + } + return sanitized; + }, {} as Record) as T; +} + +export class VisTypeScriptKibanaApi { + constructor( + private readonly deps: VisTypeScriptKibanaApiDeps, + private readonly visSearchContext: VisSearchContext + ) {} + + async esSearch( + payload: ESSearchRequest, + { useKibanaContext = true }: ESSearchOptions = { useKibanaContext: true } + ): Promise { + payload = sanitizeRequest(payload, [...allowedESSearchRequestKeys]); + + if (useKibanaContext) { + // TODO: adjust request based on this.visSearchContext + // eslint-disable-next-line no-console + console.log(this.visSearchContext); + } + + const response = await lastValueFrom( + this.deps.data.search.search({ params: payload }, { strategy: ENHANCED_ES_SEARCH_STRATEGY }) + ); + return response.rawResponse; + } + + async sqlSearch( + payload: SQLSearchRequest, + { useKibanaContext = true }: ESSearchOptions = { useKibanaContext: true } + ): Promise { + payload = sanitizeRequest(payload, [...allowedSQLSearchRequestKeys]); + + if (useKibanaContext) { + // TODO: adjust request based on this.visSearchContext + // eslint-disable-next-line no-console + console.log(this.visSearchContext); + } + + const response = await lastValueFrom( + this.deps.data.search.search( + { params: payload }, + { strategy: SQL_SEARCH_STRATEGY } + ) + ); + + return response.rawResponse; + } +} diff --git a/src/plugins/vis_type_script/public/plugin.ts b/src/plugins/vis_type_script/public/plugin.ts new file mode 100644 index 0000000000000..62bb7f63053fd --- /dev/null +++ b/src/plugins/vis_type_script/public/plugin.ts @@ -0,0 +1,61 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { Plugin as ExpressionsPublicPlugin } from '@kbn/expressions-plugin/public'; +import { VisualizationsSetup } from '@kbn/visualizations-plugin/public'; + +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { getScriptVisDefinition } from './vis_definition'; +import { ConfigSchema } from '../config'; +import { scriptVisRenderer } from './expression/renderer'; +import { createScriptVisFn } from './expression/fn'; + +/** @internal */ +export interface ScriptVisPluginSetupDependencies { + expressions: ReturnType; + visualizations: VisualizationsSetup; +} + +/** @internal */ +export interface ScriptVisPluginStartDependencies { + data: DataPublicPluginStart; +} + +/** @internal */ +export class ScriptVisPlugin + implements Plugin +{ + initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public setup( + core: CoreSetup, + { expressions, visualizations }: ScriptVisPluginSetupDependencies + ) { + const validateUrl = core.http.externalUrl.validateUrl; + visualizations.createBaseVisualization(getScriptVisDefinition(validateUrl)); + expressions.registerRenderer( + scriptVisRenderer(() => + core.getStartServices().then(([coreStart, plugins]) => ({ + data: plugins.data, + validateUrl, + nonce: core.injectedMetadata.getCsp().nonce, + })) + ) + ); + expressions.registerFunction(createScriptVisFn); + } + + public start(core: CoreStart) { + // nothing to do here yet + } +} diff --git a/src/plugins/vis_type_script/public/renderer/index.scss b/src/plugins/vis_type_script/public/renderer/index.scss new file mode 100644 index 0000000000000..7bf9848db0885 --- /dev/null +++ b/src/plugins/vis_type_script/public/renderer/index.scss @@ -0,0 +1,3 @@ +.script-based-visualization-renderer { + height: 100%; +} \ No newline at end of file diff --git a/src/plugins/vis_type_script/public/renderer/index.tsx b/src/plugins/vis_type_script/public/renderer/index.tsx new file mode 100644 index 0000000000000..d8dadbf87875d --- /dev/null +++ b/src/plugins/vis_type_script/public/renderer/index.tsx @@ -0,0 +1,150 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useEffect, useState, useMemo } from 'react'; +import { createEndpoint, fromIframe } from '@remote-ui/rpc'; + +import './index.scss'; +import { IExternalUrl } from '@kbn/core/public'; +import { + ESSearchOptions, + VisTypeScriptKibanaApi, + ESSearchResponse, + ESSearchRequest, + SQLSearchRequest, + SQLSearchOptions, + SQLSearchResponse, +} from '../kibana_api'; + +export const KIBANA_API_CONSTANT_NAME = 'KIBANA'; + +const getSandboxDocument = (script: string, dependencies: string[], nonce: string) => { + return ` + + + + + ${dependencies + .map((dependency) => ``) + .join('')} + + + + + + + `; +}; + +const loadDependencies = (urls: string[]) => { + return Promise.all(urls.map((url) => fetch(url).then((res) => res.text()))); +}; + +export const ScriptRenderer: React.FunctionComponent<{ + script: string; + dependencyUrls: string[]; + kibanaApi: VisTypeScriptKibanaApi; + validateUrl: IExternalUrl['validateUrl']; + nonce: string; +}> = ({ + script: visualizationScript, + dependencyUrls, + kibanaApi, + validateUrl, + nonce, +}: { + script: string; + dependencyUrls: string[]; + kibanaApi: VisTypeScriptKibanaApi; + validateUrl: IExternalUrl['validateUrl']; + nonce: string; +}) => { + const iframeRef = React.useRef(null); + + useEffect(() => { + if (!iframeRef.current) throw new Error('Iframe init error'); + const iframeEl = iframeRef.current; + const endpoint = createEndpoint(fromIframe(iframeEl, { terminate: false })); + + endpoint.expose({ + esSearch: async ( + payload: ESSearchRequest, + options?: ESSearchOptions + ): Promise => { + return kibanaApi.esSearch(payload, options); + }, + sqlSearch: async ( + payload: SQLSearchRequest, + options?: SQLSearchOptions + ): Promise => { + return kibanaApi.sqlSearch(payload, options); + }, + }); + + return () => { + endpoint.terminate(); + }; + }, [kibanaApi]); + + const [dependencies, setDependencies] = useState([]); + + useEffect(() => { + loadDependencies(dependencyUrls.filter((url) => validateUrl(url) !== null)).then( + (deps: string[]) => setDependencies(deps) + ); + }, [dependencyUrls, validateUrl]); + + const sandboxDocument = useMemo( + () => getSandboxDocument(visualizationScript, dependencies, nonce), + [visualizationScript, dependencies, nonce] + ); + + return ( +