[Search][Onboarding] Elasticsearch start page (#192034)

## Summary

This PR introduces the elasticsearch start page with the UI user flow.
This page allows you to create an index and then redirects you to the
index details page on creation. It also polls for new indices so if you
create an index in the dev console or outside the browser it will also
redirect you to the index details page for the new index.

For now we are redirecting to the existing index details page, but we'll
update this once the new page is in place.

This PR also introduces FTR tests for the above UX.

### Screenshots

![image](https://github.com/user-attachments/assets/2868146c-41fa-49ce-b4bd-3e700bf3489c)
Viewer

![image](https://github.com/user-attachments/assets/a7f6bee7-378a-49df-ba52-56f54187ed5e)


### Testing
The `search_indices` plugin is disabled by default so to test this you
must enable it in your `kibana.dev.yml` with:
```yaml
xpack.searchIndices.enabled: true
```
Then run serverless search with `yarn serverless-es`
Then navigate to the new start page manually:
`http://localhost:5601/app/elasticsearch/start`
This page will redirect you if you have any indices, so to test the UX
you need to start with 0 user indices.

### 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))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Rodney Norris 2024-09-09 13:46:28 -05:00 committed by GitHub
parent e02e8fc1ff
commit 31b7c0b0a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 933 additions and 12 deletions

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const GET_STATUS_ROUTE = '/internal/search_indices/status';
export const GET_USER_PRIVILEGES_ROUTE = '/internal/search_indices/start_privileges';
export const POST_CREATE_INDEX_ROUTE = '/internal/search_indices/indices/create';

View file

@ -15,3 +15,11 @@ export interface UserStartPrivilegesResponse {
canCreateIndex: boolean;
};
}
export interface CreateIndexRequest {
indexName: string;
}
export interface CreateIndexResponse {
index: string;
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum AnalyticsEvents {
startPageOpened = 'start_page_opened',
startCreateIndexClick = 'start_create_index',
}

View file

@ -11,20 +11,19 @@ import { CoreStart } from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClientProvider } from '@tanstack/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Router } from '@kbn/shared-ux-router';
import { UsageTrackerContextProvider } from './contexts/usage_tracker_context';
import { initQueryClient } from './services/query_client';
import { SearchIndicesServicesContextDeps } from './types';
export const renderApp = async (
App: React.FC<{}>,
core: CoreStart,
services: SearchIndicesServicesContextDeps,
element: HTMLElement
element: HTMLElement,
queryClient: QueryClient
) => {
const queryClient = initQueryClient(core.notifications.toasts);
ReactDOM.render(
<KibanaRenderContextProvider {...core}>
<KibanaContextProvider services={{ ...core, ...services }}>

View file

@ -0,0 +1,157 @@
/*
* 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, useState } from 'react';
import {
EuiButton,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIcon,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { UserStartPrivilegesResponse } from '../../../common';
import { AnalyticsEvents } from '../../analytics/constants';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { isValidIndexName, generateRandomIndexName } from '../../utils/indices';
import { useCreateIndex } from './hooks/use_create_index';
interface CreateIndexFormState {
indexName: string;
}
function initCreateIndexState(): CreateIndexFormState {
return {
indexName: generateRandomIndexName(),
};
}
export interface CreateIndexFormProps {
userPrivileges?: UserStartPrivilegesResponse;
}
export const CreateIndexForm = ({ userPrivileges }: CreateIndexFormProps) => {
const [formState, setFormState] = useState<CreateIndexFormState>(initCreateIndexState());
const [indexNameHasError, setIndexNameHasError] = useState<boolean>(false);
const usageTracker = useUsageTracker();
const { createIndex, isLoading } = useCreateIndex();
const onCreateIndex = useCallback(() => {
if (!isValidIndexName(formState.indexName)) {
return;
}
usageTracker.click(AnalyticsEvents.startCreateIndexClick);
createIndex({ indexName: formState.indexName });
}, [usageTracker, createIndex, formState.indexName]);
const onIndexNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newIndexName = e.target.value;
setFormState({ ...formState, indexName: e.target.value });
const invalidIndexName = !isValidIndexName(newIndexName);
if (indexNameHasError !== invalidIndexName) {
setIndexNameHasError(invalidIndexName);
}
};
return (
<EuiForm component="form" fullWidth>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h4>
{i18n.translate('xpack.searchIndices.startPage.createIndex.title', {
defaultMessage: 'Create your first index',
})}
</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<></>
</EuiFlexItem>
</EuiFlexGroup>
<EuiText color="subdued">
<p>
{i18n.translate('xpack.searchIndices.startPage.createIndex.description', {
defaultMessage:
'An index stores your data and defines the schema, or field mappings, for your searches',
})}
</p>
</EuiText>
<EuiFormRow
label={i18n.translate('xpack.searchIndices.startPage.createIndex.name.label', {
defaultMessage: 'Name your index',
})}
helpText={i18n.translate('xpack.searchIndices.startPage.createIndex.name.helpText', {
defaultMessage:
'Index names must be lowercase and can only contain hyphens and numbers',
})}
fullWidth
isInvalid={indexNameHasError}
>
<EuiFieldText
fullWidth
data-test-subj="indexNameField"
name="indexName"
value={formState.indexName}
isInvalid={indexNameHasError}
onChange={onIndexNameChange}
placeholder={i18n.translate(
'xpack.searchIndices.startPage.createIndex.name.placeholder',
{
defaultMessage: 'Enter a name for your index',
}
)}
/>
</EuiFormRow>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
iconSide="left"
iconType="sparkles"
data-test-subj="createIndexBtn"
fill
disabled={
indexNameHasError ||
isLoading ||
userPrivileges?.privileges?.canCreateIndex === false
}
isLoading={isLoading}
onClick={onCreateIndex}
>
{i18n.translate('xpack.searchIndices.startPage.createIndex.action.text', {
defaultMessage: 'Create my index',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
{userPrivileges?.privileges?.canCreateApiKeys && (
<EuiFlexGroup gutterSize="s">
<EuiIcon size="l" type="key" color="subdued" />
<EuiText size="s" data-test-subj="apiKeyLabel">
<p>
{i18n.translate(
'xpack.searchIndices.startPage.createIndex.apiKeyCreation.description',
{
defaultMessage: "We'll create an API key for this index",
}
)}
</p>
</EuiText>
</EuiFlexGroup>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiForm>
);
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { IndicesStatusResponse, UserStartPrivilegesResponse } from '../../../common';
import { AnalyticsEvents } from '../../analytics/constants';
import { useUsageTracker } from '../../hooks/use_usage_tracker';
import { CreateIndexForm } from './create_index';
const MAX_WIDTH = '600px';
export interface ElasticsearchStartProps {
indicesData?: IndicesStatusResponse;
userPrivileges?: UserStartPrivilegesResponse;
}
export const ElasticsearchStart = ({ userPrivileges }: ElasticsearchStartProps) => {
const usageTracker = useUsageTracker();
useEffect(() => {
usageTracker.load(AnalyticsEvents.startPageOpened);
}, [usageTracker]);
return (
<EuiPanel
color="subdued"
hasShadow={false}
hasBorder
paddingSize="l"
style={{ maxWidth: MAX_WIDTH, margin: '0 auto' }}
>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem grow={false}>
<EuiIcon type="logoElasticsearch" size="xl" />
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel paddingSize="none" color="transparent">
<EuiTitle size="xs">
<h1>
{i18n.translate('xpack.searchIndices.startPage.pageTitle', {
defaultMessage: 'Elasticsearch',
})}
</h1>
</EuiTitle>
<EuiSpacer size="s" />
<EuiTitle size="l">
<h2>
{i18n.translate('xpack.searchIndices.startPage.pageDescription', {
defaultMessage: 'Vectorize, search, and visualize your data',
})}
</h2>
</EuiTitle>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiPanel>
<CreateIndexForm userPrivileges={userPrivileges} />
</EuiPanel>
</EuiPanel>
);
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import { useCreateIndex as useCreateIndexApi } from '../../../hooks/api/use_create_index';
import { useKibana } from '../../../hooks/use_kibana';
import { navigateToIndexDetails } from './utils';
export const useCreateIndex = () => {
const { application, http } = useKibana().services;
const { createIndex, isSuccess, isLoading, data: createIndexResponse } = useCreateIndexApi();
useEffect(() => {
if (isSuccess && createIndexResponse !== undefined) {
navigateToIndexDetails(application, http, createIndexResponse.index);
return;
}
}, [application, http, isSuccess, createIndexResponse]);
return { createIndex, isLoading };
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import type { IndicesStatusResponse } from '../../../../common';
import { useKibana } from '../../../hooks/use_kibana';
import { navigateToIndexDetails } from './utils';
export const useIndicesRedirect = (indicesStatus?: IndicesStatusResponse) => {
const { application, http } = useKibana().services;
return useEffect(() => {
if (!indicesStatus) return;
if (indicesStatus.indexNames.length === 0) return;
if (indicesStatus.indexNames.length === 1) {
navigateToIndexDetails(application, http, indicesStatus.indexNames[0]);
return;
}
application.navigateToApp('management', { deepLinkId: 'index_management' });
}, [application, http, indicesStatus]);
};

View file

@ -0,0 +1,23 @@
/*
* 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 { ApplicationStart, HttpSetup } from '@kbn/core/public';
// TODO: we should define a locator for this and use that instead
const INDEX_DETAILS_PATH = '/app/management/data/index_management/indices/index_details';
function getIndexDetailsPath(http: HttpSetup, indexName: string) {
return http.basePath.prepend(`${INDEX_DETAILS_PATH}?indexName=${indexName}`);
}
export const navigateToIndexDetails = (
application: ApplicationStart,
http: HttpSetup,
indexName: string
) => {
application.navigateToUrl(getIndexDetailsPath(http, indexName));
};

View file

@ -11,24 +11,40 @@ import { EuiLoadingLogo, EuiPageTemplate } from '@elastic/eui';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useKibana } from '../../hooks/use_kibana';
import { useIndicesStatusQuery } from '../../hooks/api/use_indices_status';
import { useUserPrivilegesQuery } from '../../hooks/api/use_user_permissions';
import { useIndicesRedirect } from './hooks/use_indices_redirect';
import { ElasticsearchStart } from './elasticsearch_start';
import { StartPageError } from './status_error';
export const ElasticsearchStartPage = () => {
const { console: consolePlugin } = useKibana().services;
const {
data: indicesData,
isInitialLoading,
isError: hasIndicesStatusFetchError,
error: indicesFetchError,
} = useIndicesStatusQuery();
const { data: userPrivileges } = useUserPrivilegesQuery();
const embeddableConsole = useMemo(
() => (consolePlugin?.EmbeddableConsole ? <consolePlugin.EmbeddableConsole /> : null),
[consolePlugin]
);
useIndicesRedirect(indicesData);
return (
<EuiPageTemplate
offset={0}
restrictWidth={false}
data-test-subj="search-startpage"
data-test-subj="elasticsearchStartPage"
grow={false}
>
<KibanaPageTemplate.Section alignment="center" restrictWidth={false} grow>
<EuiLoadingLogo />
{isInitialLoading && <EuiLoadingLogo />}
{hasIndicesStatusFetchError && <StartPageError error={indicesFetchError} />}
<ElasticsearchStart indicesData={indicesData} userPrivileges={userPrivileges} />
</KibanaPageTemplate.Section>
{embeddableConsole}
</EuiPageTemplate>

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 { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { getErrorMessage } from '../../utils/errors';
export interface StartPageErrorProps {
error: unknown;
}
export const StartPageError = ({ error }: StartPageErrorProps) => {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<h2>
{i18n.translate('xpack.searchIndices.startPage.statusFetchError.title', {
defaultMessage: 'Error loading indices',
})}
</h2>
}
body={
<EuiCodeBlock css={{ textAlign: 'left' }}>
{getErrorMessage(
error,
i18n.translate('xpack.searchIndices.startPage.statusFetchError.unknownError', {
defaultMessage: 'Unknown error fetching indices.',
})
)}
</EuiCodeBlock>
}
/>
);
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMutation } from '@tanstack/react-query';
import { POST_CREATE_INDEX_ROUTE } from '../../../common/routes';
import { CreateIndexRequest, CreateIndexResponse } from '../../../common/types';
import { useKibana } from '../use_kibana';
export const useCreateIndex = () => {
const { http } = useKibana().services;
const { mutate: createIndex, ...rest } = useMutation({
mutationKey: ['searchIndicesCreateIndex'],
mutationFn: async (input: CreateIndexRequest) =>
http.post<CreateIndexResponse>(POST_CREATE_INDEX_ROUTE, {
body: JSON.stringify(input),
}),
});
return { createIndex, ...rest };
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { GET_STATUS_ROUTE } from '../../../common/routes';
import type { IndicesStatusResponse } from '../../../common/types';
import { useKibana } from '../use_kibana';
const DEFAULT_INDICES_POLLING_INTERVAL = 15 * 1000;
export const useIndicesStatusQuery = (pollingInterval = DEFAULT_INDICES_POLLING_INTERVAL) => {
const { http } = useKibana().services;
return useQuery({
refetchInterval: pollingInterval,
refetchIntervalInBackground: true,
refetchOnWindowFocus: 'always',
retry: true,
queryKey: ['fetchSearchIndicesStatus'],
queryFn: () => http.get<IndicesStatusResponse>(GET_STATUS_ROUTE),
});
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { GET_USER_PRIVILEGES_ROUTE } from '../../../common/routes';
import type { UserStartPrivilegesResponse } from '../../../common/types';
import { useKibana } from '../use_kibana';
export const useUserPrivilegesQuery = () => {
const { http } = useKibana().services;
return useQuery({
queryKey: ['fetchUserStartPrivileges'],
queryFn: () => http.get<UserStartPrivilegesResponse>(GET_USER_PRIVILEGES_ROUTE),
});
};

View file

@ -13,6 +13,7 @@ import type {
SearchIndicesPluginStart,
SearchIndicesServicesContextDeps,
} from './types';
import { initQueryClient } from './services/query_client';
export class SearchIndicesPlugin
implements Plugin<SearchIndicesPluginSetup, SearchIndicesPluginStart>
@ -20,6 +21,8 @@ export class SearchIndicesPlugin
public setup(
core: CoreSetup<SearchIndicesAppPluginStartDependencies, SearchIndicesPluginStart>
): SearchIndicesPluginSetup {
const queryClient = initQueryClient(core.notifications.toasts);
core.application.register({
id: 'elasticsearchStart',
appRoute: '/app/elasticsearch/start',
@ -34,7 +37,7 @@ export class SearchIndicesPlugin
...depsStart,
history,
};
return renderApp(ElasticsearchStartPage, coreStart, startDeps, element);
return renderApp(ElasticsearchStartPage, coreStart, startDeps, element, queryClient);
},
});
core.application.register({
@ -51,7 +54,7 @@ export class SearchIndicesPlugin
...depsStart,
history,
};
return renderApp(SearchIndicesRouter, coreStart, startDeps, element);
return renderApp(SearchIndicesRouter, coreStart, startDeps, element, queryClient);
},
});

View file

@ -0,0 +1,49 @@
/*
* 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 { generateRandomIndexName, isValidIndexName } from './indices';
describe('indices utils', function () {
describe('generateRandomIndexName', function () {
const DEFAULT_PREFIX = 'search-';
const DEFAULT_SUFFIX_LENGTH = 4;
it('defaults to search- with a 4 character suffix', () => {
const indexName = generateRandomIndexName();
expect(indexName.startsWith(DEFAULT_PREFIX)).toBe(true);
expect(indexName.length).toBe(DEFAULT_PREFIX.length + DEFAULT_SUFFIX_LENGTH);
expect(isValidIndexName(indexName)).toBe(true);
});
it('supports changing the prefix', () => {
const otherPrefix = 'foo-';
const indexName = generateRandomIndexName(otherPrefix);
expect(indexName.startsWith(otherPrefix)).toBe(true);
expect(indexName.length).toBe(otherPrefix.length + DEFAULT_SUFFIX_LENGTH);
expect(isValidIndexName(indexName)).toBe(true);
});
it('supports changing the suffix length', () => {
const indexName = generateRandomIndexName(undefined, 6);
expect(indexName.startsWith(DEFAULT_PREFIX)).toBe(true);
expect(indexName.length).toBe(DEFAULT_PREFIX.length + 6);
expect(isValidIndexName(indexName)).toBe(true);
});
it('fallsback to single character suffix for invalid lengths', () => {
let indexName = generateRandomIndexName(undefined, 0);
expect(indexName.startsWith(DEFAULT_PREFIX)).toBe(true);
expect(indexName.length).toBe(DEFAULT_PREFIX.length + 1);
expect(isValidIndexName(indexName)).toBe(true);
indexName = generateRandomIndexName(undefined, -5);
expect(indexName.startsWith(DEFAULT_PREFIX)).toBe(true);
expect(indexName.length).toBe(DEFAULT_PREFIX.length + 1);
expect(isValidIndexName(indexName)).toBe(true);
});
});
});

View file

@ -0,0 +1,37 @@
/*
* 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.
*/
// see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html for the current rules
export function isValidIndexName(name: string) {
const byteLength = encodeURI(name).split(/%(?:u[0-9A-F]{2})?[0-9A-F]{2}|./).length - 1;
const reg = new RegExp('[\\\\/:*?"<>|\\s,#]+');
const indexPatternInvalid =
byteLength > 255 || // name can't be greater than 255 bytes
name !== name.toLowerCase() || // name should be lowercase
name.match(/^[-_+.]/) !== null || // name can't start with these chars
name.match(reg) !== null; // name can't contain these chars
return !indexPatternInvalid;
}
export function generateRandomIndexName(
prefix: string = 'search-',
randomSuffixLength: number = 4
) {
const suffixCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charsLength = suffixCharacters.length;
let result = prefix;
let counter = 0;
do {
result += suffixCharacters.charAt(Math.random() * charsLength);
counter++;
} while (counter < randomSuffixLength);
return result;
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { Logger } from '@kbn/logging';
import { createIndex } from './indices';
const mockLogger = {
warn: jest.fn(),
error: jest.fn(),
};
const logger: Logger = mockLogger as unknown as Logger;
const mockClient = {
indices: {
create: jest.fn(),
},
};
const client = mockClient as unknown as ElasticsearchClient;
describe('indices lib', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('createIndex', () => {
it('should create index with req', async () => {
mockClient.indices.create.mockResolvedValue({});
await expect(createIndex(client, logger, { indexName: 'test-index' })).resolves.toEqual({
index: 'test-index',
});
expect(mockClient.indices.create).toHaveBeenCalledTimes(1);
expect(mockClient.indices.create).toHaveBeenCalledWith({ index: 'test-index' });
});
it('should raise errors from client', async () => {
const error = new Error('Boom!!');
mockClient.indices.create.mockRejectedValue(error);
await expect(createIndex(client, logger, { indexName: 'test-index' })).rejects.toEqual(error);
});
});
});

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { Logger } from '@kbn/logging';
import { CreateIndexRequest, CreateIndexResponse } from '../../common/types';
export async function createIndex(
client: ElasticsearchClient,
logger: Logger,
data: CreateIndexRequest
): Promise<CreateIndexResponse> {
await client.indices.create({
index: data.indexName,
});
return {
index: data.indexName,
};
}

View file

@ -8,8 +8,10 @@
import type { IRouter } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { registerIndicesRoutes } from './indices';
import { registerStatusRoutes } from './status';
export function defineRoutes(router: IRouter, logger: Logger) {
registerIndicesRoutes(router, logger);
registerStatusRoutes(router, logger);
}

View file

@ -0,0 +1,65 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import type { IRouter } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { POST_CREATE_INDEX_ROUTE } from '../../common/routes';
import { CreateIndexRequest } from '../../common/types';
import { createIndex } from '../lib/indices';
export function registerIndicesRoutes(router: IRouter, logger: Logger) {
router.post(
{
path: POST_CREATE_INDEX_ROUTE,
validate: {
body: schema.object({
indexName: schema.string(),
}),
},
options: {
access: 'internal',
},
},
async (context, request, response) => {
const core = await context.core;
const client = core.elasticsearch.client.asCurrentUser;
const data: CreateIndexRequest = request.body;
try {
const body = await createIndex(client, logger, data);
return response.ok({
body,
headers: { 'content-type': 'application/json' },
});
} catch (e) {
switch (e?.meta?.body?.error?.type) {
case 'resource_already_exists_exception':
return response.conflict({
body: {
message: e.message,
},
});
}
return response.customError({
statusCode: e?.meta && e.meta?.statusCode ? e.meta?.statusCode : 500,
body: {
message: i18n.translate('xpack.searchIndices.server.createIndex.errorMessage', {
defaultMessage: 'Failed to create index due to an exception.\n{errorMessage}',
values: {
errorMessage: e.message,
},
}),
},
});
}
}
);
}

View file

@ -8,12 +8,13 @@
import type { IRouter } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { GET_STATUS_ROUTE, GET_USER_PRIVILEGES_ROUTE } from '../../common/routes';
import { fetchIndicesStatus, fetchUserStartPrivileges } from '../lib/status';
export function registerStatusRoutes(router: IRouter, logger: Logger) {
router.get(
{
path: '/internal/search_indices/status',
path: GET_STATUS_ROUTE,
validate: {},
options: {
access: 'internal',
@ -33,7 +34,7 @@ export function registerStatusRoutes(router: IRouter, logger: Logger) {
router.get(
{
path: '/internal/search_indices/start_privileges',
path: GET_USER_PRIVILEGES_ROUTE,
validate: {},
options: {
access: 'internal',

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from 'expect';
import { InternalRequestHeader, RoleCredentials } from '../../../../shared/services';
import { FtrProviderContext } from '../../../ftr_provider_context';
const INTERNAL_API_BASE_PATH = '/internal/search_indices';
export default function ({ getService }: FtrProviderContext) {
const log = getService('log');
const svlCommonApi = getService('svlCommonApi');
const svlUserManager = getService('svlUserManager');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const supertestWithoutAuth = getService('supertestWithoutAuth');
let roleAuthc: RoleCredentials;
let internalReqHeader: InternalRequestHeader;
describe('search_indices Indices APIs', function () {
before(function () {
internalReqHeader = svlCommonApi.getInternalRequestHeader();
});
describe('create index', function () {
const createIndexName = 'a-test-index';
describe('developer', function () {
before(async () => {
// get auth header for Viewer role
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('developer');
});
after(async () => {
// Cleanup index created for testing purposes
try {
await esDeleteAllIndices(createIndexName);
} catch (err) {
log.debug('[Cleanup error] Error deleting index');
throw err;
}
await svlUserManager.invalidateM2mApiKeyWithRoleScope(roleAuthc);
});
it('can create a new index', async () => {
const { body } = await supertestWithoutAuth
.post(`${INTERNAL_API_BASE_PATH}/indices/create`)
.set(internalReqHeader)
.set(roleAuthc.apiKeyHeader)
.send({
indexName: createIndexName,
})
.expect(200);
expect(body?.index).toBe(createIndexName);
});
it('gives a conflict error if the index exists already', async () => {
await supertestWithoutAuth
.post(`${INTERNAL_API_BASE_PATH}/indices/create`)
.set(internalReqHeader)
.set(roleAuthc.apiKeyHeader)
.send({
indexName: createIndexName,
})
.expect(409);
});
});
describe('viewer', function () {
before(async () => {
// get auth header for Viewer role
roleAuthc = await svlUserManager.createM2mApiKeyWithRoleScope('viewer');
});
it('cannot create a new index', async () => {
await supertestWithoutAuth
.post(`${INTERNAL_API_BASE_PATH}/indices/create`)
.set(internalReqHeader)
.set(roleAuthc.apiKeyHeader)
.send({
indexName: 'a-new-index',
})
.expect(403);
});
});
});
});
}

View file

@ -22,6 +22,7 @@ import { SvlManagementPageProvider } from './svl_management_page';
import { SvlIngestPipelines } from './svl_ingest_pipelines';
import { SvlSearchHomePageProvider } from './svl_search_homepage';
import { SvlSearchIndexDetailPageProvider } from './svl_search_index_detail_page';
import { SvlSearchElasticsearchStartPageProvider } from './svl_search_elasticsearch_start_page';
export const pageObjects = {
...xpackFunctionalPageObjects,
@ -41,4 +42,5 @@ export const pageObjects = {
svlIngestPipelines: SvlIngestPipelines,
svlSearchHomePage: SvlSearchHomePageProvider,
svlSearchIndexDetailPage: SvlSearchIndexDetailPageProvider,
svlSearchElasticsearchStartPage: SvlSearchElasticsearchStartPageProvider,
};

View file

@ -0,0 +1,52 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export function SvlSearchElasticsearchStartPageProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const browser = getService('browser');
const retry = getService('retry');
return {
async expectToBeOnStartPage() {
expect(await browser.getCurrentUrl()).contain('/app/elasticsearch/start');
await testSubjects.existOrFail('elasticsearchStartPage', { timeout: 2000 });
},
async expectToBeOnIndexDetailsPage() {
await retry.tryForTime(60 * 1000, async () => {
expect(await browser.getCurrentUrl()).contain(
'/app/management/data/index_management/indices/index_details'
);
});
},
async expectIndexNameToExist() {
await testSubjects.existOrFail('indexNameField');
},
async setIndexNameValue(value: string) {
await testSubjects.existOrFail('indexNameField');
await testSubjects.setValue('indexNameField', value);
},
async expectCreateIndexButtonToExist() {
await testSubjects.existOrFail('createIndexBtn');
},
async expectCreateIndexButtonToBeEnabled() {
await testSubjects.existOrFail('createIndexBtn');
expect(await testSubjects.isEnabled('createIndexBtn')).equal(true);
},
async expectCreateIndexButtonToBeDisabled() {
await testSubjects.existOrFail('createIndexBtn');
expect(await testSubjects.isEnabled('createIndexBtn')).equal(false);
},
async clickCreateIndexButton() {
await testSubjects.existOrFail('createIndexBtn');
expect(await testSubjects.isEnabled('createIndexBtn')).equal(true);
await testSubjects.click('createIndexBtn');
},
};
}

View file

@ -22,5 +22,13 @@ export function SvlSearchNavigationServiceProvider({
await testSubjects.existOrFail('svlSearchOverviewPage', { timeout: 2000 });
});
},
async navigateToElasticsearchStartPage() {
await retry.tryForTime(60 * 1000, async () => {
await PageObjects.common.navigateToApp('elasticsearch/start', {
shouldLoginIfPrompted: false,
});
await testSubjects.existOrFail('elasticsearchStartPage', { timeout: 2000 });
});
},
};
}

View file

@ -7,6 +7,82 @@
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({}: FtrProviderContext) {
describe('Elasticsearch Start [Onboarding Empty State]', function () {});
import { testHasEmbeddedConsole } from './embedded_console';
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const pageObjects = getPageObjects([
'svlCommonPage',
'embeddedConsole',
'svlSearchElasticsearchStartPage',
]);
const svlSearchNavigation = getService('svlSearchNavigation');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const es = getService('es');
const deleteAllTestIndices = async () => {
await esDeleteAllIndices(['search-*', 'test-*']);
};
describe('Elasticsearch Start [Onboarding Empty State]', function () {
describe('developer', function () {
before(async () => {
await pageObjects.svlCommonPage.loginWithRole('developer');
});
after(async () => {
await deleteAllTestIndices();
});
beforeEach(async () => {
await deleteAllTestIndices();
await svlSearchNavigation.navigateToElasticsearchStartPage();
});
it('should have embedded dev console', async () => {
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage();
await testHasEmbeddedConsole(pageObjects);
});
it('should support index creation flow with UI', async () => {
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage();
await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexButtonToBeEnabled();
await pageObjects.svlSearchElasticsearchStartPage.clickCreateIndexButton();
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage();
});
it('should support setting index name', async () => {
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage();
await pageObjects.svlSearchElasticsearchStartPage.expectIndexNameToExist();
await pageObjects.svlSearchElasticsearchStartPage.setIndexNameValue('INVALID_INDEX');
await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexButtonToBeDisabled();
await pageObjects.svlSearchElasticsearchStartPage.setIndexNameValue('test-index-name');
await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexButtonToBeEnabled();
await pageObjects.svlSearchElasticsearchStartPage.clickCreateIndexButton();
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage();
});
it('should redirect to index details when index is created via API', async () => {
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage();
await es.indices.create({ index: 'test-my-index' });
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage();
});
});
describe('viewer', function () {
before(async () => {
await pageObjects.svlCommonPage.loginAsViewer();
await deleteAllTestIndices();
});
beforeEach(async () => {
await svlSearchNavigation.navigateToElasticsearchStartPage();
});
after(async () => {
await deleteAllTestIndices();
});
it('should redirect to index details when index is created via API', async () => {
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnStartPage();
await pageObjects.svlSearchElasticsearchStartPage.expectCreateIndexButtonToBeDisabled();
await es.indices.create({ index: 'test-my-api-index' });
await pageObjects.svlSearchElasticsearchStartPage.expectToBeOnIndexDetailsPage();
});
});
});
}