diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index df3a56dd35130..2db898fab68bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -181,6 +181,7 @@ /x-pack/plugins/remote_clusters/ @elastic/es-ui /x-pack/legacy/plugins/rollup/ @elastic/es-ui /x-pack/plugins/searchprofiler/ @elastic/es-ui +/x-pack/plugins/painless_lab/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/upgrade_assistant/ @elastic/es-ui /x-pack/plugins/upgrade_assistant/ @elastic/es-ui diff --git a/packages/kbn-ui-shared-deps/monaco.ts b/packages/kbn-ui-shared-deps/monaco.ts index 570aca86c484c..42801c69a3e2c 100644 --- a/packages/kbn-ui-shared-deps/monaco.ts +++ b/packages/kbn-ui-shared-deps/monaco.ts @@ -25,6 +25,8 @@ import 'monaco-editor/esm/vs/base/worker/defaultWorkerFactory'; import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js'; import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js'; // Needed for word-wise char navigation + import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; // Needed for suggestions import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 9ebfeb5387b26..df61271baf879 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -132,4 +132,6 @@ export class DevToolsPlugin implements Plugin { getSortedDevTools: this.getSortedDevTools.bind(this), }; } + + public stop() {} } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index a8bb989f6bff3..2a28e349ace99 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -30,6 +30,7 @@ "xpack.ml": ["plugins/ml", "legacy/plugins/ml"], "xpack.monitoring": ["plugins/monitoring", "legacy/plugins/monitoring"], "xpack.remoteClusters": "plugins/remote_clusters", + "xpack.painlessLab": "plugins/painless_lab", "xpack.reporting": ["plugins/reporting", "legacy/plugins/reporting"], "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "plugins/searchprofiler", diff --git a/x-pack/plugins/painless_lab/common/constants.ts b/x-pack/plugins/painless_lab/common/constants.ts new file mode 100644 index 0000000000000..dfc7d8ae85a2c --- /dev/null +++ b/x-pack/plugins/painless_lab/common/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { LicenseType } from '../../licensing/common/types'; + +const basicLicense: LicenseType = 'basic'; + +export const PLUGIN = { + id: 'painlessLab', + minimumLicenseType: basicLicense, +}; + +export const API_BASE_PATH = '/api/painless_lab'; diff --git a/x-pack/plugins/painless_lab/kibana.json b/x-pack/plugins/painless_lab/kibana.json new file mode 100644 index 0000000000000..4b4ea24202846 --- /dev/null +++ b/x-pack/plugins/painless_lab/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "painlessLab", + "version": "8.0.0", + "kibanaVersion": "kibana", + "requiredPlugins": [ + "devTools", + "licensing", + "home" + ], + "configPath": [ + "xpack", + "painless_lab" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/painless_lab/public/application/components/editor.tsx b/x-pack/plugins/painless_lab/public/application/components/editor.tsx new file mode 100644 index 0000000000000..b8891ce6524f5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/editor.tsx @@ -0,0 +1,35 @@ +/* + * 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 { CodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +interface Props { + code: string; + onChange: (code: string) => void; +} + +export function Editor({ code, onChange }: Props) { + return ( + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/main.tsx b/x-pack/plugins/painless_lab/public/application/components/main.tsx new file mode 100644 index 0000000000000..10907536e9cc2 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main.tsx @@ -0,0 +1,94 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { formatRequestPayload, formatJson } from '../lib/format'; +import { exampleScript } from '../constants'; +import { PayloadFormat } from '../types'; +import { useSubmitCode } from '../hooks'; +import { useAppContext } from '../context'; +import { OutputPane } from './output_pane'; +import { MainControls } from './main_controls'; +import { Editor } from './editor'; +import { RequestFlyout } from './request_flyout'; + +export const Main: React.FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + services: { + http, + chrome: { getIsNavDrawerLocked$ }, + }, + links, + } = useAppContext(); + + const [isRequestFlyoutOpen, setRequestFlyoutOpen] = useState(false); + const { inProgress, response, submit } = useSubmitCode(http); + + // Live-update the output and persist payload state as the user changes it. + useEffect(() => { + if (validation.isValid) { + submit(payload); + } + }, [payload, submit, validation.isValid]); + + const toggleRequestFlyout = () => { + setRequestFlyoutOpen(!isRequestFlyoutOpen); + }; + + const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); + + useEffect(() => { + const subscription = getIsNavDrawerLocked$().subscribe((newIsNavDrawerLocked: boolean) => { + setIsNavDrawerLocked(newIsNavDrawerLocked); + }); + + return () => subscription.unsubscribe(); + }); + + return ( +
+ + + +

+ {i18n.translate('xpack.painlessLab.title', { + defaultMessage: 'Painless Lab', + })} +

+
+ + updatePayload({ code: nextCode })} /> +
+ + + + +
+ + updatePayload({ code: exampleScript })} + /> + + {isRequestFlyoutOpen && ( + setRequestFlyoutOpen(false)} + requestBody={formatRequestPayload(payload, PayloadFormat.PRETTY)} + response={response ? formatJson(response.result || response.error) : ''} + /> + )} +
+ ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx new file mode 100644 index 0000000000000..6307c21e26dc4 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx @@ -0,0 +1,145 @@ +/* + * 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 classNames from 'classnames'; +import { + EuiPopover, + EuiBottomBar, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Links } from '../../links'; + +interface Props { + toggleRequestFlyout: () => void; + isRequestFlyoutOpen: boolean; + isLoading: boolean; + reset: () => void; + links: Links; + isNavDrawerLocked: boolean; +} + +export function MainControls({ + toggleRequestFlyout, + isRequestFlyoutOpen, + reset, + links, + isNavDrawerLocked, +}: Props) { + const [isHelpOpen, setIsHelpOpen] = useState(false); + + const items = [ + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.walkthroughButtonLabel', { + defaultMessage: 'Walkthrough', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.apiReferenceButtonLabel', { + defaultMessage: 'API reference', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.languageSpecButtonLabel', { + defaultMessage: 'Language spec', + })} + , + + { + reset(); + setIsHelpOpen(false); + }} + > + {i18n.translate('xpack.painlessLab.resetButtonLabel', { + defaultMessage: 'Reset script', + })} + , + ]; + + const classes = classNames('painlessLab__bottomBar', { + 'painlessLab__bottomBar-isNavDrawerLocked': isNavDrawerLocked, + }); + + return ( + + + + + + setIsHelpOpen(!isHelpOpen)} + > + {i18n.translate('xpack.painlessLab.helpButtonLabel', { + defaultMessage: 'Help', + })} + + } + isOpen={isHelpOpen} + closePopover={() => setIsHelpOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="upRight" + > + + + + + + + + {isRequestFlyoutOpen + ? i18n.translate('xpack.painlessLab.hideRequestButtonLabel', { + defaultMessage: 'Hide API request', + }) + : i18n.translate('xpack.painlessLab.showRequestButtonLabel', { + defaultMessage: 'Show API request', + })} + + + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx new file mode 100644 index 0000000000000..47efd524f092a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx @@ -0,0 +1,202 @@ +/* + * 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, { FunctionComponent } 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 '../../constants'; +import { useAppContext } from '../../context'; + +export const ContextTab: FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + links, + } = useAppContext(); + const { context, document, index, query } = payload; + + return ( + <> + + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.contextFieldDocLinkText', { + defaultMessage: 'Context docs', + })} + + + } + fullWidth + > + updatePayload({ context: nextContext })} + itemLayoutAlign="top" + hasDividers + fullWidth + /> + + + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + isInvalid={!validation.fields.index} + error={ + validation.fields.index + ? [] + : [ + i18n.translate('xpack.painlessLab.indexFieldMissingErrorMessage', { + defaultMessage: 'Enter an index name', + }), + ] + } + > + { + const nextIndex = e.target.value; + updatePayload({ index: nextIndex }); + }} + isInvalid={!validation.fields.index} + /> + + )} + {/* Query DSL Code Editor */} + {'score'.indexOf(context) !== -1 && ( + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.queryFieldDocLinkText', { + defaultMessage: 'Query DSL docs', + })} + + + } + fullWidth + > + + updatePayload({ query: nextQuery })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + > + + updatePayload({ document: nextDocument })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts b/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts new file mode 100644 index 0000000000000..85b7a7816b5aa --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/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/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx new file mode 100644 index 0000000000000..e6a97bb02f738 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiTabbedContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Response } from '../../types'; +import { OutputTab } from './output_tab'; +import { ParametersTab } from './parameters_tab'; +import { ContextTab } from './context_tab'; + +interface Props { + isLoading: boolean; + response?: Response; +} + +export const OutputPane: FunctionComponent = ({ isLoading, response }) => { + const outputTabLabel = ( + + + {isLoading ? ( + + ) : response && response.error ? ( + + ) : ( + + )} + + + + {i18n.translate('xpack.painlessLab.outputTabLabel', { + defaultMessage: 'Output', + })} + + + ); + + return ( + + , + }, + { + id: 'parameters', + name: i18n.translate('xpack.painlessLab.parametersTabLabel', { + defaultMessage: 'Parameters', + }), + content: , + }, + { + id: 'context', + name: i18n.translate('xpack.painlessLab.contextTabLabel', { + defaultMessage: 'Context', + }), + content: , + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx new file mode 100644 index 0000000000000..8969e5421640a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; + +import { formatResponse } from '../../lib/format'; +import { Response } from '../../types'; + +interface Props { + response?: Response; +} + +export function OutputTab({ response }: Props) { + return ( + <> + + + {formatResponse(response)} + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx new file mode 100644 index 0000000000000..7c8bce0f7b21b --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/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, { FunctionComponent } from 'react'; +import { + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/ui-shared-deps/monaco'; +import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; + +import { useAppContext } from '../../context'; + +export const ParametersTab: FunctionComponent = () => { + const { + store: { payload }, + updatePayload, + links, + } = useAppContext(); + return ( + <> + + + + {' '} + + + + } + fullWidth + labelAppend={ + + + {i18n.translate('xpack.painlessLab.parametersFieldDocLinkText', { + defaultMessage: 'Parameters docs', + })} + + + } + > + + updatePayload({ parameters: nextParams })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + editorDidMount={(editor: monaco.editor.IStandaloneCodeEditor) => { + // Updating tab size for the editor + const model = editor.getModel(); + if (model) { + model.updateOptions({ tabSize: 2 }); + } + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx new file mode 100644 index 0000000000000..123df91f4346a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx @@ -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 React, { FunctionComponent } from 'react'; +import { + EuiCodeBlock, + EuiTabbedContent, + EuiTitle, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Links } from '../../links'; + +interface Props { + onClose: any; + requestBody: string; + links: Links; + response?: string; +} + +export const RequestFlyout: FunctionComponent = ({ + onClose, + requestBody, + response, + links, +}) => { + return ( + + + + + {/* We need an extra div to get out of flex grow */} +
+ +

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

+
+
+
+ + + + {i18n.translate('xpack.painlessLab.flyoutDocLink', { + defaultMessage: 'API documentation', + })} + + +
+
+ + + + {'POST _scripts/painless/_execute\n'} + {requestBody} + + ), + }, + { + id: 'response', + name: 'Response', + content: ( + + {response} + + ), + }, + ]} + /> + +
+ + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/constants.tsx b/x-pack/plugins/painless_lab/public/application/constants.tsx new file mode 100644 index 0000000000000..d8430dbfc7d9d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/constants.tsx @@ -0,0 +1,135 @@ +/* + * 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.painlessLab.contextDefaultLabel', { + defaultMessage: 'Basic', +}); + +const filterLabel = i18n.translate('xpack.painlessLab.contextFilterLabel', { + defaultMessage: 'Filter', +}); + +const scoreLabel = i18n.translate('xpack.painlessLab.contextScoreLabel', { + defaultMessage: 'Score', +}); + +export const painlessContextOptions = [ + { + value: 'painless_test', + inputDisplay: defaultLabel, + dropdownDisplay: ( + <> + {defaultLabel} + +

+ {i18n.translate('xpack.painlessLab.context.defaultLabel', { + defaultMessage: 'The script result will be converted to a string', + })} +

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

+ {i18n.translate('xpack.painlessLab.context.filterLabel', { + defaultMessage: "Use the context of a filter's script query", + })} +

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

+ {i18n.translate('xpack.painlessLab.context.scoreLabel', { + defaultMessage: 'Use the context of a script_score function in function_score query', + })} +

+
+ + ), + }, +]; + +// Render a smiley face as an example. +export const exampleScript = `boolean isInCircle(def posX, def posY, def circleX, def circleY, def radius) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow(circleY - posY, 2)); + return distanceFromCircleCenter <= radius; +} + +boolean isOnCircle(def posX, def posY, def circleX, def circleY, def radius, def thickness, def squashY) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow((circleY - posY) / squashY, 2)); + return ( + distanceFromCircleCenter >= radius - thickness + && distanceFromCircleCenter <= radius + thickness + ); +} + +def result = ''; +int charCount = 0; + +// Canvas dimensions +int width = 31; +int height = 31; +double halfWidth = Math.floor(width * 0.5); +double halfHeight = Math.floor(height * 0.5); + +// Style constants +double strokeWidth = 0.6; + +// Smiley face configuration +int headSize = 13; +double headSquashY = 0.78; +int eyePositionX = 10; +int eyePositionY = 12; +int eyeSize = 1; +int mouthSize = 15; +int mouthPositionX = width / 2; +int mouthPositionY = 5; +int mouthOffsetY = 11; + +for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + boolean isHead = isOnCircle(x, y, halfWidth, halfHeight, headSize, strokeWidth, headSquashY); + boolean isLeftEye = isInCircle(x, y, eyePositionX, eyePositionY, eyeSize); + boolean isRightEye = isInCircle(x, y, width - eyePositionX - 1, eyePositionY, eyeSize); + boolean isMouth = isOnCircle(x, y, mouthPositionX, mouthPositionY, mouthSize, strokeWidth, 1) && y > mouthPositionY + mouthOffsetY; + + if (isLeftEye || isRightEye || isMouth || isHead) { + result += "*"; + } else { + result += "."; + } + + result += " "; + + // Make sure the smiley face doesn't deform as the container changes width. + charCount++; + if (charCount % width === 0) { + result += "\\\\n"; + } + } +} + +return result;`; diff --git a/x-pack/plugins/painless_lab/public/application/context/context.tsx b/x-pack/plugins/painless_lab/public/application/context/context.tsx new file mode 100644 index 0000000000000..0fb5842dfea58 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/context.tsx @@ -0,0 +1,95 @@ +/* + * 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, { createContext, ReactNode, useState, useContext } from 'react'; +import { HttpSetup, ChromeStart } from 'src/core/public'; + +import { Links } from '../../links'; +import { Store, Payload, Validation } from '../types'; +import { initialPayload } from './initial_payload'; + +interface AppContextProviderArgs { + children: ReactNode; + value: { + http: HttpSetup; + links: Links; + chrome: ChromeStart; + }; +} + +interface ContextValue { + store: Store; + updatePayload: (changes: Partial) => void; + services: { + http: HttpSetup; + chrome: ChromeStart; + }; + links: Links; +} + +const AppContext = createContext(undefined as any); + +const validatePayload = (payload: Payload): Validation => { + const { index } = payload; + + // For now just validate that the user has entered an index. + const indexExists = Boolean(index || index.trim()); + + return { + isValid: indexExists, + fields: { + index: indexExists, + }, + }; +}; + +export const AppContextProvider = ({ + children, + value: { http, links, chrome }, +}: AppContextProviderArgs) => { + const PAINLESS_LAB_KEY = 'painlessLabState'; + + const [store, setStore] = useState(() => { + // Using a callback here ensures these values are only calculated on the first render. + const defaultPayload = { + ...initialPayload, + ...JSON.parse(localStorage.getItem(PAINLESS_LAB_KEY) || '{}'), + }; + + return { + payload: defaultPayload, + validation: validatePayload(defaultPayload), + }; + }); + + const updatePayload = (changes: Partial): void => { + const nextPayload = { + ...store.payload, + ...changes, + }; + // Persist state locally so we can load it up when the user reopens the app. + localStorage.setItem(PAINLESS_LAB_KEY, JSON.stringify(nextPayload)); + + setStore({ + payload: nextPayload, + validation: validatePayload(nextPayload), + }); + }; + + return ( + + {children} + + ); +}; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('AppContext can only be used inside of AppContextProvider!'); + } + return ctx; +}; diff --git a/x-pack/plugins/painless_lab/public/application/context/index.tsx b/x-pack/plugins/painless_lab/public/application/context/index.tsx new file mode 100644 index 0000000000000..7a685137b7a4f --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/index.tsx @@ -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 { AppContextProvider, useAppContext } from './context'; diff --git a/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts new file mode 100644 index 0000000000000..4d9d8ad8b3ae7 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts @@ -0,0 +1,22 @@ +/* + * 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 { exampleScript, painlessContextOptions } from '../constants'; + +export const initialPayload = { + context: painlessContextOptions[0].value, + code: exampleScript, + parameters: `{ + "string_parameter": "string value", + "number_parameter": 1.5, + "boolean_parameter": true +}`, + index: 'my-index', + document: `{ + "my_field": "field_value" +}`, + query: '', +}; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/index.ts b/x-pack/plugins/painless_lab/public/application/hooks/index.ts new file mode 100644 index 0000000000000..159ff96d2278c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/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 { useSubmitCode } from './use_submit_code'; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts new file mode 100644 index 0000000000000..36cd4f280ac4c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useRef, useCallback, useState } from 'react'; +import { HttpSetup } from 'kibana/public'; +import { debounce } from 'lodash'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { Response, PayloadFormat, Payload } from '../types'; +import { formatRequestPayload } from '../lib/format'; + +const DEBOUNCE_MS = 800; + +export const useSubmitCode = (http: HttpSetup) => { + const currentRequestIdRef = useRef(0); + const [response, setResponse] = useState(undefined); + const [inProgress, setInProgress] = useState(false); + + const submit = useCallback( + debounce( + async (config: Payload) => { + setInProgress(true); + + // 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 = ++currentRequestIdRef.current; + + try { + const result = await http.post(`${API_BASE_PATH}/execute`, { + // Stringify the string, because http runs it through JSON.parse, and we want to actually + // send a JSON string. + body: JSON.stringify(formatRequestPayload(config, PayloadFormat.UGLY)), + }); + + if (currentRequestIdRef.current === requestId) { + setResponse(result); + setInProgress(false); + } + // else ignore this response... + } catch (error) { + if (currentRequestIdRef.current === requestId) { + setResponse({ + error, + }); + setInProgress(false); + } + // else ignore this response... + } + }, + DEBOUNCE_MS, + { trailing: true } + ), + [http] + ); + + return { + response, + inProgress, + submit, + }; +}; diff --git a/x-pack/plugins/painless_lab/public/application/index.tsx b/x-pack/plugins/painless_lab/public/application/index.tsx new file mode 100644 index 0000000000000..ebcb84bbce83c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/index.tsx @@ -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 React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { HttpSetup, ChromeStart } from 'src/core/public'; +import { createKibanaReactContext } from '../../../../../src/plugins/kibana_react/public'; + +import { Links } from '../links'; +import { AppContextProvider } from './context'; +import { Main } from './components/main'; + +interface AppDependencies { + http: HttpSetup; + I18nContext: CoreStart['i18n']['Context']; + uiSettings: CoreSetup['uiSettings']; + links: Links; + chrome: ChromeStart; +} + +export function renderApp( + element: HTMLElement | null, + { http, I18nContext, uiSettings, links, chrome }: AppDependencies +) { + if (!element) { + return () => undefined; + } + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings, + }); + render( + + + +
+ + + , + element + ); + return () => unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap new file mode 100644 index 0000000000000..4df90d1b3abe1 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatRequestPayload pretty formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"// Here's a comment and a variable, then a loop. + double halfWidth = Math.floor(width * 0.5); + for (int y = 0; y < height; y++) { + return \\"results here\\\\\\\\n\\"; + } + + return result;\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"\\", + \\"document\\": + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"// Here's a comment and a variable, then a loop.\\\\ndouble halfWidth = Math.floor(width * 0.5);\\\\nfor (int y = 0; y < height; y++) {\\\\n return \\\\\\"results here\\\\\\\\\\\\\\\\n\\\\\\";\\\\n}\\\\n\\\\nreturn result;\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"undefined\\", + \\"document\\": undefined + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": undefined + } +}" +`; diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.test.ts b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts new file mode 100644 index 0000000000000..5f0022ebbc089 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { PayloadFormat } from '../types'; +import { formatRequestPayload } from './format'; + +describe('formatRequestPayload', () => { + Object.values(PayloadFormat).forEach(format => { + describe(`${format} formats`, () => { + test('no script', () => { + expect(formatRequestPayload({}, format)).toMatchSnapshot(); + }); + + test('a single-line script', () => { + const code = 'return "ok";'; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('a complex multi-line script', () => { + const code = `// Here's a comment and a variable, then a loop. +double halfWidth = Math.floor(width * 0.5); +for (int y = 0; y < height; y++) { + return "results here\\\\n"; +} + +return result;`; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('code and parameters', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, parameters }, format)).toMatchSnapshot(); + }); + + test('code, parameters, and context', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + expect(formatRequestPayload({ code, parameters, context }, format)).toMatchSnapshot(); + }); + + test('code, context, index, and document', () => { + const code = 'return "ok";'; + const context = 'filter'; + const index = 'index'; + const document = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, context, index, document }, format)).toMatchSnapshot(); + }); + + test('code, parameters, context, index, and document', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + const index = 'index'; + const document = parameters; + expect( + formatRequestPayload({ code, parameters, context, index, document }, format) + ).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.ts b/x-pack/plugins/painless_lab/public/application/lib/format.ts new file mode 100644 index 0000000000000..15ecdf682d247 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.ts @@ -0,0 +1,117 @@ +/* + * 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, ExecutionError, PayloadFormat, Payload } from '../types'; + +function prettifyPayload(payload = '', indentationLevel = 0) { + const indentation = new Array(indentationLevel + 1).join(' '); + return payload.replace(/\n/g, `\n${indentation}`); +} + +/** + * Values should be preserved as strings so that floating point precision, + * e.g. 1.0, is preserved instead of being coerced to an integer, e.g. 1. + */ +export function formatRequestPayload( + { code, context, parameters, index, document, query }: Partial, + format: PayloadFormat = PayloadFormat.UGLY +): string { + const isAdvancedContext = context === 'filter' || context === 'score'; + + let formattedCode: string | undefined; + let formattedParameters: string | undefined; + let formattedContext: string | undefined; + let formattedIndex: string | undefined; + let formattedDocument: string | undefined; + let formattedQuery: string | undefined; + + if (format === PayloadFormat.UGLY) { + formattedCode = JSON.stringify(code); + formattedParameters = parameters; + formattedContext = context; + formattedIndex = index; + formattedDocument = document; + formattedQuery = query; + } else { + // Triple quote the code because it's multiline. + formattedCode = `"""${prettifyPayload(code, 4)}"""`; + formattedParameters = prettifyPayload(parameters, 4); + formattedContext = prettifyPayload(context, 6); + formattedIndex = prettifyPayload(index); + formattedDocument = prettifyPayload(document, 4); + formattedQuery = prettifyPayload(query, 4); + } + + const requestPayload = `{ + "script": { + "source": ${formattedCode}${ + parameters + ? `, + "params": ${formattedParameters}` + : `` + } + }${ + isAdvancedContext + ? `, + "context": "${formattedContext}", + "context_setup": { + "index": "${formattedIndex}", + "document": ${formattedDocument}${ + query && context === 'score' + ? `, + "query": ${formattedQuery}` + : '' + } + }` + : `` + } +}`; + return requestPayload; +} + +/** + * 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(executionErrorOrError: ExecutionError | Error): string { + if (executionErrorOrError instanceof Error) { + return executionErrorOrError.message; + } + + if ( + executionErrorOrError.script_stack && + executionErrorOrError.caused_by && + executionErrorOrError.position + ) { + return `Unhandled Exception ${executionErrorOrError.caused_by.type} + +${executionErrorOrError.caused_by.reason} + +Stack: +${formatJson(executionErrorOrError.script_stack)} +`; + } + return formatJson(executionErrorOrError); +} diff --git a/x-pack/plugins/painless_lab/public/application/types.ts b/x-pack/plugins/painless_lab/public/application/types.ts new file mode 100644 index 0000000000000..d800558ef7ecc --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Store { + payload: Payload; + validation: Validation; +} + +export interface Payload { + context: string; + code: string; + parameters: string; + index: string; + document: string; + query: string; +} + +export interface Validation { + isValid: boolean; + fields: { + index: boolean; + }; +} + +// TODO: This should be an enumerated list +export type Context = string; + +export enum PayloadFormat { + UGLY = 'ugly', + PRETTY = 'pretty', +} + +export interface Response { + error?: ExecutionError | Error; + result?: string; +} + +export type ExecutionErrorScriptStack = string[]; + +export interface ExecutionErrorPosition { + start: number; + end: number; + offset: number; +} + +export interface ExecutionError { + script_stack?: ExecutionErrorScriptStack; + caused_by?: { + type: string; + reason: string; + }; + message?: string; + position: ExecutionErrorPosition; + script: string; +} diff --git a/x-pack/plugins/painless_lab/public/index.scss b/x-pack/plugins/painless_lab/public/index.scss new file mode 100644 index 0000000000000..29a5761255278 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.scss @@ -0,0 +1 @@ +@import 'styles/index'; diff --git a/x-pack/plugins/painless_lab/public/index.ts b/x-pack/plugins/painless_lab/public/index.ts new file mode 100644 index 0000000000000..da357e52af676 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import './styles/_index.scss'; +import { PainlessLabUIPlugin } from './plugin'; + +export function plugin() { + return new PainlessLabUIPlugin(); +} diff --git a/x-pack/plugins/painless_lab/public/lib/index.ts b/x-pack/plugins/painless_lab/public/lib/index.ts new file mode 100644 index 0000000000000..2421307b7c107 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/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 { monacoPainlessLang } from './monaco_painless_lang'; diff --git a/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts new file mode 100644 index 0000000000000..602697064a768 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts @@ -0,0 +1,174 @@ +/* + * 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'; + +/** + * 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[]; +} + +export const monacoPainlessLang = { + 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', 'def'], + constants: ['true', 'false'], + operators: [ + '=', + '>', + '<', + '!', + '~', + '?', + '?:', + '?.', + ':', + '==', + '===', + '<=', + '>=', + '!=', + '!==', + '&&', + '||', + '++', + '--', + '+', + '-', + '*', + '/', + '&', + '|', + '^', + '%', + '<<', + '>>', + '>>>', + '+=', + '-=', + '*=', + '/=', + '&=', + '|=', + '^=', + '%=', + '<<=', + '>>=', + '>>>=', + '->', + '::', + '=~', + '==~', + ], + symbols: /[=>; + +export const getLinks = ({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL }: DocLinksStart) => + Object.freeze({ + painlessExecuteAPI: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, + painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, + painlessAPIReference: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, + painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, + painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, + esQueryDSL: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, + modulesScriptingPreferParams: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/modules-scripting-using.html#prefer-params`, + }); diff --git a/x-pack/plugins/painless_lab/public/plugin.tsx b/x-pack/plugins/painless_lab/public/plugin.tsx new file mode 100644 index 0000000000000..b9ca7031cf670 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/plugin.tsx @@ -0,0 +1,114 @@ +/* + * 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 { Plugin, CoreStart, CoreSetup } from 'kibana/public'; +import { first } from 'rxjs/operators'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; +import { LICENSE_CHECK_STATE } from '../../licensing/public'; + +import { PLUGIN } from '../common/constants'; + +import { PluginDependencies } from './types'; +import { getLinks } from './links'; +import { LanguageService } from './services'; + +export class PainlessLabUIPlugin implements Plugin { + languageService = new LanguageService(); + + async setup( + { http, getStartServices, uiSettings }: CoreSetup, + { devTools, home, licensing }: PluginDependencies + ) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.painlessLab.registryProviderTitle', { + defaultMessage: 'Painless Lab (beta)', + }), + description: i18n.translate('xpack.painlessLab.registryProviderDescription', { + defaultMessage: 'Simulate and debug painless code.', + }), + icon: '', + path: '/app/kibana#/dev_tools/painless_lab', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + + devTools.register({ + id: 'painless_lab', + order: 7, + title: ( + + + {i18n.translate('xpack.painlessLab.displayName', { + defaultMessage: 'Painless Lab', + })} + + + + + + + ) as any, + enableRouting: false, + disabled: false, + mount: async (ctx, { element }) => { + const [core] = await getStartServices(); + + const { + i18n: { Context: I18nContext }, + notifications, + docLinks, + chrome, + } = core; + + this.languageService.setup(); + + const license = await licensing.license$.pipe(first()).toPromise(); + const { state, message: invalidLicenseMessage } = license.check( + PLUGIN.id, + PLUGIN.minimumLicenseType + ); + const isValidLicense = state === LICENSE_CHECK_STATE.Valid; + + if (!isValidLicense) { + notifications.toasts.addDanger(invalidLicenseMessage as string); + window.location.hash = '/dev_tools'; + return () => {}; + } + + const { renderApp } = await import('./application'); + const tearDownApp = renderApp(element, { + I18nContext, + http, + uiSettings, + links: getLinks(docLinks), + chrome, + }); + + return () => { + tearDownApp(); + }; + }, + }); + } + + async start(core: CoreStart, plugins: any) {} + + async stop() { + this.languageService.stop(); + } +} diff --git a/x-pack/plugins/painless_lab/public/services/index.ts b/x-pack/plugins/painless_lab/public/services/index.ts new file mode 100644 index 0000000000000..20bec9de24550 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/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 { LanguageService } from './language_service'; diff --git a/x-pack/plugins/painless_lab/public/services/language_service.ts b/x-pack/plugins/painless_lab/public/services/language_service.ts new file mode 100644 index 0000000000000..efff9cd0e78d5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/language_service.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +// It is important that we use this specific monaco instance so that +// editor settings are registered against the instance our React component +// uses. +import { monaco } from '@kbn/ui-shared-deps/monaco'; + +// @ts-ignore +import workerSrc from 'raw-loader!monaco-editor/min/vs/base/worker/workerMain.js'; + +import { monacoPainlessLang } from '../lib'; + +const LANGUAGE_ID = 'painless'; + +// Safely check whether these globals are present +const CAN_CREATE_WORKER = typeof Blob === 'function' && typeof Worker === 'function'; + +export class LanguageService { + private originalMonacoEnvironment: any; + + public setup() { + monaco.languages.register({ id: LANGUAGE_ID }); + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monacoPainlessLang); + + if (CAN_CREATE_WORKER) { + this.originalMonacoEnvironment = (window as any).MonacoEnvironment; + (window as any).MonacoEnvironment = { + getWorker: () => { + const blob = new Blob([workerSrc], { type: 'application/javascript' }); + return new Worker(window.URL.createObjectURL(blob)); + }, + }; + } + } + + public stop() { + if (CAN_CREATE_WORKER) { + (window as any).MonacoEnvironment = this.originalMonacoEnvironment; + } + } +} diff --git a/x-pack/plugins/painless_lab/public/styles/_index.scss b/x-pack/plugins/painless_lab/public/styles/_index.scss new file mode 100644 index 0000000000000..f68dbe302511a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/styles/_index.scss @@ -0,0 +1,58 @@ +@import '@elastic/eui/src/components/header/variables'; +@import '@elastic/eui/src/components/nav_drawer/variables'; + +/** + * This is a very brittle way of preventing the editor and other content from disappearing + * behind the bottom bar. + */ +$bottomBarHeight: calc(#{$euiSize} * 3); + +.painlessLabBottomBarPlaceholder { + height: $bottomBarHeight +} + +.painlessLabRightPane { + border-right: none; + border-top: none; + border-bottom: none; + border-radius: 0; + padding-top: 0; + height: 100%; +} + +.painlessLabRightPane__tabs { + display: flex; + flex-direction: column; + height: 100%; + + [role="tabpanel"] { + height: 100%; + overflow-y: auto; + } +} + +.painlessLab__betaLabelContainer { + line-height: 0; +} + +.painlessLabMainContainer { + height: calc(100vh - calc(#{$euiHeaderChildSize} * 2) - #{$bottomBarHeight}); +} + +.painlessLabPanelsContainer { + // The panels container should adopt the height of the main container + height: 100%; +} + +/** + * 1. Hack EUI so the bottom bar doesn't obscure the nav drawer flyout, but is also not obscured + * by the main content area. + */ +.painlessLab__bottomBar { + z-index: 5; /* 1 */ + left: $euiNavDrawerWidthCollapsed; +} + +.painlessLab__bottomBar-isNavDrawerLocked { + left: $euiNavDrawerWidthExpanded; +} diff --git a/x-pack/plugins/painless_lab/public/types.ts b/x-pack/plugins/painless_lab/public/types.ts new file mode 100644 index 0000000000000..9153f4c28de8d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { DevToolsSetup } from '../../../../src/plugins/dev_tools/public'; +import { LicensingPluginSetup } from '../../licensing/public'; + +export interface PluginDependencies { + licensing: LicensingPluginSetup; + home: HomePublicPluginSetup; + devTools: DevToolsSetup; +} diff --git a/x-pack/plugins/painless_lab/server/index.ts b/x-pack/plugins/painless_lab/server/index.ts new file mode 100644 index 0000000000000..96ea9a163deca --- /dev/null +++ b/x-pack/plugins/painless_lab/server/index.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. + */ +import { PluginInitializerContext } from 'kibana/server'; +import { PainlessLabServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => { + return new PainlessLabServerPlugin(ctx); +}; diff --git a/x-pack/plugins/painless_lab/server/lib/index.ts b/x-pack/plugins/painless_lab/server/lib/index.ts new file mode 100644 index 0000000000000..a9a3c61472d8c --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/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 { isEsError } from './is_es_error'; diff --git a/x-pack/plugins/painless_lab/server/lib/is_es_error.ts b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts new file mode 100644 index 0000000000000..4137293cf39c0 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/is_es_error.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 * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/plugins/painless_lab/server/plugin.ts b/x-pack/plugins/painless_lab/server/plugin.ts new file mode 100644 index 0000000000000..74629a0b035ed --- /dev/null +++ b/x-pack/plugins/painless_lab/server/plugin.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; + +import { PLUGIN } from '../common/constants'; +import { License } from './services'; +import { Dependencies } from './types'; +import { registerExecuteRoute } from './routes/api'; + +export class PainlessLabServerPlugin implements Plugin { + private readonly license: License; + private readonly logger: Logger; + + constructor({ logger }: PluginInitializerContext) { + this.logger = logger.get(); + this.license = new License(); + } + + async setup({ http }: CoreSetup, { licensing }: Dependencies) { + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN.id, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.painlessLab.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + registerExecuteRoute({ router, license: this.license }); + } + + start() {} + + stop() {} +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/execute.ts b/x-pack/plugins/painless_lab/server/routes/api/execute.ts new file mode 100644 index 0000000000000..55adb5e0410cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/execute.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 { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; +import { isEsError } from '../../lib'; + +const bodySchema = schema.string(); + +export function registerExecuteRoute({ router, license }: RouteDependencies) { + router.post( + { + path: `${API_BASE_PATH}/execute`, + validate: { + body: bodySchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const body = req.body; + + try { + const callAsCurrentUser = ctx.core.elasticsearch.dataClient.callAsCurrentUser; + const response = await callAsCurrentUser('scriptsPainlessExecute', { + body, + }); + + return res.ok({ + body: response, + }); + } catch (e) { + if (isEsError(e)) { + // Assume invalid painless script was submitted + // Return 200 with error object + return res.ok({ + body: e.body, + }); + } + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/index.ts b/x-pack/plugins/painless_lab/server/routes/api/index.ts new file mode 100644 index 0000000000000..62f05971d59cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/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 { registerExecuteRoute } from './execute'; diff --git a/x-pack/plugins/painless_lab/server/services/index.ts b/x-pack/plugins/painless_lab/server/services/index.ts new file mode 100644 index 0000000000000..b7a45e59549eb --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/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 { License } from './license'; diff --git a/x-pack/plugins/painless_lab/server/services/license.ts b/x-pack/plugins/painless_lab/server/services/license.ts new file mode 100644 index 0000000000000..1c9d77198f928 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/license.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { LicenseType, LICENSE_CHECK_STATE } from '../../../licensing/common/types'; + +export interface LicenseStatus { + isValid: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true }; + } else { + this.licenseStatus = { + isValid: false, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/painless_lab/server/types.ts b/x-pack/plugins/painless_lab/server/types.ts new file mode 100644 index 0000000000000..541a31dd175ec --- /dev/null +++ b/x-pack/plugins/painless_lab/server/types.ts @@ -0,0 +1,17 @@ +/* + * 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 { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; + +export interface RouteDependencies { + router: IRouter; + license: License; +} + +export interface Dependencies { + licensing: LicensingPluginSetup; +}