[Search] Introduced Notebooks view for console (#180400)

## Summary

This PR adds the search-notebooks plugin and a python notebook renderer
to the persistent console.

### Screenshots
Console Closed
<img width="1418" alt="image"
src="8e2e2934-a19f-4204-8a31-1e8eab7fd20f">
Notebooks:
<img width="1418" alt="image"
src="bf9d40ad-352d-482e-8d84-f426c3026c69">
<img width="1418" alt="image"
src="fcf8cac2-4640-49e8-9bce-94a5a853383f">

Console View
<img width="1418" alt="image"
src="9230d1c2-3987-41f8-aa86-77a20509b8c0">

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Rodney Norris 2024-04-15 11:10:28 -05:00 committed by GitHub
parent f447ed09c5
commit 308f514a45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1292 additions and 118 deletions

1
.github/CODEOWNERS vendored
View file

@ -490,6 +490,7 @@ src/plugins/interactive_setup @elastic/kibana-security
test/interactive_setup_api_integration/plugins/test_endpoints @elastic/kibana-security
packages/kbn-interpreter @elastic/kibana-visualizations
packages/kbn-io-ts-utils @elastic/obs-knowledge-team
packages/kbn-ipynb @elastic/enterprise-search-frontend
packages/kbn-jest-serializers @elastic/kibana-operations
packages/kbn-journeys @elastic/kibana-operations @elastic/appex-qa
packages/kbn-json-ast @elastic/kibana-operations

View file

@ -6,6 +6,7 @@ xpack.cloudSecurityPosture.enabled: false
xpack.infra.enabled: true
xpack.uptime.enabled: true
xpack.securitySolution.enabled: false
xpack.search.notebooks.enabled: false
## Enable the slo plugin
xpack.slo.enabled: true
@ -14,7 +15,7 @@ xpack.slo.enabled: true
xpack.cloud.serverless.project_type: observability
## Enable the Serverless Observability plugin
xpack.serverless.observability.enabled: true
xpack.serverless.observability.enabled: true
## Configure plugins

View file

@ -7,6 +7,7 @@ xpack.infra.enabled: false
xpack.observabilityLogsExplorer.enabled: false
xpack.observability.enabled: false
xpack.observabilityAIAssistant.enabled: false
xpack.search.notebooks.enabled: false
## Cloud settings
xpack.cloud.serverless.project_type: security

View file

@ -521,6 +521,7 @@
"@kbn/interactive-setup-test-endpoints-plugin": "link:test/interactive_setup_api_integration/plugins/test_endpoints",
"@kbn/interpreter": "link:packages/kbn-interpreter",
"@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils",
"@kbn/ipynb": "link:packages/kbn-ipynb",
"@kbn/kbn-health-gateway-status-plugin": "link:test/health_gateway/plugins/status",
"@kbn/kbn-sample-panel-action-plugin": "link:test/plugin_functional/plugins/kbn_sample_panel_action",
"@kbn/kbn-top-nav-plugin": "link:test/plugin_functional/plugins/kbn_top_nav",

View file

@ -0,0 +1,3 @@
# @kbn/ipynb
This package has a component to render iPython Notebooks.

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { NotebookDefinition } from '../types';
import { NotebookCell } from './notebook_cell';
import { NotebookCellOutput } from './notebook_cell_output';
export interface NotebookRendererProps {
notebook: NotebookDefinition;
}
export const NotebookRenderer = ({ notebook }: NotebookRendererProps) => {
const { euiTheme } = useEuiTheme();
const language = notebook.metadata?.language_info?.name ?? 'python';
return (
<EuiFlexGroup
direction="column"
gutterSize="xl"
style={{ maxWidth: `${euiTheme.base * 50}px` }}
justifyContent="center"
>
{notebook.cells.map((cell, i) => {
const cellId = cell.id ?? `nb.cell.${i}`;
if (cell.outputs && cell.outputs.length > 0) {
return (
<EuiFlexItem key={cellId}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<NotebookCell cell={cell} language={language} />
</EuiFlexItem>
{cell.outputs.map((output, outputIndex) => (
<EuiFlexItem key={`${cellId}.output.${outputIndex}`}>
<NotebookCellOutput output={output} />
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexItem>
);
}
return (
<EuiFlexItem key={cellId}>
<NotebookCell cell={cell} language={language} />
</EuiFlexItem>
);
})}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiMarkdownFormat, EuiCodeBlock, EuiTitle } from '@elastic/eui';
import { NotebookCellType } from '../types';
import { combineSource } from '../utils';
export const NotebookCell = ({ cell, language }: { cell: NotebookCellType; language: string }) => {
if (!cell.cell_type) return null;
const content = cell.source
? combineSource(cell.source)
: cell.input
? combineSource(cell.input)
: null;
if (!content) return null;
switch (cell.cell_type) {
case 'markdown':
return <EuiMarkdownFormat>{content}</EuiMarkdownFormat>;
case 'code':
return (
<EuiCodeBlock language={language} lineNumbers isCopyable>
{content}
</EuiCodeBlock>
);
case 'heading':
return (
<EuiTitle>
<h2>{content}</h2>
</EuiTitle>
);
}
return null;
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiCodeBlock } from '@elastic/eui';
import { NotebookOutputType } from '../types';
import { isDefined, isDefinedAndHasValue, combineSource } from '../utils';
export const NotebookCellOutput = ({ output }: { output: NotebookOutputType }) => {
if (isDefined(output.text) && output.text.length > 0) {
return <EuiCodeBlock>{combineSource(output.text)}</EuiCodeBlock>;
}
if (isDefinedAndHasValue(output.png)) {
return <OutputPng value={output.png} />;
}
if (isDefinedAndHasValue(output.jpeg)) {
return <OutputJpeg value={output.jpeg} />;
}
if (isDefinedAndHasValue(output.gif)) {
return <OutputGif value={output.gif} />;
}
if (!isDefined(output.data)) return null;
if (isDefined(output.data['text/plain'])) {
return <EuiCodeBlock>{combineSource(output.data['text/plain'])}</EuiCodeBlock>;
}
if (isDefinedAndHasValue(output.data['image/png'])) {
return <OutputPng value={output.data['image/png']} />;
}
if (isDefinedAndHasValue(output.data['image/jpeg'])) {
return <OutputJpeg value={output.data['image/jpeg']} />;
}
if (isDefinedAndHasValue(output.data['image/gif'])) {
return <OutputGif value={output.data['image/gif']} />;
}
if (isDefined(output.data['text/html'])) {
// We just present HTML instead of rendering it for safety
return <EuiCodeBlock lineNumbers>{combineSource(output.data['text/html'])}</EuiCodeBlock>;
}
if (isDefined(output.data['application/javascript'])) {
// We just present JS instead of rendering it for safety
return (
<EuiCodeBlock lineNumbers>
{combineSource(output.data['application/javascript'])}
</EuiCodeBlock>
);
}
if (isDefined(output.data['text/latex'])) {
return <EuiCodeBlock lineNumbers>{combineSource(output.data['text/latex'])}</EuiCodeBlock>;
}
return null;
};
const OutputImage = ({ value, type }: { value: string; type: 'png' | 'jpeg' | 'gif' }) => (
<img
src={`data:image/${type};base64,${value}`}
alt={`output ${type} image`}
style={{ maxHeight: '400px', maxWidth: '400px' }}
/>
);
const OutputPng = ({ value }: { value: string }) => <OutputImage value={value} type="png" />;
const OutputJpeg = ({ value }: { value: string }) => <OutputImage value={value} type="jpeg" />;
const OutputGif = ({ value }: { value: string }) => <OutputImage value={value} type="gif" />;

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './types';
export * from './components';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-ipynb'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/ipynb",
"owner": "@elastic/enterprise-search-frontend"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/ipynb",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": []
}

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface NotebookDefinition {
cells: NotebookCellType[];
metadata?: NotebookMetadataType;
nbformat?: number;
nbformat_minor?: number;
}
export interface NotebookMetadataType {
kernelspec?: {
display_name?: string;
language?: string;
name?: string;
};
language_info?: {
mimetype?: string;
name?: string;
version?: string;
};
}
export interface NotebookCellType {
auto_number?: number;
cell_type?: string;
execution_count?: number | null;
id?: string;
input?: string[];
metadata?: {
id?: string;
};
outputs?: NotebookOutputType[];
prompt_number?: number;
source?: string[];
}
export interface NotebookOutputType {
name?: string;
ename?: string;
evalue?: string;
traceback?: string[];
data?: NotebookOutputData;
output_type?: string;
png?: string;
jpeg?: string;
gif?: string;
svg?: string;
text?: string[];
execution_count?: number;
}
export interface NotebookOutputData {
'text/plain'?: string[];
'text/html'?: string[];
'text/latex'?: string[];
'image/png'?: string;
'image/jpeg'?: string;
'image/gif'?: string;
'image/svg+xml'?: string;
'application/javascript'?: string[];
}

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isDefined, combineSource } from './utils';
describe('isDefined', () => {
it('returns true if value is defined', () => {
expect(isDefined({ foo: 'bar' })).toEqual(true);
});
it('returns false if value is null', () => {
expect(isDefined(null)).toEqual(false);
});
it('returns false if value is undefined', () => {
expect(isDefined(undefined)).toEqual(false);
});
});
describe('combineSource', () => {
it('returns value when given a string', () => {
expect(combineSource('foobar')).toEqual('foobar');
});
it('returns combined string from array', () => {
expect(combineSource(['foo', 'bar', 'baz'])).toEqual('foobarbaz');
});
it('returns combined string from array with separator', () => {
expect(combineSource(['foo', 'bar', 'baz'], '\n')).toEqual('foo\nbar\nbaz');
});
});

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export function isDefined<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}
export function isDefinedAndHasValue(value: string | undefined): value is string {
return isDefined(value) && value.length > 0;
}
export const combineSource = (value: string | string[], separator: string = ''): string => {
if (Array.isArray(value)) return value.join(separator);
return value;
};

View file

@ -127,6 +127,7 @@ pageLoadAssetSize:
screenshotMode: 17856
screenshotting: 22870
searchConnectors: 30000
searchNotebooks: 18942
searchPlayground: 19325
searchprofiler: 67080
security: 81771

View file

@ -64,11 +64,23 @@
width: 100%;
display: flex;
justify-content: flex-start;
align-items: center;
overflow-y: hidden; // Ensures the movement of buttons in :focus don't cause scrollbars
overflow-x: auto;
padding-right: $euiSizeS;
&--button {
justify-content: flex-start;
flex-grow: 1;
width: 100%;
.euiButtonEmpty__content {
justify-content: flex-start;
}
}
&--altViewButton-container {
margin-left: auto;
// padding: $euiSizeS;
}
}

View file

@ -106,7 +106,8 @@ const loadDependencies = async (
};
};
interface ConsoleWrapperProps extends Omit<EmbeddableConsoleDependencies, 'setDispatch'> {
interface ConsoleWrapperProps
extends Omit<EmbeddableConsoleDependencies, 'setDispatch' | 'alternateView'> {
onKeyDown: (this: Window, ev: WindowEventMap['keydown']) => any;
}

View file

@ -10,11 +10,12 @@ import React, { useReducer, useEffect } from 'react';
import classNames from 'classnames';
import useObservable from 'react-use/lib/useObservable';
import {
EuiButton,
EuiButtonEmpty,
EuiFocusTrap,
EuiPortal,
EuiScreenReaderOnly,
EuiThemeProvider,
EuiWindowEvent,
keys,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -23,6 +24,7 @@ import { dynamic } from '@kbn/shared-ux-utility';
import {
EmbeddableConsoleProps,
EmbeddableConsoleDependencies,
EmbeddableConsoleView,
} from '../../../types/embeddable_console';
import * as store from '../../stores/embeddable_console';
@ -45,6 +47,7 @@ export const EmbeddableConsole = ({
core,
usageCollection,
setDispatch,
alternateView,
}: EmbeddableConsoleProps & EmbeddableConsoleDependencies) => {
const [consoleState, consoleDispatch] = useReducer(
store.reducer,
@ -57,22 +60,39 @@ export const EmbeddableConsole = ({
return () => setDispatch(null);
}, [setDispatch, consoleDispatch]);
useEffect(() => {
if (consoleState.isOpen && consoleState.loadFromContent) {
if (consoleState.view === EmbeddableConsoleView.Console && consoleState.loadFromContent) {
setLoadFromParameter(consoleState.loadFromContent);
} else if (!consoleState.isOpen) {
} else if (consoleState.view === EmbeddableConsoleView.Closed) {
removeLoadFromParameter();
}
}, [consoleState.isOpen, consoleState.loadFromContent]);
}, [consoleState.view, consoleState.loadFromContent]);
useEffect(() => {
document.body.classList.add(KBN_BODY_CONSOLE_CLASS);
return () => document.body.classList.remove(KBN_BODY_CONSOLE_CLASS);
}, []);
const isConsoleOpen = consoleState.isOpen;
const isOpen = consoleState.view !== EmbeddableConsoleView.Closed;
const showConsole =
consoleState.view !== EmbeddableConsoleView.Closed &&
(consoleState.view === EmbeddableConsoleView.Console || alternateView === undefined);
const showAlternateView =
consoleState.view === EmbeddableConsoleView.Alternate && alternateView !== undefined;
const setIsConsoleOpen = (value: boolean) => {
consoleDispatch(value ? { type: 'open' } : { type: 'close' });
};
const toggleConsole = () => setIsConsoleOpen(!isConsoleOpen);
const toggleConsole = () => setIsConsoleOpen(!isOpen);
const clickAlternateViewActivateButton: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
switch (consoleState.view) {
case EmbeddableConsoleView.Console:
case EmbeddableConsoleView.Closed:
consoleDispatch({ type: 'open', payload: { alternateView: true } });
break;
case EmbeddableConsoleView.Alternate:
consoleDispatch({ type: 'open', payload: { alternateView: false } });
break;
}
};
const onKeyDown = (event: any) => {
if (event.key === keys.ESCAPE) {
@ -83,7 +103,7 @@ export const EmbeddableConsole = ({
};
const classes = classNames('embeddableConsole', {
'embeddableConsole-isOpen': isConsoleOpen,
'embeddableConsole-isOpen': isOpen,
'embeddableConsole--large': size === 'l',
'embeddableConsole--medium': size === 'm',
'embeddableConsole--small': size === 's',
@ -96,7 +116,7 @@ export const EmbeddableConsole = ({
return (
<EuiPortal>
<EuiFocusTrap onClickOutside={toggleConsole} disabled={!isConsoleOpen}>
<EuiFocusTrap onClickOutside={toggleConsole} disabled={!isOpen}>
<section
aria-label={landmarkHeading}
className={classes}
@ -107,24 +127,35 @@ export const EmbeddableConsole = ({
</EuiScreenReaderOnly>
<EuiThemeProvider colorMode={'dark'} wrapperProps={{ cloneElement: true }}>
<div className="embeddableConsole__controls">
<EuiButton
<EuiButtonEmpty
color="text"
iconType={isConsoleOpen ? 'arrowUp' : 'arrowDown'}
iconType={isOpen ? 'arrowUp' : 'arrowDown'}
onClick={toggleConsole}
fullWidth
contentProps={{
className: 'embeddableConsole__controls--button',
}}
className="embeddableConsole__controls--button"
data-test-subj="consoleEmbeddedControlBar"
data-telemetry-id="console-embedded-controlbar-button"
>
{i18n.translate('console.embeddableConsole.title', {
defaultMessage: 'Console',
})}
</EuiButton>
</EuiButtonEmpty>
{alternateView && (
<div className="embeddableConsole__controls--altViewButton-container">
<alternateView.ActivationButton
activeView={showAlternateView}
onClick={clickAlternateViewActivateButton}
/>
</div>
)}
</div>
</EuiThemeProvider>
{isConsoleOpen ? <ConsoleWrapper {...{ core, usageCollection, onKeyDown }} /> : null}
{showConsole ? <ConsoleWrapper {...{ core, usageCollection, onKeyDown }} /> : null}
{showAlternateView ? (
<div className="embeddableConsole__content" data-test-subj="consoleEmbeddedBody">
<EuiWindowEvent event="keydown" handler={onKeyDown} />
<alternateView.ViewContent />
</div>
) : null}
</section>
<EuiScreenReaderOnly>
<p aria-live="assertive">

View file

@ -10,11 +10,15 @@ import { Reducer } from 'react';
import { produce } from 'immer';
import { identity } from 'fp-ts/lib/function';
import { EmbeddedConsoleAction, EmbeddedConsoleStore } from '../../types/embeddable_console';
import {
EmbeddableConsoleView,
EmbeddedConsoleAction,
EmbeddedConsoleStore,
} from '../../types/embeddable_console';
export const initialValue: EmbeddedConsoleStore = produce<EmbeddedConsoleStore>(
{
isOpen: false,
view: EmbeddableConsoleView.Closed,
},
identity
);
@ -23,19 +27,22 @@ export const reducer: Reducer<EmbeddedConsoleStore, EmbeddedConsoleAction> = (st
produce<EmbeddedConsoleStore>(state, (draft) => {
switch (action.type) {
case 'open':
if (!state.isOpen) {
draft.isOpen = true;
const newView = action.payload?.alternateView
? EmbeddableConsoleView.Alternate
: EmbeddableConsoleView.Console;
if (state.view !== newView) {
draft.view = newView;
draft.loadFromContent = action.payload?.content;
return;
return draft;
}
break;
case 'close':
if (state.isOpen) {
draft.isOpen = false;
if (state.view !== EmbeddableConsoleView.Closed) {
draft.view = EmbeddableConsoleView.Closed;
draft.loadFromContent = undefined;
return;
return draft;
}
break;
}
return draft;
return state;
});

View file

@ -17,7 +17,9 @@ export type {
ConsoleUILocatorParams,
ConsolePluginSetup,
ConsolePluginStart,
EmbeddableConsoleProps as RemoteConsoleProps,
EmbeddableConsoleProps,
EmbeddedConsoleView,
EmbeddedConsoleViewButtonProps,
} from './types';
export { ConsoleUIPlugin as Plugin };

View file

@ -19,6 +19,7 @@ import {
ConsolePluginStart,
ConsoleUILocatorParams,
EmbeddableConsoleProps,
EmbeddedConsoleView,
} from './types';
import { AutocompleteInfo, setAutocompleteInfo, EmbeddableConsoleInfo } from './services';
@ -132,12 +133,16 @@ export class ConsoleUIPlugin
setDispatch: (d) => {
this._embeddableConsole.setDispatch(d);
},
alternateView: this._embeddableConsole.alternateView,
});
};
consoleStart.isEmbeddedConsoleAvailable = () =>
this._embeddableConsole.isEmbeddedConsoleAvailable();
consoleStart.openEmbeddedConsole = (content?: string) =>
this._embeddableConsole.openEmbeddedConsole(content);
consoleStart.registerEmbeddedConsoleAlternateView = (view: EmbeddedConsoleView | null) => {
this._embeddableConsole.registerAlternateView(view);
};
}
return consoleStart;

View file

@ -7,10 +7,18 @@
*/
import type { Dispatch } from 'react';
import { EmbeddedConsoleAction as EmbeddableConsoleAction } from '../types/embeddable_console';
import {
EmbeddedConsoleAction as EmbeddableConsoleAction,
EmbeddedConsoleView,
} from '../types/embeddable_console';
export class EmbeddableConsoleInfo {
private _dispatch: Dispatch<EmbeddableConsoleAction> | null = null;
private _alternateView: EmbeddedConsoleView | undefined;
public get alternateView(): EmbeddedConsoleView | undefined {
return this._alternateView;
}
public setDispatch(d: Dispatch<EmbeddableConsoleAction> | null) {
this._dispatch = d;
@ -26,4 +34,8 @@ export class EmbeddableConsoleInfo {
this._dispatch({ type: 'open', payload: content ? { content } : undefined });
}
public registerAlternateView(view: EmbeddedConsoleView | null) {
this._alternateView = view ?? undefined;
}
}

View file

@ -5,6 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ComponentType, MouseEventHandler } from 'react';
import type { CoreStart } from '@kbn/core/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { Dispatch } from 'react';
@ -23,13 +24,29 @@ export interface EmbeddableConsoleDependencies {
core: CoreStart;
usageCollection?: UsageCollectionStart;
setDispatch: (dispatch: Dispatch<EmbeddedConsoleAction> | null) => void;
alternateView?: EmbeddedConsoleView;
}
export type EmbeddedConsoleAction =
| { type: 'open'; payload?: { content?: string } }
| { type: 'open'; payload?: { content?: string; alternateView?: boolean } }
| { type: 'close' };
export enum EmbeddableConsoleView {
Closed,
Console,
Alternate,
}
export interface EmbeddedConsoleStore {
isOpen: boolean;
view: EmbeddableConsoleView;
loadFromContent?: string;
}
export interface EmbeddedConsoleViewButtonProps {
activeView: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
}
export interface EmbeddedConsoleView {
ActivationButton: ComponentType<EmbeddedConsoleViewButtonProps>;
ViewContent: ComponentType<{}>;
}

View file

@ -13,7 +13,7 @@ import { UsageCollectionSetup, UsageCollectionStart } from '@kbn/usage-collectio
import { SharePluginSetup, SharePluginStart, LocatorPublic } from '@kbn/share-plugin/public';
import { ConsoleUILocatorParams } from './locator';
import { EmbeddableConsoleProps } from './embeddable_console';
import { EmbeddableConsoleProps, EmbeddedConsoleView } from './embeddable_console';
export interface AppSetupUIPluginDependencies {
home?: HomePublicPluginSetup;
@ -56,4 +56,10 @@ export interface ConsolePluginStart {
* EmbeddableConsole is a functional component used to render a portable version of the dev tools console on any page in Kibana
*/
EmbeddableConsole?: FC<EmbeddableConsoleProps>;
/**
* Register an alternate view for the Embedded Console
*
* When registering an alternate view ensure that the content component you register is lazy loaded.
*/
registerEmbeddedConsoleAlternateView?: (view: EmbeddedConsoleView | null) => void;
}

View file

@ -974,6 +974,8 @@
"@kbn/interpreter/*": ["packages/kbn-interpreter/*"],
"@kbn/io-ts-utils": ["packages/kbn-io-ts-utils"],
"@kbn/io-ts-utils/*": ["packages/kbn-io-ts-utils/*"],
"@kbn/ipynb": ["packages/kbn-ipynb"],
"@kbn/ipynb/*": ["packages/kbn-ipynb/*"],
"@kbn/jest-serializers": ["packages/kbn-jest-serializers"],
"@kbn/jest-serializers/*": ["packages/kbn-jest-serializers/*"],
"@kbn/journeys": ["packages/kbn-journeys"],

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { Notebook } from './types';
export const INTRODUCTION_NOTEBOOK: Notebook = {
id: 'introduction',
title: i18n.translate('xpack.searchNotebooks.introductionNotebook.title', {
defaultMessage: 'What are Jupyter Notebooks?',
}),
description: i18n.translate('xpack.searchNotebooks.introductionNotebook.description', {
defaultMessage:
'Jupyter Notebooks are an open-source document format for sharing interactive code embedded in narrative text.',
}),
notebook: {
cells: [
{
cell_type: 'markdown',
source: [
'# What are Jupyter Notebooks\n',
'\n',
'Jupyter Notebooks combine executable code and rich Markdown documentation in a single interactive document. Easy to run, edit and share, they enable collaboration in fields like data science, scientific computing, and machine learning.',
],
},
{
cell_type: 'code',
source: ['print("Hello world!!!")'],
},
],
},
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { NotebookDefinition } from '@kbn/ipynb';
export interface NotebookInformation {
id: string;
title: string;
description: string;
}
export interface NotebookCatalog {
notebooks: NotebookInformation[];
}
export interface Notebook extends NotebookInformation {
link?: {
title: string;
url: string;
};
notebook: NotebookDefinition;
}

View file

@ -6,14 +6,16 @@
"plugin": {
"id": "searchNotebooks",
"server": true,
"browser": false,
"browser": true,
"configPath": [
"xpack",
"search",
"notebooks"
],
"requiredPlugins": [],
"requiredPlugins": [
"console"
],
"optionalPlugins": [],
"requiredBundles": []
"requiredBundles": ["kibanaReact"]
}
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiPanel, EuiLoadingSpinner } from '@elastic/eui';
export const LoadingPanel = () => (
<EuiPanel color="subdued">
<EuiLoadingSpinner />
</EuiPanel>
);

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { EmbeddedConsoleViewButtonProps } from '@kbn/console-plugin/public';
export const SearchNotebooksButton = ({ activeView, onClick }: EmbeddedConsoleViewButtonProps) => {
if (activeView) {
return (
<EuiButton
color="success"
fill
onClick={onClick}
size="s"
iconType="documentation"
iconSide="left"
data-test-subj="consoleEmbeddedNotebooksButton"
data-telemetry-id="console-embedded-notebooks-button"
>
{i18n.translate('xpack.searchNotebooks.notebooksButton.title', {
defaultMessage: 'Notebooks',
})}
</EuiButton>
);
}
return (
<EuiButtonEmpty
color="success"
onClick={onClick}
size="s"
iconType="documentation"
iconSide="left"
data-test-subj="consoleEmbeddedNotebooksButton"
data-telemetry-id="console-embedded-notebooks-button"
>
{i18n.translate('xpack.searchNotebooks.notebooksButton.title', {
defaultMessage: 'Notebooks',
})}
</EuiButtonEmpty>
);
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {} from '@elastic/eui';
import { NotebookInformation } from '../../common/types';
import { LoadingPanel } from './loading_panel';
import { SelectionPanel } from './selection_panel';
export interface NotebooksListProps {
notebooks: NotebookInformation[] | null;
onNotebookSelect: (id: string) => void;
selectedNotebookId: string;
}
export const NotebooksList = ({
notebooks,
onNotebookSelect,
selectedNotebookId,
}: NotebooksListProps) => {
if (notebooks === null) {
// Loading Notebooks
return <LoadingPanel />;
}
return (
<>
{notebooks.map((notebook) => (
<SelectionPanel
key={notebook.id}
id={notebook.id}
title={notebook.title}
description={notebook.description}
onClick={onNotebookSelect}
isSelected={selectedNotebookId === notebook.id}
/>
))}
</>
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SearchNotebooks } from './search_notebooks';
export interface SearchNotebooksViewProps {
core: CoreStart;
queryClient: QueryClient;
}
export const SearchNotebooksView = ({ core, queryClient }: SearchNotebooksViewProps) => (
<KibanaThemeProvider theme={core.theme}>
<KibanaContextProvider services={{ ...core }}>
<QueryClientProvider client={queryClient}>
<SearchNotebooks />
</QueryClientProvider>
</KibanaContextProvider>
</KibanaThemeProvider>
);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiPanel, EuiButton, EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const SearchLabsButtonPanel = () => {
return (
<EuiPanel hasShadow={false}>
<EuiFlexGroup justifyContent="center">
<EuiButton
href="https://github.com/elastic/elasticsearch-labs"
target="_blank"
iconSide="right"
iconType="popout"
data-test-subj="console-notebooks-search-labs-btn"
data-telemetry-id="console-notebooks-search-labs-btn"
>
{i18n.translate('xpack.searchNotebooks.searchLabsLink', {
defaultMessage: 'See more at Elastic Search Labs',
})}
</EuiButton>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiPanel, EuiEmptyPrompt, EuiCodeBlock } from '@elastic/eui';
import { NotebookRenderer } from '@kbn/ipynb';
import { FormattedMessage } from '@kbn/i18n-react';
import { useNotebook } from '../hooks/use_notebook';
import { LoadingPanel } from './loading_panel';
export interface SearchNotebookProps {
notebookId: string;
}
export const SearchNotebook = ({ notebookId }: SearchNotebookProps) => {
const { data, isLoading, error } = useNotebook(notebookId);
if (isLoading) {
return <LoadingPanel />;
}
if (!data || error) {
return (
<EuiEmptyPrompt
iconType="warning"
iconColor="danger"
title={
<h2>
<FormattedMessage
id="xpack.searchNotebooks.notebook.fetchError.title"
defaultMessage="Error loading notebook"
/>
</h2>
}
titleSize="l"
body={
<>
<p>
<FormattedMessage
id="xpack.searchNotebooks.notebook.fetchError.body"
defaultMessage="We can't fetch the notebook from Kibana due to the following error:"
/>
</p>
<EuiCodeBlock css={{ textAlign: 'left' }}>{JSON.stringify(error)}</EuiCodeBlock>
</>
}
/>
);
}
return (
<EuiPanel
paddingSize="xl"
hasShadow={false}
style={{ display: 'flex', justifyContent: 'center' }}
data-test-subj={`console-embedded-notebook-view-panel-${notebookId}`}
>
<NotebookRenderer notebook={data.notebook} />
</EuiPanel>
);
};

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiResizableContainer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { INTRODUCTION_NOTEBOOK } from '../../common/constants';
import { useNotebooksCatalog } from '../hooks/use_notebook_catalog';
import { NotebooksList } from './notebooks_list';
import { SelectionPanel } from './selection_panel';
import { TitlePanel } from './title_panel';
import { SearchNotebook } from './search_notebook';
import { SearchLabsButtonPanel } from './search_labs_button_panel';
const LIST_PANEL_ID = 'notebooksList';
const OUTPUT_PANEL_ID = 'notebooksOutput';
const defaultSizes: Record<string, number> = {
[LIST_PANEL_ID]: 25,
[OUTPUT_PANEL_ID]: 75,
};
export const SearchNotebooks = () => {
const [sizes, setSizes] = useState(defaultSizes);
const [selectedNotebookId, setSelectedNotebookId] = useState<string>('introduction');
const { data } = useNotebooksCatalog();
const onPanelWidthChange = useCallback((newSizes: Record<string, number>) => {
setSizes((prevSizes: Record<string, number>) => ({
...prevSizes,
...newSizes,
}));
}, []);
const notebooks = useMemo(() => {
if (data) return data.notebooks;
return null;
}, [data]);
const onNotebookSelectionClick = useCallback((id: string) => {
setSelectedNotebookId(id);
}, []);
return (
<EuiResizableContainer
style={{ height: '100%', width: '100%' }}
onPanelWidthChange={onPanelWidthChange}
data-test-subj="consoleEmbeddedNotebooksContainer"
>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
id={LIST_PANEL_ID}
size={sizes[LIST_PANEL_ID]}
minSize="10%"
tabIndex={0}
paddingSize="none"
>
<EuiFlexGroup direction="column" gutterSize="none">
<TitlePanel>
{i18n.translate('xpack.searchNotebooks.notebooksList.introduction.title', {
defaultMessage: 'Introduction',
})}
</TitlePanel>
<SelectionPanel
id={INTRODUCTION_NOTEBOOK.id}
title={INTRODUCTION_NOTEBOOK.title}
description={INTRODUCTION_NOTEBOOK.description}
onClick={onNotebookSelectionClick}
isSelected={selectedNotebookId === INTRODUCTION_NOTEBOOK.id}
/>
<TitlePanel>
{i18n.translate('xpack.searchNotebooks.notebooksList.availableNotebooks.title', {
defaultMessage: 'Available Notebooks',
})}
</TitlePanel>
<NotebooksList
notebooks={notebooks}
selectedNotebookId={selectedNotebookId}
onNotebookSelect={onNotebookSelectionClick}
/>
<SearchLabsButtonPanel />
</EuiFlexGroup>
</EuiResizablePanel>
<EuiResizableButton />
<EuiResizablePanel
id={OUTPUT_PANEL_ID}
size={sizes[OUTPUT_PANEL_ID]}
minSize="200px"
tabIndex={0}
paddingSize="none"
>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<TitlePanel>
{i18n.translate('xpack.searchNotebooks.notebooksList.activeNotebook.title', {
defaultMessage: 'Active notebook',
})}
</TitlePanel>
</EuiFlexItem>
<EuiFlexItem>
<SearchNotebook notebookId={selectedNotebookId} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
);
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiHorizontalRule,
EuiPanel,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
useEuiTheme,
} from '@elastic/eui';
export interface SelectionPanelProps {
description: string;
id: string;
isSelected: boolean;
onClick: (id: string) => void;
title: string;
}
export const SelectionPanel = ({
description,
id,
isSelected,
title,
onClick,
}: SelectionPanelProps) => {
const { euiTheme } = useEuiTheme();
return (
<>
<EuiPanel
data-test-subj={`console-embedded-notebook-select-btn-${id}`}
data-telemdata-telemetry-id={`console-embedded-notebook-select-btn-${id}`}
onClick={() => onClick(id)}
color={isSelected ? 'primary' : 'subdued'}
hasBorder
>
<EuiTitle size="xxs">
<h5>
<EuiTextColor color={euiTheme.colors.primaryText}>{title}</EuiTextColor>
</h5>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiText size="s" color={isSelected ? euiTheme.colors.primaryText : 'subdued'}>
<p>{description}</p>
</EuiText>
</EuiPanel>
<EuiHorizontalRule margin="none" />
</>
);
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiHorizontalRule, EuiPanel, EuiText } from '@elastic/eui';
export const TitlePanel: React.FC = ({ children }) => (
<>
<EuiPanel hasShadow={false} paddingSize="s">
<EuiText size="s" color="subdued">
{children}
</EuiText>
</EuiPanel>
<EuiHorizontalRule margin="none" />
</>
);

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { EmbeddedConsoleView } from '@kbn/console-plugin/public';
import { dynamic } from '@kbn/shared-ux-utility';
import { QueryClient } from '@tanstack/react-query';
import { SearchNotebooksButton } from './components/notebooks_button';
const SearchNotebooksView = dynamic(async () => ({
default: (await import('./components/notebooks_view')).SearchNotebooksView,
}));
export const notebooksConsoleView = (
core: CoreStart,
queryClient: QueryClient
): EmbeddedConsoleView => {
return {
ActivationButton: SearchNotebooksButton,
ViewContent: () => <SearchNotebooksView core={core} queryClient={queryClient} />,
};
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConsolePluginStart } from '@kbn/console-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { useKibana as useKibanaBase } from '@kbn/kibana-react-plugin/public';
export interface SearchNotebooksContext {
console: ConsolePluginStart;
}
type ServerlessSearchKibanaContext = CoreStart & SearchNotebooksContext;
export const useKibanaServices = () => useKibanaBase<ServerlessSearchKibanaContext>().services;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { Notebook } from '../../common/types';
import { useKibanaServices } from './use_kibana';
export const useNotebook = (id: string) => {
const { http } = useKibanaServices();
return useQuery({
queryKey: ['fetchSearchNotebook', id],
queryFn: () => http.get<Notebook>(`/internal/search_notebooks/notebooks/${id}`),
});
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { NotebookCatalog } from '../../common/types';
import { useKibanaServices } from './use_kibana';
export const useNotebooksCatalog = () => {
const { http } = useKibanaServices();
return useQuery({
queryKey: ['fetchNotebooksCatalog'],
queryFn: () => http.get<NotebookCatalog>('/internal/search_notebooks/notebooks'),
});
};

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SearchNotebooksPlugin } from './plugin';
export function plugin() {
return new SearchNotebooksPlugin();
}
export type { SearchNotebooksPluginSetup, SearchNotebooksPluginStart } from './types';

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreSetup, Plugin, CoreStart } from '@kbn/core/public';
import { QueryClient, MutationCache, QueryCache } from '@tanstack/react-query';
import { notebooksConsoleView } from './console_view';
import {
SearchNotebooksPluginSetup,
SearchNotebooksPluginStart,
SearchNotebooksPluginStartDependencies,
} from './types';
import { getErrorCode, getErrorMessage, isKibanaServerError } from './utils/get_error_message';
export class SearchNotebooksPlugin
implements Plugin<SearchNotebooksPluginSetup, SearchNotebooksPluginStart>
{
private queryClient: QueryClient | undefined;
public setup(core: CoreSetup): SearchNotebooksPluginSetup {
this.queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error) => {
core.notifications.toasts.addError(error as Error, {
title: (error as Error).name,
toastMessage: getErrorMessage(error),
toastLifeTimeMs: 1000,
});
},
}),
queryCache: new QueryCache({
onError: (error) => {
// 404s are often functionally okay and shouldn't show toasts by default
if (getErrorCode(error) === 404) {
return;
}
if (isKibanaServerError(error) && !error.skipToast) {
core.notifications.toasts.addError(error, {
title: error.name,
toastMessage: getErrorMessage(error),
toastLifeTimeMs: 1000,
});
}
},
}),
});
return {};
}
public start(
core: CoreStart,
deps: SearchNotebooksPluginStartDependencies
): SearchNotebooksPluginStart {
if (deps.console?.registerEmbeddedConsoleAlternateView) {
deps.console.registerEmbeddedConsoleAlternateView(
notebooksConsoleView(core, this.queryClient!)
);
}
return {};
}
public stop() {}
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConsolePluginStart } from '@kbn/console-plugin/public';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchNotebooksPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchNotebooksPluginStart {}
export interface SearchNotebooksPluginStartDependencies {
console: ConsolePluginStart;
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (isKibanaServerError(error)) {
return error.body.message;
}
if (typeof error === 'object' && (error as { name: string }).name) {
return (error as { name: string }).name;
}
return '';
}
export function getErrorCode(error: unknown): number | undefined {
if (isKibanaServerError(error)) {
return error.body.statusCode;
}
return undefined;
}
export function isKibanaServerError(
input: unknown
): input is Error & { body: KibanaServerError; name: string; skipToast?: boolean } {
if (
typeof input === 'object' &&
(input as { body: KibanaServerError }).body &&
typeof (input as { body: KibanaServerError }).body.message === 'string'
) {
return true;
}
return false;
}

View file

@ -11,7 +11,7 @@ import { PluginConfigDescriptor } from '@kbn/core/server';
export * from './types';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
enabled: schema.boolean({ defaultValue: true }),
});
type SearchNotebooksSchema = TypeOf<typeof configSchema>;

View file

@ -9,8 +9,9 @@ import fs from 'fs/promises';
import path from 'path';
import { i18n } from '@kbn/i18n';
import type { Logger } from '@kbn/logging';
import { NotebookDefinition } from '@kbn/ipynb';
import { NotebookCatalog, NotebookInformation, NotebookDefinition } from '../types';
import { NotebookCatalog, NotebookInformation } from '../../common/types';
const NOTEBOOKS_DATA_DIR = '../data';

View file

@ -8,9 +8,10 @@
import { schema } from '@kbn/config-schema';
import type { IRouter } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { NotebookDefinition } from '@kbn/ipynb';
import { INTRODUCTION_NOTEBOOK } from '../../common/constants';
import { DEFAULT_NOTEBOOKS, NOTEBOOKS_MAP, getNotebook } from '../lib/notebook_catalog';
import { NotebookDefinition } from '../types';
export function defineRoutes(router: IRouter, logger: Logger) {
router.get(
@ -35,9 +36,16 @@ export function defineRoutes(router: IRouter, logger: Logger) {
}),
},
},
async (context, request, response) => {
async (_, request, response) => {
const notebookId = request.params.notebookId;
if (notebookId === INTRODUCTION_NOTEBOOK.id) {
return response.ok({
body: INTRODUCTION_NOTEBOOK,
headers: { 'content-type': 'application/json' },
});
}
if (!NOTEBOOKS_MAP.hasOwnProperty(notebookId)) {
logger.warn(`Unknown search notebook requested ${notebookId}`);
return response.notFound();

View file

@ -9,81 +9,3 @@
export interface SearchNotebooksPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SearchNotebooksPluginStart {}
export interface NotebookInformation {
id: string;
title: string;
description: string;
}
export interface NotebookCatalog {
notebooks: NotebookInformation[];
}
export interface Notebook extends NotebookInformation {
link?: {
title: string;
url: string;
};
notebook: NotebookDefinition;
}
export interface NotebookDefinition {
cells: NotebookCellType[];
metadata?: NotebookMetadataType;
nbformat?: number;
nbformat_minor?: number;
}
export interface NotebookMetadataType {
kernelspec?: {
display_name?: string;
language?: string;
name?: string;
};
language_info?: {
mimetype?: string;
name?: string;
version?: string;
};
}
export interface NotebookCellType {
auto_number?: number;
cell_type?: string;
execution_count?: number | null;
id?: string;
inputs?: string[];
metadata?: {
id?: string;
};
outputs?: NotebookOutputType[];
prompt_number?: number;
source?: string[];
}
export interface NotebookOutputType {
name?: string;
ename?: string;
evalue?: string;
traceback?: string[];
data?: {
'text/plain'?: string[];
'text/html'?: string[];
'text/latex'?: string[];
'image/png'?: string;
'image/jpeg'?: string;
'image/gif'?: string;
'image/svg+xml'?: string;
'application/javascript'?: string[];
};
output_type?: string;
png?: string;
jpeg?: string;
gif?: string;
svg?: string;
text?: string[];
execution_count?: number;
metadata?: {
scrolled?: boolean;
};
}

View file

@ -19,5 +19,12 @@
"@kbn/core",
"@kbn/i18n",
"@kbn/logging",
"@kbn/console-plugin",
"@kbn/i18n-react",
"@kbn/kibana-react-plugin",
"@kbn/react-kibana-context-theme",
"@kbn/shared-ux-utility",
"@kbn/kibana-utils-plugin",
"@kbn/ipynb",
]
}

View file

@ -310,6 +310,27 @@ export function SvlCommonNavigationProvider(ctx: FtrProviderContext) {
async clickEmbeddedConsoleControlBar() {
await testSubjects.click('consoleEmbeddedControlBar');
},
async expectEmbeddedConsoleNotebooksButtonExists() {
await testSubjects.existOrFail('consoleEmbeddedNotebooksButton');
},
async clickEmbeddedConsoleNotebooksButton() {
await testSubjects.click('consoleEmbeddedNotebooksButton');
},
async expectEmbeddedConsoleNotebooksToBeOpen() {
await testSubjects.existOrFail('consoleEmbeddedNotebooksContainer');
},
async expectEmbeddedConsoleNotebooksToBeClosed() {
await testSubjects.missingOrFail('consoleEmbeddedNotebooksContainer');
},
async expectEmbeddedConsoleNotebookListItemToBeAvailable(id: string) {
await testSubjects.existOrFail(`console-embedded-notebook-select-btn-${id}`);
},
async clickEmbeddedConsoleNotebook(id: string) {
await testSubjects.click(`console-embedded-notebook-select-btn-${id}`);
},
async expectEmbeddedConsoleNotebookToBeAvailable(id: string) {
await testSubjects.click(`console-embedded-notebook-select-btn-${id}`);
},
},
};
}

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const pageObjects = getPageObjects(['svlCommonPage', 'svlCommonNavigation']);
describe('Console Notebooks', function () {
before(async () => {
await pageObjects.svlCommonPage.login();
});
after(async () => {
await pageObjects.svlCommonPage.forceLogout();
});
it('has notebooks view available', async () => {
// Expect Console Bar & Notebooks button to exist
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleControlBarExists();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksButtonExists();
// Click the Notebooks button to open console to See Notebooks
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleNotebooksButton();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksToBeOpen();
// Click the Notebooks button again to switch to the dev console
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleNotebooksButton();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleToBeOpen();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksToBeClosed();
// Clicking control bar should close the console
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleControlBar();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksToBeClosed();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleToBeClosed();
// Re-open console and then open Notebooks
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleControlBar();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleToBeOpen();
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleNotebooksButton();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksToBeOpen();
// Close the console
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleControlBar();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksToBeClosed();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleToBeClosed();
});
it('can open notebooks', async () => {
// Click the Notebooks button to open console to See Notebooks
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleNotebooksButton();
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebooksToBeOpen();
const defaultNotebooks = [
'00_quick_start',
'01_keyword_querying_filtering',
'02_hybrid_search',
'03_elser',
'04_multilingual',
];
for (const notebookId of defaultNotebooks) {
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebookListItemToBeAvailable(
notebookId
);
await pageObjects.svlCommonNavigation.devConsole.clickEmbeddedConsoleNotebook(notebookId);
await pageObjects.svlCommonNavigation.devConsole.expectEmbeddedConsoleNotebookToBeAvailable(
notebookId
);
}
});
});
}

View file

@ -18,6 +18,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./dashboards/import_dashboard'));
loadTestFile(require.resolve('./advanced_settings'));
loadTestFile(require.resolve('./rules/rule_details'));
loadTestFile(require.resolve('./console_notebooks'));
loadTestFile(require.resolve('./ml'));
});

View file

@ -5000,6 +5000,10 @@
version "0.0.0"
uid ""
"@kbn/ipynb@link:packages/kbn-ipynb":
version "0.0.0"
uid ""
"@kbn/jest-serializers@link:packages/kbn-jest-serializers":
version "0.0.0"
uid ""