diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index f0b590f7ffd6c..83a418f09682c 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -29,6 +29,7 @@ "xpack.ml": "legacy/plugins/ml", "xpack.monitoring": "legacy/plugins/monitoring", "xpack.remoteClusters": "plugins/remote_clusters", + "xpack.painless_playground": "legacy/plugins/painless_playground", "xpack.reporting": ["plugins/reporting", "legacy/plugins/reporting"], "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "plugins/searchprofiler", diff --git a/x-pack/index.js b/x-pack/index.js index f3f569e021070..f6368aeb8137e 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -12,6 +12,7 @@ import { security } from './legacy/plugins/security'; import { ml } from './legacy/plugins/ml'; import { tilemap } from './legacy/plugins/tilemap'; import { grokdebugger } from './legacy/plugins/grokdebugger'; +import { painlessPlayground } from './legacy/plugins/painless_playground'; import { dashboardMode } from './legacy/plugins/dashboard_mode'; import { logstash } from './legacy/plugins/logstash'; import { beats } from './legacy/plugins/beats_management'; @@ -51,6 +52,7 @@ module.exports = function(kibana) { ml(kibana), tilemap(kibana), grokdebugger(kibana), + painlessPlayground(kibana), dashboardMode(kibana), logstash(kibana), beats(kibana), diff --git a/x-pack/legacy/plugins/painless_playground/common/constants.ts b/x-pack/legacy/plugins/painless_playground/common/constants.ts new file mode 100644 index 0000000000000..d8807fcccccf0 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/common/constants.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const PLUGIN_ID = 'painless_playground'; + +export const API_ROUTE_EXECUTE = '/api/painless_playground/execute'; + +export const ADVANCED_SETTINGS_FLAG_NAME = 'devTools:enablePainlessPlayground'; diff --git a/x-pack/legacy/plugins/painless_playground/index.ts b/x-pack/legacy/plugins/painless_playground/index.ts new file mode 100644 index 0000000000000..caa2cff4eb826 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { resolve } from 'path'; +import { PLUGIN_ID, ADVANCED_SETTINGS_FLAG_NAME } from './common/constants'; + +import { registerLicenseChecker } from './server/register_license_checker'; +import { registerExecuteRoute } from './server/register_execute_route'; +import { Legacy } from '../../../../kibana'; + +export const painlessPlayground = (kibana: any) => + new kibana.Plugin({ + id: PLUGIN_ID, + publicDir: resolve(__dirname, 'public'), + require: ['kibana', 'elasticsearch', 'xpack_main'], + configPrefix: 'xpack.painless_playground', + config(Joi: any) { + return Joi.object({ + enabled: Joi.boolean().default(true), + }).default(); + }, + uiExports: { + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + devTools: [resolve(__dirname, 'public/register')], + }, + init: (server: Legacy.Server) => { + // Register feature flag + server.newPlatform.setup.core.uiSettings.register({ + [ADVANCED_SETTINGS_FLAG_NAME]: { + name: i18n.translate('xpack.painless_playground.devTools.painlessPlaygroundTitle', { + defaultMessage: 'Painless Playground', + }), + description: i18n.translate( + 'xpack.painless_playground.devTools.painlessPlaygroundDescription', + { + defaultMessage: 'Enable experimental Painless Playground.', + } + ), + value: false, + category: ['Dev Tools'], + }, + }); + + registerLicenseChecker(server); + registerExecuteRoute(server); + }, + }); diff --git a/x-pack/legacy/plugins/painless_playground/public/common/constants.tsx b/x-pack/legacy/plugins/painless_playground/public/common/constants.tsx new file mode 100644 index 0000000000000..ef3feb1738118 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/common/constants.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultLabel = i18n.translate('xpack.painless_playground.contextDefaultLabel', { + defaultMessage: 'Basic', +}); + +const filterLabel = i18n.translate('xpack.painless_playground.contextFilterLabel', { + defaultMessage: 'Filter', +}); + +const scoreLabel = i18n.translate('xpack.painless_playground.contextScoreLabel', { + defaultMessage: 'Score', +}); + +export const painlessContextOptions = [ + { + value: 'painless_test', + inputDisplay: defaultLabel, + dropdownDisplay: ( + <> + {defaultLabel} + +

The script result will be converted to a string

+
+ + ), + }, + { + value: 'filter', + inputDisplay: filterLabel, + dropdownDisplay: ( + <> + {filterLabel} + +

Use the context of a filter’s script query

+
+ + ), + }, + { + value: 'score', + inputDisplay: scoreLabel, + dropdownDisplay: ( + <> + {scoreLabel} + +

+ Use the context of a cript_score function in function_score query +

+
+ + ), + }, +]; diff --git a/x-pack/legacy/plugins/painless_playground/public/common/types.ts b/x-pack/legacy/plugins/painless_playground/public/common/types.ts new file mode 100644 index 0000000000000..825b0a7848136 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/common/types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +export interface Request { + script: { + source: string; + params?: Record; + }; + context?: string; + context_setup?: { + document: Record; + index: string; + }; +} + +export interface Response { + error?: ExecutionError; + result?: string; +} + +export type ExecutionErrorScriptStack = string[]; + +export interface ExecutionError { + script_stack?: ExecutionErrorScriptStack; + caused_by?: { + type: string; + reason: string; + }; + message?: string; +} + +export type JsonArray = JsonValue[]; +export type JsonValue = null | boolean | number | string | JsonObject | JsonArray; + +export interface JsonObject { + [key: string]: JsonValue; +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/editor.tsx b/x-pack/legacy/plugins/painless_playground/public/components/editor.tsx new file mode 100644 index 0000000000000..f77f4ab888be6 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/editor.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiSpacer, EuiPageContent } from '@elastic/eui'; +import { CodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +interface Props { + code: string; + setCode: (code: string) => void; +} + +export function Editor({ code, setCode }: Props) { + return ( + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/main_controls.tsx b/x-pack/legacy/plugins/painless_playground/public/components/main_controls.tsx new file mode 100644 index 0000000000000..98e8f25f171b1 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/main_controls.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiPopover, + EuiBottomBar, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +interface Props { + toggleRequestFlyout: () => void; + isRequestFlyoutOpen: boolean; + isLoading: boolean; + reset: () => void; +} + +export function MainControls({ + toggleRequestFlyout, + isRequestFlyoutOpen, + isLoading, + reset, +}: Props) { + const [isHelpOpen, setIsHelpOpen] = useState(false); + + const items = [ + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painless_playground.walkthroughButtonLabel', { + defaultMessage: 'Walkthrough', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painless_playground.apiReferenceButtonLabel', { + defaultMessage: 'API reference', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painless_playground.languageSpecButtonLabel', { + defaultMessage: 'Language spec', + })} + , + + { + reset(); + setIsHelpOpen(false); + }} + > + {i18n.translate('xpack.painless_playground.resetButtonLabel', { + defaultMessage: 'Reset script', + })} + , + ]; + + return ( + <> +
+ + + + + + + setIsHelpOpen(!isHelpOpen)} + > + {i18n.translate('xpack.painless_playground.helpButtonLabel', { + defaultMessage: 'Help', + })} + + } + isOpen={isHelpOpen} + closePopover={() => setIsHelpOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="upRight" + > + + + + + + + + {isRequestFlyoutOpen + ? i18n.translate('xpack.painless_playground.hideRequestButtonLabel', { + defaultMessage: 'Hide API request', + }) + : i18n.translate('xpack.painless_playground.showRequestButtonLabel', { + defaultMessage: 'Show API request', + })} + + + + + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/output_pane/context_tab.tsx b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/context_tab.tsx new file mode 100644 index 0000000000000..ec0f27c005b96 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/context_tab.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; +import { painlessContextOptions } from '../../common/constants'; + +interface Props { + context: string; + contextSetup: Record; + setContext: (context: string) => void; + setContextSetup: (contextSetup: Record) => void; + renderMainControls: () => React.ReactElement; +} + +export function ContextTab({ context, contextSetup, setContext, setContextSetup }: Props) { + return ( + <> + + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painless_playground.contextFieldDocLinkText', { + defaultMessage: 'Context docs', + })} + + + } + fullWidth + > + setContext(value)} + itemLayoutAlign="top" + hasDividers + fullWidth + /> + + + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + > + + setContextSetup(Object.assign({}, contextSetup, { index: e.target.value })) + } + /> + + )} + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + > + + + setContextSetup(Object.assign({}, contextSetup, { document: value })) + } + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/output_pane/index.ts b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/index.ts new file mode 100644 index 0000000000000..85b7a7816b5aa --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { OutputPane } from './output_pane'; diff --git a/x-pack/legacy/plugins/painless_playground/public/components/output_pane/output_pane.tsx b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/output_pane.tsx new file mode 100644 index 0000000000000..69393ca47a66c --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/output_pane.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiTabbedContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { formatJson, formatResponse } from '../../lib/helpers'; +import { Response } from '../../common/types'; +import { OutputTab } from './output_tab'; +import { ParametersTab } from './parameters_tab'; +import { ContextTab } from './context_tab'; + +export function OutputPane({ + response, + context, + contextSetup, + setContext, + setContextSetup, + isLoading, +}: { + response?: Response; +}) { + const outputTabLabel = ( + + + {isLoading ? ( + + ) : response.error ? ( + + ) : ( + + )} + + + + {i18n.translate('xpack.painless_playground.outputTabLabel', { + defaultMessage: 'Output', + })} + + + ); + + return ( + + , + }, + { + id: 'parameters', + name: i18n.translate('xpack.painless_playground.parametersTabLabel', { + defaultMessage: 'Parameters', + }), + content: ( + + ), + }, + { + id: 'context', + name: i18n.translate('xpack.painless_playground.contextTabLabel', { + defaultMessage: 'Context', + }), + content: ( + + ), + }, + ]} + /> + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/output_pane/output_tab.tsx b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/output_tab.tsx new file mode 100644 index 0000000000000..4b69e7969c924 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/output_tab.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { formatResponse } from '../../lib/helpers'; +import { Response } from '../../common/types'; + +interface Props { + response?: Response; +} + +export function OutputTab({ response = {} }: Props) { + return ( + <> + + + {formatResponse(response.success || response.error)} + + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/output_pane/parameters_tab.tsx b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/parameters_tab.tsx new file mode 100644 index 0000000000000..02dd5b0971e2c --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/output_pane/parameters_tab.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; +import { painlessContextOptions } from '../../common/constants'; + +interface Props { + context: string; + contextSetup: Record; + setContext: (context: string) => void; + setContextSetup: (contextSetup: Record) => void; + renderMainControls: () => React.ReactElement; +} + +export function ParametersTab({ context, contextSetup, setContext, setContextSetup }: Props) { + return ( + <> + + + + {' '} + + + + } + fullWidth + labelAppend={ + + + {i18n.translate('xpack.painless_playground.parametersFieldDocLinkText', { + defaultMessage: 'Parameters docs', + })} + + + } + helpText={i18n.translate('xpack.painless_playground.helpIconAriaLabel', { + defaultMessage: 'Use JSON format', + })} + > + + setContextSetup({ params: value })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/painless_playground.tsx b/x-pack/legacy/plugins/painless_playground/public/components/painless_playground.tsx new file mode 100644 index 0000000000000..1a94d42606120 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/painless_playground.tsx @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { debounce } from 'lodash'; +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiTabbedContent, + EuiTitle, + EuiSpacer, + EuiPageContent, + EuiFlyout, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { buildRequestPayload, formatJson, getFromLocalStorage } from '../lib/helpers'; +import { Request, Response } from '../common/types'; +import { OutputPane } from './output_pane'; +import { MainControls } from './main_controls'; +import { Editor } from './editor'; +import { RequestFlyout } from './request_flyout'; + +let _mostRecentRequestId = 0; + +const submit = async (code, context, contextSetup, executeCode, setResponse, setIsLoading) => { + // Prevent an older request that resolves after a more recent request from clobbering it. + // We store the resulting ID in this closure for comparison when the request resolves. + const requestId = ++_mostRecentRequestId; + setIsLoading(true); + + try { + localStorage.setItem('painlessPlaygroundCode', code); + localStorage.setItem('painlessPlaygroundContext', context); + localStorage.setItem('painlessPlaygroundContextSetup', JSON.stringify(contextSetup)); + const response = await executeCode(buildRequestPayload(code, context, contextSetup)); + + if (_mostRecentRequestId === requestId) { + if (response.error) { + setResponse({ + success: undefined, + error: response.error, + }); + } else { + setResponse({ + success: response, + error: undefined, + }); + } + setIsLoading(false); + } + } catch (error) { + if (_mostRecentRequestId === requestId) { + setResponse({ + success: undefined, + error: { error }, + }); + setIsLoading(false); + } + } +}; + +const debouncedSubmit = debounce(submit, 800); + +// Render a heart as an example. +const exampleScript = ` +def result = ''; +int charCount = 0; + +int n = 10; +int threshold = n*n/2; +int dimension = 3*n/2; + +for (int i = -dimension; i <= n; i++) { + int a = -n/2-i; + + for (int j = -dimension; j <= dimension; j++) { + int b = n/2-j; + int c = -n/2-j; + + def isHeartVentricles = (Math.abs(i) + Math.abs(j) < n); + def isRightAtrium = ((a * a) + (b * b) <= threshold); + def isLeftAtrium = ((a * a) + (c * c) <= threshold); + + if (isHeartVentricles || isRightAtrium || isLeftAtrium) { + result += "* "; + } else { + result += ". "; + } + + // Make sure the heart doesn't deform as the container changes width. + charCount++; + if (charCount % 31 === 0) { + result += "\\\\n"; + } + } +} + +return result; +`; + +export function PainlessPlayground({ + executeCode, +}: { + executeCode: (payload: Request) => Promise; +}) { + const [code, setCode] = useState(getFromLocalStorage('painlessPlaygroundCode', exampleScript)); + const [response, setResponse] = useState({ error: undefined, success: undefined }); + const [isRequestFlyoutOpen, setRequestFlyoutOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [context, setContext] = useState( + getFromLocalStorage('painlessPlaygroundContext', 'painless_test_without_params') + ); + + const [contextSetup, setContextSetup] = useState( + getFromLocalStorage('painlessPlaygroundContextSetup', {}, true) + ); + + // Live-update the output as the user changes the input code. + useEffect(() => { + debouncedSubmit(code, context, contextSetup, executeCode, setResponse, setIsLoading); + }, [code, context, contextSetup, executeCode]); + + const toggleRequestFlyout = () => { + setRequestFlyoutOpen(!isRequestFlyoutOpen); + }; + + return ( + <> + + + +

+ {i18n.translate('xpack.painless_playground.title', { + defaultMessage: 'Painless Playground', + })} +

+
+ + +
+ + + + +
+ + submit(code, context, contextSetup, executeCode, setResponse)} + isLoading={isLoading} + toggleRequestFlyout={toggleRequestFlyout} + isRequestFlyoutOpen={isRequestFlyoutOpen} + reset={() => setCode(exampleScript)} + /> + + {isRequestFlyoutOpen && ( + setRequestFlyoutOpen(false)} + requestBody={formatJson(buildRequestPayload(code, context, contextSetup))} + response={formatJson(response.success || response.error)} + /> + )} + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/components/request_flyout.tsx b/x-pack/legacy/plugins/painless_playground/public/components/request_flyout.tsx new file mode 100644 index 0000000000000..60ecdd5d4799b --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/components/request_flyout.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiCodeBlock, + EuiTabbedContent, + EuiTitle, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function RequestFlyout({ + onClose, + requestBody, + response, +}: { + onClose: any; + requestBody: string; + response?: string; +}) { + return ( + + + + + {/* We need an extra div to get out of flex grow */} +
+ +

+ {i18n.translate('xpack.painless_playground.flyoutTitle', { + defaultMessage: 'API request', + })} +

+
+
+
+ + + + {i18n.translate('xpack.painless_playground.flyoutDocLink', { + defaultMessage: 'API documentation', + })} + + +
+
+ + + + {'POST _scripts/painless/_execute\n'} + {requestBody} + + ), + }, + { + id: 'response', + name: 'Response', + content: ( + + {response} + + ), + }, + ]} + /> + +
+ + + ); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/index.scss b/x-pack/legacy/plugins/painless_playground/public/index.scss new file mode 100644 index 0000000000000..46b834c83786c --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/index.scss @@ -0,0 +1,33 @@ +// Import the EUI global scope so we can use EUI constants +@import 'src/legacy/ui/public/styles/_styling_constants'; + +/** + * 1. This is a very brittle way of preventing the editor and other content from disappearing + * behind the bottom bar. + */ +.painlessPlaygroundBottomBarPlaceholder { + height: $euiSize * 2; /* [1] */ +} + +.painlessPlaygroundRightPane { + border-right: none; + border-top: none; + border-bottom: none; + border-radius: 0; + padding-top: 0; + height: 100%; +} + +.painlessPlaygroundRightPane__tabs { + display: flex; + flex-direction: column; + height: 100%; + + [role="tabpanel"] { + height: 100%; + } +} + +.painlessPlayground__betaLabelContainer { + line-height: 0; +} diff --git a/x-pack/legacy/plugins/painless_playground/public/lib/execute_code.ts b/x-pack/legacy/plugins/painless_playground/public/lib/execute_code.ts new file mode 100644 index 0000000000000..c8b9f2724d38b --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/lib/execute_code.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { API_ROUTE_EXECUTE } from '../../common/constants'; + +export async function executeCode(http: any, payload: Record) { + return await http.post(API_ROUTE_EXECUTE, { + body: JSON.stringify(payload), + }); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/lib/helpers.ts b/x-pack/legacy/plugins/painless_playground/public/lib/helpers.ts new file mode 100644 index 0000000000000..ddc633b2e99d7 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/lib/helpers.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Response, Request, ExecutionError, JsonObject } from '../common/types'; + +export function parseJSON(text: string) { + try { + return JSON.parse(text); + } catch (e) { + return {}; + } +} + +export function buildRequestPayload( + code: string, + context: string, + contextSetup: Record +) { + const request: Request = { + script: { + source: code, + }, + }; + if (contextSetup.params) { + request.script.params = parseJSON(contextSetup?.params); + } + if (context === 'filter' || context === 'score') { + request.context = context; + request.context_setup = { + index: contextSetup.index, + document: parseJSON(contextSetup.document), + }; + return request; + } + + return request; +} + +/** + * Retrieves a value from the browsers local storage, provides a default + * if none is given. With the parse flag you can parse textual JSON to an object + */ +export function getFromLocalStorage( + key: string, + defaultValue: string | JsonObject = '', + parse = false +) { + const value = localStorage.getItem(key); + if (value && parse) { + try { + return JSON.parse(value); + } catch (e) { + return defaultValue; + } + } else if (value) { + return value; + } + return defaultValue; +} + +/** + * Stringify a given object to JSON in a formatted way + */ +export function formatJson(json: unknown): string { + try { + return JSON.stringify(json, null, 2); + } catch (e) { + return `Invalid JSON ${String(json)}`; + } +} + +export function formatResponse(response?: Response): string { + if (!response) { + return ''; + } + + if (typeof response.result === 'string') { + return response.result.replace(/\\n/g, '\n'); + } else if (response.error) { + return formatExecutionError(response.error); + } + return formatJson(response); +} + +export function formatExecutionError(json: ExecutionError): string { + if (json.script_stack && json.caused_by) { + return `Unhandled Exception ${json.caused_by.type} + +${json.caused_by.reason} + +Located at: +${formatJson(json.script_stack)} +`; + } + return formatJson(json); +} diff --git a/x-pack/legacy/plugins/painless_playground/public/register.tsx b/x-pack/legacy/plugins/painless_playground/public/register.tsx new file mode 100644 index 0000000000000..4908eeb758114 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/register.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +// @ts-ignore +import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; +import { npSetup, npStart } from 'ui/new_platform'; +import { registerPainless } from './register_painless'; +import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public'; +import { ADVANCED_SETTINGS_FLAG_NAME } from '../common/constants'; + +npSetup.plugins.home.featureCatalogue.register({ + id: 'painless_playground', + title: i18n.translate('xpack.painless_playground.registryProviderTitle', { + defaultMessage: 'Painless Playground (beta)', + }), + description: i18n.translate('xpack.painless_playground.registryProviderDescription', { + defaultMessage: 'Simulate and debug painless code', + }), + icon: '', + path: '/app/kibana#/dev_tools/painless_playground', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, +}); + +npSetup.core.uiSettings.get$(ADVANCED_SETTINGS_FLAG_NAME, false).subscribe(value => { + // eslint-disable-next-line + console.log('use this to figure out whether we should register', value); +}); + +npSetup.plugins.devTools.register({ + order: 7, + title: ( + + + {i18n.translate('xpack.painless_playground.displayName', { + defaultMessage: 'Painless Playground', + })} + + + + + + + ), + enableRouting: false, + disabled: false, + tooltipContent: xpackInfo.get('features.painlessPlayground.message'), + async mount(context, { element }) { + registerPainless(); + + const licenseCheck = { + showPage: xpackInfo.get('features.painlessPlayground.enableLink'), + message: xpackInfo.get('features.painlessPlayground.message'), + }; + + if (!licenseCheck.showPage) { + npStart.core.notifications.toasts.addDanger(licenseCheck.message); + window.location.hash = '/dev_tools'; + return () => {}; + } + + const { renderApp } = await import('./render_app'); + return renderApp(element, npStart.core); + }, +}); diff --git a/x-pack/legacy/plugins/painless_playground/public/register_painless.ts b/x-pack/legacy/plugins/painless_playground/public/register_painless.ts new file mode 100644 index 0000000000000..700e6d93dad2a --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/public/register_painless.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +export const LANGUAGE_ID = 'painless'; + +/** + * Extends the default type for a Monarch language so we can use + * attribute references (like @keywords to reference the keywords list) + * in the defined tokenizer + */ +interface Language extends monaco.languages.IMonarchLanguage { + default: string; + brackets: any; + keywords: string[]; + symbols: RegExp; + escapes: RegExp; + digits: RegExp; + primitives: string[]; + octaldigits: RegExp; + binarydigits: RegExp; + constants: string[]; + operators: string[]; +} + +function getPainlessLanguage() { + return { + default: '', + // painless does not use < >, so we define our own + brackets: [ + ['{', '}', 'delimiter.curly'], + ['[', ']', 'delimiter.square'], + ['(', ')', 'delimiter.parenthesis'], + ], + keywords: [ + 'if', + 'in', + 'else', + 'while', + 'do', + 'for', + 'continue', + 'break', + 'return', + 'new', + 'try', + 'catch', + 'throw', + 'this', + 'instanceof', + ], + primitives: ['void', 'boolean', 'byte', 'short', 'char', 'int', 'long', 'float', 'double'], + constants: ['true', 'false'], + operators: [ + '=', + '>', + '<', + '!', + '~', + '?', + '?:', + '?.', + ':', + '==', + '===', + '<=', + '>=', + '!=', + '!==', + '&&', + '||', + '++', + '--', + '+', + '-', + '*', + '/', + '&', + '|', + '^', + '%', + '<<', + '>>', + '>>>', + '+=', + '-=', + '*=', + '/=', + '&=', + '|=', + '^=', + '%=', + '<<=', + '>>=', + '>>>=', + '->', + '::', + '=~', + '==~', + ], + symbols: /[=> + + executeCode(http, payload)} /> + + , + element + ); + return () => unmountComponentAtNode(element); +} diff --git a/x-pack/legacy/plugins/painless_playground/server/lib/check_license.ts b/x-pack/legacy/plugins/painless_playground/server/lib/check_license.ts new file mode 100644 index 0000000000000..f6a815abe9a47 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/server/lib/check_license.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export function checkLicense(xpackLicenseInfo: any) { + // If, for some reason, we cannot get the license information + // from Elasticsearch, assume worst case and disable the Watcher UI + if (!xpackLicenseInfo || !xpackLicenseInfo.isAvailable()) { + return { + enableLink: false, + enableAPIRoute: false, + message: i18n.translate('xpack.painless_playground.unavailableLicenseInformationMessage', { + defaultMessage: + 'You cannot use the Painless Playground because license information is not available at this time.', + }), + }; + } + + const isLicenseActive = xpackLicenseInfo.license.isActive(); + const licenseType = xpackLicenseInfo.license.getType(); + + // License is not valid + if (!isLicenseActive) { + return { + enableLink: false, + enableAPIRoute: false, + message: i18n.translate('xpack.painless_playground.licenseHasExpiredMessage', { + defaultMessage: + 'You cannot use the Painless Playground because your {licenseType} license has expired.', + values: { + licenseType, + }, + }), + }; + } + + // License is valid and active + return { + enableLink: true, + enableAPIRoute: true, + }; +} diff --git a/x-pack/legacy/plugins/painless_playground/server/lib/license_pre_routing_factory.ts b/x-pack/legacy/plugins/painless_playground/server/lib/license_pre_routing_factory.ts new file mode 100644 index 0000000000000..387a263114a6e --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/server/lib/license_pre_routing_factory.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import Boom from 'boom'; +import { PLUGIN_ID } from '../../common/constants'; +import { ServerFacade } from '../../../index_management'; + +export const licensePreRoutingFactory = (server: ServerFacade) => { + const xpackMainPlugin = server.plugins.xpack_main; + + // License checking and enable/disable logic + function licensePreRouting() { + const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN_ID).getLicenseCheckResults(); + if (!licenseCheckResults.enableAPIRoute) { + throw Boom.forbidden(licenseCheckResults.message); + } + + return null; + } + + return licensePreRouting; +}; diff --git a/x-pack/legacy/plugins/painless_playground/server/register_execute_route.ts b/x-pack/legacy/plugins/painless_playground/server/register_execute_route.ts new file mode 100644 index 0000000000000..e4ffad9c21d60 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/server/register_execute_route.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { ServerRoute } from 'hapi'; +import { licensePreRoutingFactory } from './lib/license_pre_routing_factory'; +import { Legacy } from '../../../../../kibana'; +import { API_ROUTE_EXECUTE } from '../common/constants'; + +export function registerExecuteRoute(server: any) { + const licensePreRouting = licensePreRoutingFactory(server); + + server.route({ + path: API_ROUTE_EXECUTE, + method: 'POST', + handler: (request: Legacy.Request) => { + const cluster = server.plugins.elasticsearch.getCluster('data'); + return cluster + .callWithRequest(request, 'scriptsPainlessExecute', { + body: request.payload, + }) + .catch((e: any) => { + return e.body; + }); + }, + config: { + pre: [licensePreRouting], + }, + } as ServerRoute); +} diff --git a/x-pack/legacy/plugins/painless_playground/server/register_license_checker.ts b/x-pack/legacy/plugins/painless_playground/server/register_license_checker.ts new file mode 100644 index 0000000000000..1ec5b33c4d9f9 --- /dev/null +++ b/x-pack/legacy/plugins/painless_playground/server/register_license_checker.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +// @ts-ignore +import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; +import { checkLicense } from './lib/check_license'; +import { PLUGIN_ID } from '../common/constants'; + +export function registerLicenseChecker(server: any) { + const xpackMainPlugin = server.plugins.xpack_main; + const plugin = server.plugins[PLUGIN_ID]; + + mirrorPluginStatus(xpackMainPlugin, plugin); + xpackMainPlugin.status.once('green', () => { + // Register a function that is called whenever the xpack info changes, + // to re-compute the license check results for this plugin + xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); + }); +}