[Search][Notebooks] Introduce support for lists (#187163)

## Summary

This PR introduces support for notebook lists that can be set via query
parameter or via a function exported from the `search_notebooks` plugin
start object. This will be used to allow setting a different list of
notebooks for specific pages that allow the notebooks shown to be
contextual to the current user task.

This PR does not add any new lists of notebooks, as that will be done in
another PR for the static notebooks and done via the S3 file for dynamic
notebooks.

### Checklist

- [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
This commit is contained in:
Rodney Norris 2024-07-01 16:33:27 -05:00 committed by GitHub
parent 4e785c669c
commit bda4a6282e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 371 additions and 38 deletions

View file

@ -17,8 +17,12 @@ export interface NotebookInformation {
url: string;
};
}
export type NotebookCatalogResponse = Pick<NotebookCatalog, 'notebooks'>;
export interface NotebookCatalog {
notebooks: NotebookInformation[];
lists?: Record<string, string[] | undefined>;
}
export interface Notebook extends NotebookInformation {
@ -47,6 +51,9 @@ export const NotebookCatalogSchema = schema.object(
),
{ minSize: 1 }
),
lists: schema.maybe(
schema.recordOf(schema.string(), schema.arrayOf(schema.string(), { minSize: 1 }))
),
},
{
unknowns: 'allow',

View file

@ -10,7 +10,23 @@ import { i18n } from '@kbn/i18n';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { EmbeddedConsoleViewButtonProps } from '@kbn/console-plugin/public';
export const SearchNotebooksButton = ({ activeView, onClick }: EmbeddedConsoleViewButtonProps) => {
export interface SearchNotebooksButtonProps extends EmbeddedConsoleViewButtonProps {
clearNotebookList: () => void;
}
export const SearchNotebooksButton = ({
activeView,
onClick,
clearNotebookList,
}: SearchNotebooksButtonProps) => {
React.useEffect(() => {
return () => {
// When the Notebooks button is unmounted we want to clear
// any page specific contextual notebook list that was set.
clearNotebookList();
};
}, [clearNotebookList]);
if (activeView) {
return (
<EuiButton

View file

@ -11,15 +11,21 @@ import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SearchNotebooks } from './search_notebooks';
import { NotebookListValue } from '../types';
export interface SearchNotebooksViewProps {
core: CoreStart;
queryClient: QueryClient;
getNotebookList: () => NotebookListValue;
}
export const SearchNotebooksView = ({ core, queryClient }: SearchNotebooksViewProps) => (
export const SearchNotebooksView = ({
core,
queryClient,
getNotebookList,
}: SearchNotebooksViewProps) => (
<KibanaThemeProvider theme={core.theme}>
<KibanaContextProvider services={{ ...core }}>
<KibanaContextProvider services={{ ...core, notebooks: { getNotebookList } }}>
<QueryClientProvider client={queryClient}>
<SearchNotebooks />
</QueryClientProvider>

View file

@ -7,22 +7,38 @@
import React from 'react';
import { CoreStart } from '@kbn/core/public';
import { EmbeddedConsoleView } from '@kbn/console-plugin/public';
import type {
EmbeddedConsoleView,
EmbeddedConsoleViewButtonProps,
} from '@kbn/console-plugin/public';
import { dynamic } from '@kbn/shared-ux-utility';
import { QueryClient } from '@tanstack/react-query';
import { SearchNotebooksButton } from './components/notebooks_button';
import { NotebookListValue } from './types';
const SearchNotebooksButton = dynamic(async () => ({
default: (await import('./components/notebooks_button')).SearchNotebooksButton,
}));
const SearchNotebooksView = dynamic(async () => ({
default: (await import('./components/notebooks_view')).SearchNotebooksView,
}));
export const notebooksConsoleView = (
core: CoreStart,
queryClient: QueryClient
queryClient: QueryClient,
clearNotebookList: () => void,
getNotebookListValue: () => NotebookListValue
): EmbeddedConsoleView => {
return {
ActivationButton: SearchNotebooksButton,
ViewContent: () => <SearchNotebooksView core={core} queryClient={queryClient} />,
ActivationButton: (props: EmbeddedConsoleViewButtonProps) => (
<SearchNotebooksButton {...props} clearNotebookList={clearNotebookList} />
),
ViewContent: () => (
<SearchNotebooksView
core={core}
queryClient={queryClient}
getNotebookList={getNotebookListValue}
/>
),
};
};

View file

@ -9,8 +9,13 @@ 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';
import { NotebookListValue } from '../types';
export interface SearchNotebooksContext {
console: ConsolePluginStart;
notebooks: {
getNotebookList: () => NotebookListValue;
};
}
type ServerlessSearchKibanaContext = CoreStart & SearchNotebooksContext;

View file

@ -6,13 +6,20 @@
*/
import { useQuery } from '@tanstack/react-query';
import { NotebookCatalog } from '../../common/types';
import { NotebookCatalogResponse } from '../../common/types';
import { useKibanaServices } from './use_kibana';
import { useNotebookList } from './use_notebooks_list';
export const useNotebooksCatalog = () => {
const { http } = useKibanaServices();
const list = useNotebookList();
return useQuery({
queryKey: ['fetchNotebooksCatalog'],
queryFn: () => http.get<NotebookCatalog>('/internal/search_notebooks/notebooks'),
queryKey: [`fetchNotebooksCatalog-${list ?? 'default'}`],
queryFn: () =>
http.get<NotebookCatalogResponse>('/internal/search_notebooks/notebooks', {
query: {
list,
},
}),
});
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { parse } from 'query-string';
import { useKibanaServices } from './use_kibana';
export const readNotebookListFromParam = () => {
const [, queryString] = (window.location.search || window.location.hash || '').split('?');
const queryParams = parse(queryString || '', { sort: false });
if (queryParams && queryParams.nblist && typeof queryParams.nblist === 'string') {
return queryParams.nblist;
}
return undefined;
};
export const useNotebookList = () => {
const {
notebooks: { getNotebookList },
} = useKibanaServices();
const nbList = useMemo(() => getNotebookList(), [getNotebookList]);
const nbListQueryParam = useMemo(() => readNotebookListFromParam(), []);
if (nbListQueryParam) return nbListQueryParam;
if (nbList) return nbList;
return undefined;
};

View file

@ -13,13 +13,16 @@ import {
SearchNotebooksPluginSetup,
SearchNotebooksPluginStart,
SearchNotebooksPluginStartDependencies,
NotebookListValue,
} from './types';
import { getErrorCode, getErrorMessage, isKibanaServerError } from './utils/get_error_message';
export class SearchNotebooksPlugin
implements Plugin<SearchNotebooksPluginSetup, SearchNotebooksPluginStart>
{
private notebooksList: NotebookListValue = null;
private queryClient: QueryClient | undefined;
public setup(core: CoreSetup): SearchNotebooksPluginSetup {
this.queryClient = new QueryClient({
mutationCache: new MutationCache({
@ -55,10 +58,31 @@ export class SearchNotebooksPlugin
): SearchNotebooksPluginStart {
if (deps.console?.registerEmbeddedConsoleAlternateView) {
deps.console.registerEmbeddedConsoleAlternateView(
notebooksConsoleView(core, this.queryClient!)
notebooksConsoleView(
core,
this.queryClient!,
this.clearNotebookList.bind(this),
this.getNotebookList.bind(this)
)
);
}
return {};
return {
setNotebookList: (value: NotebookListValue) => {
this.setNotebookList(value);
},
};
}
public stop() {}
private clearNotebookList() {
this.setNotebookList(null);
}
private setNotebookList(value: NotebookListValue) {
this.notebooksList = value;
}
private getNotebookList(): NotebookListValue {
return this.notebooksList;
}
}

View file

@ -9,9 +9,13 @@ 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 SearchNotebooksPluginStart {
setNotebookList: (value: NotebookListValue) => void;
}
export interface SearchNotebooksPluginStartDependencies {
console: ConsolePluginStart;
}
export type NotebookListValue = string | null;

View file

@ -23,6 +23,7 @@ import {
getNotebook,
getNotebookCatalog,
DEFAULT_NOTEBOOKS,
NOTEBOOKS_MAP,
NotebookCatalogFetchOptions,
getNotebookMetadata,
} from './notebook_catalog';
@ -65,7 +66,32 @@ describe('Notebook Catalog', () => {
describe('getNotebookCatalog', () => {
describe('static notebooks', () => {
it('returns default notebooks when theres an empty catalog config', async () => {
await expect(getNotebookCatalog(staticOptions)).resolves.toBe(DEFAULT_NOTEBOOKS);
await expect(getNotebookCatalog(staticOptions)).resolves.toMatchObject(DEFAULT_NOTEBOOKS);
});
it.skip('returns requested list of notebooks when it exists', async () => {
// Re-enable this with actual list when we implement them
await expect(
getNotebookCatalog({ ...staticOptions, notebookList: 'ml' })
).resolves.toMatchObject({
notebooks: [
NOTEBOOKS_MAP['03_elser'],
NOTEBOOKS_MAP['02_hybrid_search'],
NOTEBOOKS_MAP['04_multilingual'],
],
});
});
it('returns default list if requested list doesnt exist', async () => {
await expect(
getNotebookCatalog({ ...staticOptions, notebookList: 'foo' })
).resolves.toMatchObject({
notebooks: [
NOTEBOOKS_MAP['00_quick_start'],
NOTEBOOKS_MAP['01_keyword_querying_filtering'],
NOTEBOOKS_MAP['02_hybrid_search'],
NOTEBOOKS_MAP['03_elser'],
NOTEBOOKS_MAP['04_multilingual'],
],
});
});
});
@ -310,6 +336,97 @@ describe('Notebook Catalog', () => {
timestamp: fakeNow,
});
});
describe('supports notebook lists', () => {
beforeEach(() => {
const mockCatalog: RemoteNotebookCatalog = {
notebooks: [
{
id: 'unit-test',
title: 'Test',
description: 'Test notebook',
url: 'http://localhost:3000/my_notebook.ipynb',
},
{
id: 'unit-test-002',
title: 'Test',
description: 'Test notebook 2',
url: 'http://localhost:3000/my_other_notebook.ipynb',
},
],
lists: {
default: ['unit-test', 'unit-test-002'],
test: ['unit-test-002'],
vector: ['unit-test'],
},
};
const mockResp = {
status: 200,
statusText: 'OK',
ok: true,
json: jest.fn().mockResolvedValue(mockCatalog),
};
fetchMock.mockResolvedValue(mockResp);
});
it('can return a custom notebook list', async () => {
await expect(
getNotebookCatalog({ ...dynamicOptions, notebookList: 'test' })
).resolves.toEqual({
notebooks: [
{
id: 'unit-test-002',
title: 'Test',
description: 'Test notebook 2',
},
],
});
await expect(
getNotebookCatalog({ ...dynamicOptions, notebookList: 'vector' })
).resolves.toEqual({
notebooks: [
{
id: 'unit-test',
title: 'Test',
description: 'Test notebook',
},
],
});
});
it('returns default list when requested list not defined', async () => {
await expect(
getNotebookCatalog({ ...dynamicOptions, notebookList: 'foo' })
).resolves.toEqual({
notebooks: [
{
id: 'unit-test',
title: 'Test',
description: 'Test notebook',
},
{
id: 'unit-test-002',
title: 'Test',
description: 'Test notebook 2',
},
],
});
});
it('returns default list when list is not specified', async () => {
await expect(getNotebookCatalog(dynamicOptions)).resolves.toEqual({
notebooks: [
{
id: 'unit-test',
title: 'Test',
description: 'Test notebook',
},
{
id: 'unit-test-002',
title: 'Test',
description: 'Test notebook 2',
},
],
});
});
});
});
});

View file

@ -14,8 +14,8 @@ import { NotebookDefinition } from '@kbn/ipynb';
import {
NotebookCatalog,
NotebookCatalogResponse,
NotebookInformation,
NotebookCatalogSchema,
NotebookSchema,
} from '../../common/types';
@ -23,9 +23,10 @@ import type { SearchNotebooksConfig } from '../config';
import type { NotebooksCache, RemoteNotebookCatalog } from '../types';
import {
cleanCachedNotebook,
cleanCachedNotebookCatalog,
cleanNotebookMetadata,
dateWithinTTL,
notebookCatalogResponse,
validateRemoteNotebookCatalog,
} from '../utils';
const NOTEBOOKS_DATA_DIR = '../data';
@ -38,8 +39,10 @@ export interface NotebookCatalogFetchOptions {
cache: NotebooksCache;
config: SearchNotebooksConfig;
logger: Logger;
notebookList?: string;
}
// Notebook catalog v1, leaving to ensure backward-compatibility
export const DEFAULT_NOTEBOOKS: NotebookCatalog = {
notebooks: [
{
@ -102,26 +105,40 @@ export const DEFAULT_NOTEBOOKS: NotebookCatalog = {
},
],
};
// Notebook catalog v1.1 with lists for contextual notebooks
export const DEFAULT_NOTEBOOK_CATALOG: NotebookCatalog = {
notebooks: [...DEFAULT_NOTEBOOKS.notebooks],
lists: {
default: [
'00_quick_start',
'01_keyword_querying_filtering',
'02_hybrid_search',
'03_elser',
'04_multilingual',
],
},
};
export const NOTEBOOKS_MAP: Record<string, NotebookInformation> =
DEFAULT_NOTEBOOKS.notebooks.reduce((nbMap, nb) => {
DEFAULT_NOTEBOOK_CATALOG.notebooks.reduce((nbMap, nb) => {
nbMap[nb.id] = nb;
return nbMap;
}, {} as Record<string, NotebookInformation>);
const NOTEBOOK_IDS = DEFAULT_NOTEBOOKS.notebooks.map(({ id }) => id);
const NOTEBOOK_IDS = DEFAULT_NOTEBOOK_CATALOG.notebooks.map(({ id }) => id);
export const getNotebookCatalog = async ({
config,
cache,
logger,
}: NotebookCatalogFetchOptions) => {
notebookList,
}: NotebookCatalogFetchOptions): Promise<NotebookCatalogResponse> => {
if (config.catalog && config.catalog.url) {
const catalog = await fetchNotebookCatalog(config.catalog, cache, logger);
const catalog = await fetchNotebookCatalog(config.catalog, cache, logger, notebookList);
if (catalog) {
return catalog;
}
}
return DEFAULT_NOTEBOOKS;
return notebookCatalogResponse(DEFAULT_NOTEBOOK_CATALOG, notebookList);
};
export const getNotebook = async (
@ -179,19 +196,20 @@ type CatalogConfig = Readonly<{
export const fetchNotebookCatalog = async (
catalogConfig: CatalogConfig,
cache: NotebooksCache,
logger: Logger
): Promise<NotebookCatalog | null> => {
logger: Logger,
notebookList?: string
): Promise<NotebookCatalogResponse | null> => {
if (cache.catalog && dateWithinTTL(cache.catalog.timestamp, catalogConfig.ttl)) {
return cleanCachedNotebookCatalog(cache.catalog);
return notebookCatalogResponse(cache.catalog, notebookList);
}
try {
const resp = await fetch(catalogConfig.url, FETCH_OPTIONS);
if (resp.ok) {
const respJson = await resp.json();
const catalog: RemoteNotebookCatalog = NotebookCatalogSchema.validate(respJson);
const catalog: RemoteNotebookCatalog = validateRemoteNotebookCatalog(respJson);
cache.catalog = { ...catalog, timestamp: new Date() };
return cleanCachedNotebookCatalog(cache.catalog);
return notebookCatalogResponse(cache.catalog, notebookList);
} else {
throw new Error(`Failed to fetch notebook ${resp.status} ${resp.statusText}`);
}
@ -203,7 +221,7 @@ export const fetchNotebookCatalog = async (
if (cache.catalog && dateWithinTTL(cache.catalog.timestamp, catalogConfig.errorTTL)) {
// If we can't fetch the catalog but we have it cached and it's within the error TTL,
// returned the cached value.
return cleanCachedNotebookCatalog(cache.catalog);
return notebookCatalogResponse(cache.catalog, notebookList);
}
}

View file

@ -16,16 +16,26 @@ export function defineRoutes({ config, notebooksCache, logger, router }: RouteDe
router.get(
{
path: '/internal/search_notebooks/notebooks',
validate: {},
validate: {
query: schema.object({
list: schema.maybe(schema.string()),
}),
},
options: {
access: 'internal',
},
},
async (_context, _request, response) => {
const notebooks = await getNotebookCatalog({ cache: notebooksCache, config, logger });
async (_context, request, response) => {
const { list } = request.query;
const resp = await getNotebookCatalog({
cache: notebooksCache,
config,
logger,
notebookList: list,
});
return response.ok({
body: notebooks,
body: resp,
headers: { 'content-type': 'application/json' },
});
}

View file

@ -6,11 +6,17 @@
*/
import { NotebookDefinition } from '@kbn/ipynb';
import { NotebookCatalog, NotebookInformation } from '../common/types';
import {
NotebookCatalog,
NotebookCatalogResponse,
NotebookCatalogSchema,
NotebookInformation,
} from '../common/types';
import type {
CachedNotebook,
CachedNotebookCatalog,
NotebooksCache,
RemoteNotebookCatalog,
RemoteNotebookInformation,
} from './types';
@ -24,10 +30,12 @@ export function dateWithinTTL(value: Date, ttl: number) {
return (Date.now() - value.getTime()) / 1000 <= ttl;
}
export function cleanCachedNotebookCatalog(catalog: CachedNotebookCatalog): NotebookCatalog {
const notebooks = catalog.notebooks.map(cleanNotebookMetadata);
export function cleanCachedNotebookCatalog(
notebooks: Array<RemoteNotebookInformation | NotebookInformation>
): NotebookCatalogResponse {
const cleanedNotebooks = notebooks.map(cleanNotebookMetadata);
return {
notebooks,
notebooks: cleanedNotebooks,
};
}
export function cleanCachedNotebook(notebook: CachedNotebook): NotebookDefinition {
@ -35,7 +43,9 @@ export function cleanCachedNotebook(notebook: CachedNotebook): NotebookDefinitio
return result;
}
export function cleanNotebookMetadata(nb: RemoteNotebookInformation): NotebookInformation {
export function cleanNotebookMetadata(
nb: RemoteNotebookInformation | NotebookInformation
): NotebookInformation {
const { id, title, description, link } = nb;
return {
description,
@ -44,3 +54,64 @@ export function cleanNotebookMetadata(nb: RemoteNotebookInformation): NotebookIn
title,
};
}
export function isCachedNotebookCatalog(
catalog: CachedNotebookCatalog | NotebookCatalog
): catalog is CachedNotebookCatalog {
return 'timestamp' in catalog;
}
const DEFAULT_NOTEBOOK_LIST_KEY = 'default';
export function notebookCatalogResponse(
catalog: CachedNotebookCatalog | NotebookCatalog,
list: string = DEFAULT_NOTEBOOK_LIST_KEY
): NotebookCatalogResponse {
if (!catalog.lists) {
return isCachedNotebookCatalog(catalog)
? cleanCachedNotebookCatalog(catalog.notebooks)
: catalog;
}
const listOfNotebookIds = getListOfNotebookIds(catalog.lists, list);
const notebookIndexMap = (catalog.notebooks as NotebookInformation[]).reduce(
(indexMap, nb, i) => {
indexMap[nb.id] = i;
return indexMap;
},
{} as Record<string, number | undefined>
);
const notebooks = listOfNotebookIds
.map((id) => {
const nbIndex = notebookIndexMap[id];
if (nbIndex === undefined) return undefined;
return catalog.notebooks[nbIndex] ?? undefined;
})
.filter(
(nbInfo): nbInfo is RemoteNotebookInformation | NotebookInformation => nbInfo !== undefined
);
return cleanCachedNotebookCatalog(notebooks);
}
function getListOfNotebookIds(
catalogLists: NonNullable<NotebookCatalog['lists']>,
list: string
): string[] {
if (list in catalogLists && catalogLists[list]) return catalogLists[list]!;
if (DEFAULT_NOTEBOOK_LIST_KEY in catalogLists && catalogLists[DEFAULT_NOTEBOOK_LIST_KEY])
return catalogLists[DEFAULT_NOTEBOOK_LIST_KEY];
// This should not happen as we should not load a catalog with lists thats missing the default list as valid,
// but handling this case for code completeness.
throw new Error('Notebook catalog has lists, but is missing default list'); // TODO: ?translate
}
export function validateRemoteNotebookCatalog(respJson: any): RemoteNotebookCatalog {
const catalog: RemoteNotebookCatalog = NotebookCatalogSchema.validate(respJson);
if (catalog.lists && !(DEFAULT_NOTEBOOK_LIST_KEY in catalog.lists)) {
// TODO: translate error message
throw new Error(
'Invalid remote notebook catalog. Catalog defines lists, but is missing the default list.'
);
}
return catalog;
}