mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Interactive Setup UI enhancements (#113011)
This commit is contained in:
parent
4d8d7ee0ad
commit
94b2e30bd7
23 changed files with 1095 additions and 267 deletions
|
@ -7,3 +7,11 @@
|
|||
*/
|
||||
|
||||
export const VERIFICATION_CODE_LENGTH = 6;
|
||||
|
||||
export const ERROR_OUTSIDE_PREBOOT_STAGE = 'outside_preboot_stage';
|
||||
export const ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED = 'elasticsearch_connection_configured';
|
||||
export const ERROR_KIBANA_CONFIG_NOT_WRITABLE = 'kibana_config_not_writable';
|
||||
export const ERROR_KIBANA_CONFIG_FAILURE = 'kibana_config_failure';
|
||||
export const ERROR_ENROLL_FAILURE = 'enroll_failure';
|
||||
export const ERROR_CONFIGURE_FAILURE = 'configure_failure';
|
||||
export const ERROR_PING_FAILURE = 'ping_failure';
|
||||
|
|
|
@ -8,4 +8,13 @@
|
|||
|
||||
export type { InteractiveSetupViewState, EnrollmentToken, Certificate, PingResult } from './types';
|
||||
export { ElasticsearchConnectionStatus } from './elasticsearch_connection_status';
|
||||
export { VERIFICATION_CODE_LENGTH } from './constants';
|
||||
export {
|
||||
ERROR_CONFIGURE_FAILURE,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_ENROLL_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
ERROR_PING_FAILURE,
|
||||
VERIFICATION_CODE_LENGTH,
|
||||
} from './constants';
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('ClusterAddressForm', () => {
|
|||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<ClusterAddressForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
@ -52,7 +52,7 @@ describe('ClusterAddressForm', () => {
|
|||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<ClusterAddressForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
@ -63,7 +63,7 @@ describe('ClusterAddressForm', () => {
|
|||
|
||||
fireEvent.click(await findByRole('button', { name: 'Check address', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter a valid address including protocol/i);
|
||||
await findAllByText(/Enter a valid address/i);
|
||||
|
||||
expect(coreStart.http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -22,12 +21,12 @@ import React from 'react';
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import type { PingResult } from '../common';
|
||||
import { SubmitErrorCallout } from './submit_error_callout';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHttp } from './use_http';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export interface ClusterAddressFormValues {
|
||||
host: string;
|
||||
|
@ -46,7 +45,7 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
|
|||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
const { http } = useKibana();
|
||||
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
|
@ -65,7 +64,7 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
errors.host = i18n.translate('interactiveSetup.clusterAddressForm.hostInvalidError', {
|
||||
defaultMessage: 'Enter a valid address including protocol.',
|
||||
defaultMessage: "Enter a valid address, including 'http' or 'https'.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -88,14 +87,12 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
|
|||
<EuiForm component="form" noValidate {...eventHandlers}>
|
||||
{form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
|
||||
<SubmitErrorCallout
|
||||
error={form.submitError}
|
||||
defaultTitle={i18n.translate('interactiveSetup.clusterAddressForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't check address",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
@ -112,6 +109,7 @@ export const ClusterAddressForm: FunctionComponent<ClusterAddressFormProps> = ({
|
|||
name="host"
|
||||
value={form.values.host}
|
||||
isInvalid={form.touched.host && !!form.errors.host}
|
||||
placeholder="https://localhost:9200"
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
|
|
@ -21,14 +21,14 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
|
|||
describe('ClusterConfigurationForm', () => {
|
||||
jest.setTimeout(20_000);
|
||||
|
||||
it('calls enrollment API when submitting form', async () => {
|
||||
it('calls enrollment API for https addresses when submitting form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
coreStart.http.post.mockResolvedValue({});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<ClusterConfigurationForm
|
||||
host="https://localhost:9200"
|
||||
authRequired
|
||||
|
@ -53,7 +53,7 @@ describe('ClusterConfigurationForm', () => {
|
|||
target: { value: 'changeme' },
|
||||
});
|
||||
fireEvent.click(await findByLabelText('Certificate authority'));
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith(
|
||||
|
@ -71,12 +71,43 @@ describe('ClusterConfigurationForm', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('calls enrollment API for http addresses when submitting form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
coreStart.http.post.mockResolvedValue({});
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole } = render(
|
||||
<Providers services={coreStart}>
|
||||
<ClusterConfigurationForm
|
||||
host="http://localhost:9200"
|
||||
authRequired={false}
|
||||
certificateChain={[]}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</Providers>
|
||||
);
|
||||
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith(
|
||||
'/internal/interactive_setup/configure',
|
||||
{
|
||||
body: JSON.stringify({
|
||||
host: 'http://localhost:9200',
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates form', async () => {
|
||||
const coreStart = coreMock.createStart();
|
||||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<ClusterConfigurationForm
|
||||
host="https://localhost:9200"
|
||||
authRequired
|
||||
|
@ -95,7 +126,7 @@ describe('ClusterConfigurationForm', () => {
|
|||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter a password/i);
|
||||
await findAllByText(/Confirm that you recognize and trust this certificate/i);
|
||||
|
@ -104,7 +135,7 @@ describe('ClusterConfigurationForm', () => {
|
|||
target: { value: 'elastic' },
|
||||
});
|
||||
|
||||
await findAllByText(/User 'elastic' can't be used as Kibana system user/i);
|
||||
await findAllByText(/User 'elastic' can't be used as the Kibana system user/i);
|
||||
|
||||
expect(coreStart.http.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
|
@ -19,25 +20,32 @@ import {
|
|||
EuiFormRow,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiModal,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import useUpdateEffect from 'react-use/lib/useUpdateEffect';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme';
|
||||
|
||||
import type { Certificate } from '../common';
|
||||
import { DocLink } from './doc_link';
|
||||
import { SubmitErrorCallout } from './submit_error_callout';
|
||||
import { TextTruncate } from './text_truncate';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHtmlId } from './use_html_id';
|
||||
import { useHttp } from './use_http';
|
||||
import { useKibana } from './use_kibana';
|
||||
import { useVerification } from './use_verification';
|
||||
import { useVisibility } from './use_visibility';
|
||||
|
||||
|
@ -68,7 +76,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
const { http } = useKibana();
|
||||
const { status, getCode } = useVerification();
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
|
@ -87,7 +95,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
errors.username = i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.usernameReservedError',
|
||||
{
|
||||
defaultMessage: "User 'elastic' can't be used as Kibana system user.",
|
||||
defaultMessage: "User 'elastic' can't be used as the Kibana system user.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -102,7 +110,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
}
|
||||
}
|
||||
|
||||
if (certificateChain && !values.caCert) {
|
||||
if (certificateChain && certificateChain.length > 0 && !values.caCert) {
|
||||
errors.caCert = i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.caCertConfirmationRequiredError',
|
||||
{
|
||||
|
@ -117,9 +125,9 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
await http.post('/internal/interactive_setup/configure', {
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
caCert: values.caCert,
|
||||
username: authRequired ? values.username : undefined,
|
||||
password: authRequired ? values.password : undefined,
|
||||
caCert: certificateChain && certificateChain.length > 0 ? values.caCert : undefined,
|
||||
code: getCode(),
|
||||
}),
|
||||
});
|
||||
|
@ -139,17 +147,19 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
<EuiForm component="form" noValidate {...eventHandlers}>
|
||||
{status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.clusterConfigurationForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't connect to cluster",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
<SubmitErrorCallout
|
||||
error={form.submitError}
|
||||
defaultTitle={i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.submitErrorTitle',
|
||||
{
|
||||
defaultMessage: "Couldn't configure Elastic",
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false} className="eui-textNoWrap">
|
||||
<FormattedMessage
|
||||
|
@ -204,27 +214,27 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
<>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
title={i18n.translate(
|
||||
'interactiveSetup.clusterConfigurationForm.insecureClusterTitle',
|
||||
{
|
||||
defaultMessage: 'This cluster is not secure.',
|
||||
defaultMessage: 'This cluster is not secure',
|
||||
}
|
||||
)}
|
||||
size="s"
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
tagName="div"
|
||||
id="interactiveSetup.clusterConfigurationForm.insecureClusterDescription"
|
||||
defaultMessage="Anyone with the address can access your data."
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<DocLink app="elasticsearch" doc="configuring-stack-security.html">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.insecureClusterDescription"
|
||||
defaultMessage="Anyone with the address could access your data."
|
||||
id="interactiveSetup.clusterConfigurationForm.insecureClusterLink"
|
||||
defaultMessage="Learn how to enable security features."
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<EuiLink color="warning">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.insecureClusterLink"
|
||||
defaultMessage="Learn how to enable security features."
|
||||
/>
|
||||
</EuiLink>
|
||||
</p>
|
||||
</DocLink>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
|
@ -253,7 +263,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
form.setValue('caCert', form.values.caCert ? '' : intermediateCa.raw);
|
||||
}}
|
||||
>
|
||||
<CertificatePanel certificate={certificateChain[0]} />
|
||||
<CertificateChain certificateChain={certificateChain} />
|
||||
</EuiCheckableCard>
|
||||
</EuiFormRow>
|
||||
<EuiSpacer />
|
||||
|
@ -279,7 +289,7 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.clusterConfigurationForm.submitButton"
|
||||
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
|
||||
defaultMessage="{isSubmitting, select, true{Configuring Elastic…} other{Configure Elastic}}"
|
||||
values={{ isSubmitting: form.isSubmitting }}
|
||||
/>
|
||||
</EuiButton>
|
||||
|
@ -290,40 +300,169 @@ export const ClusterConfigurationForm: FunctionComponent<ClusterConfigurationFor
|
|||
};
|
||||
|
||||
export interface CertificatePanelProps {
|
||||
certificate: Certificate;
|
||||
certificate: Omit<Certificate, 'raw'>;
|
||||
compressed?: boolean;
|
||||
type?: 'root' | 'intermediate';
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export const CertificatePanel: FunctionComponent<CertificatePanelProps> = ({ certificate }) => {
|
||||
export const CertificatePanel: FunctionComponent<CertificatePanelProps> = ({
|
||||
certificate,
|
||||
onClick,
|
||||
type,
|
||||
compressed = false,
|
||||
}) => {
|
||||
return (
|
||||
<EuiPanel color="subdued">
|
||||
<EuiPanel color={compressed ? 'subdued' : undefined} hasBorder={!compressed}>
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="m">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="document" size="l" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h3>{certificate.subject.O || certificate.subject.CN}</h3>
|
||||
</EuiTitle>
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.issuer"
|
||||
defaultMessage="Issued by: {issuer}"
|
||||
values={{
|
||||
issuer: certificate.issuer.O || certificate.issuer.CN,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiFlexGroup responsive={false} gutterSize="none" justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="xxs">
|
||||
<h3>{certificate.subject.O || certificate.subject.CN}</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{!compressed && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge>
|
||||
{type === 'root'
|
||||
? i18n.translate('interactiveSetup.certificatePanel.rootCertificateAuthority', {
|
||||
defaultMessage: 'Root CA',
|
||||
})
|
||||
: type === 'intermediate'
|
||||
? i18n.translate(
|
||||
'interactiveSetup.certificatePanel.intermediateCertificateAuthority',
|
||||
{
|
||||
defaultMessage: 'Intermediate CA',
|
||||
}
|
||||
)
|
||||
: i18n.translate('interactiveSetup.certificatePanel.serverCertificate', {
|
||||
defaultMessage: 'Server certificate',
|
||||
})}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
{compressed && (
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.issuer"
|
||||
defaultMessage="Issued by: {issuer}"
|
||||
values={{
|
||||
issuer: onClick ? (
|
||||
<EuiLink onClick={onClick}>
|
||||
{certificate.issuer.O || certificate.issuer.CN}
|
||||
</EuiLink>
|
||||
) : (
|
||||
certificate.issuer.O || certificate.issuer.CN
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
)}
|
||||
{!compressed && (
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.validFrom"
|
||||
defaultMessage="Issued on: {validFrom}"
|
||||
values={{
|
||||
validFrom: certificate.valid_from,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
)}
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.validTo"
|
||||
defaultMessage="Expires: {validTo}"
|
||||
defaultMessage="Expires on: {validTo}"
|
||||
values={{
|
||||
validTo: certificate.valid_to,
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
{!compressed && (
|
||||
<EuiText size="xs">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificatePanel.fingerprint"
|
||||
defaultMessage="Fingerprint (SHA-256): {fingerprint}"
|
||||
values={{
|
||||
fingerprint: certificate.fingerprint256.replace(/\:/g, ' '),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export interface CertificateChainProps {
|
||||
certificateChain: Certificate[];
|
||||
}
|
||||
const CertificateChain: FunctionComponent<CertificateChainProps> = ({ certificateChain }) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CertificatePanel
|
||||
certificate={certificateChain[0]}
|
||||
onClick={() => setShowModal(true)}
|
||||
compressed
|
||||
/>
|
||||
{showModal && (
|
||||
<EuiModal onClose={() => setShowModal(false)} maxWidth={euiThemeVars.euiBreakpoints.s}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificateChain.title"
|
||||
defaultMessage="Certificate chain"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
{certificateChain
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(({ raw, ...certificate }, i) => (
|
||||
<>
|
||||
{i > 0 && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGroup responsive={false} justifyContent="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="sortDown" color="subdued" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
<CertificatePanel
|
||||
certificate={certificate}
|
||||
type={
|
||||
i === 0
|
||||
? 'root'
|
||||
: i < certificateChain.length - 1
|
||||
? 'intermediate'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
</EuiModalBody>
|
||||
<EuiModalFooter>
|
||||
<EuiButton fill onClick={() => setShowModal(false)}>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.certificateChain.cancelButton"
|
||||
defaultMessage="Close"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</EuiModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
62
src/plugins/interactive_setup/public/doc_link.tsx
Normal file
62
src/plugins/interactive_setup/public/doc_link.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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 type { EuiLinkAnchorProps } from '@elastic/eui';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import type { CoreStart } from 'src/core/public';
|
||||
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export type DocLinks = CoreStart['docLinks']['links'];
|
||||
export type GetDocLinkFunction = (app: string, doc: string) => string;
|
||||
|
||||
/**
|
||||
* Creates links to the documentation.
|
||||
*
|
||||
* @see {@link DocLink} for a component that creates a link to the docs.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* <DocLink app="elasticsearch" doc="built-in-roles.html">
|
||||
* Learn what privileges individual roles grant.
|
||||
* </DocLink>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const [docs] = useDocLinks();
|
||||
*
|
||||
* <EuiLink href={docs.dashboard.guide} target="_blank" external>
|
||||
* Learn how to get started with dashboards.
|
||||
* </EuiLink>
|
||||
* ```
|
||||
*/
|
||||
export function useDocLinks(): [DocLinks, GetDocLinkFunction] {
|
||||
const { docLinks } = useKibana();
|
||||
const { links, ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks;
|
||||
const getDocLink = useCallback<GetDocLinkFunction>(
|
||||
(app, doc) => {
|
||||
return `${ELASTIC_WEBSITE_URL}guide/en/${app}/reference/${DOC_LINK_VERSION}/${doc}`;
|
||||
},
|
||||
[ELASTIC_WEBSITE_URL, DOC_LINK_VERSION]
|
||||
);
|
||||
return [links, getDocLink];
|
||||
}
|
||||
|
||||
export interface DocLinkProps extends Omit<EuiLinkAnchorProps, 'href'> {
|
||||
app: string;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
export const DocLink: FunctionComponent<DocLinkProps> = ({ app, doc, ...props }) => {
|
||||
const [, getDocLink] = useDocLinks();
|
||||
return <EuiLink href={getDocLink(app, doc)} target="_blank" external {...props} />;
|
||||
};
|
|
@ -36,14 +36,14 @@ describe('EnrollmentTokenForm', () => {
|
|||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<EnrollmentTokenForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
fireEvent.change(await findByLabelText('Enrollment token'), {
|
||||
target: { value: btoa(JSON.stringify(token)) },
|
||||
});
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/interactive_setup/enroll', {
|
||||
|
@ -62,12 +62,12 @@ describe('EnrollmentTokenForm', () => {
|
|||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<EnrollmentTokenForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Connect to cluster', hidden: true }));
|
||||
fireEvent.click(await findByRole('button', { name: 'Configure Elastic', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter an enrollment token/i);
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiForm,
|
||||
|
@ -24,13 +23,14 @@ import useUpdateEffect from 'react-use/lib/useUpdateEffect';
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import type { EnrollmentToken } from '../common';
|
||||
import { DocLink } from './doc_link';
|
||||
import { SubmitErrorCallout } from './submit_error_callout';
|
||||
import { TextTruncate } from './text_truncate';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHttp } from './use_http';
|
||||
import { useKibana } from './use_kibana';
|
||||
import { useVerification } from './use_verification';
|
||||
import { useVisibility } from './use_visibility';
|
||||
|
||||
|
@ -51,7 +51,7 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
|
|||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
const { http } = useKibana();
|
||||
const { status, getCode } = useVerification();
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
|
@ -100,14 +100,12 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
|
|||
<EuiForm component="form" noValidate {...eventHandlers}>
|
||||
{status !== 'unverified' && !form.isSubmitting && !form.isValidating && form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.enrollmentTokenForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't connect to cluster",
|
||||
<SubmitErrorCallout
|
||||
error={form.submitError}
|
||||
defaultTitle={i18n.translate('interactiveSetup.enrollmentTokenForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't configure Elastic",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
@ -118,7 +116,18 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
|
|||
})}
|
||||
error={form.errors.token}
|
||||
isInvalid={form.touched.token && !!form.errors.token}
|
||||
helpText={enrollmentToken && <EnrollmentTokenDetails token={enrollmentToken} />}
|
||||
helpText={
|
||||
enrollmentToken ? (
|
||||
<EnrollmentTokenDetails token={enrollmentToken} />
|
||||
) : (
|
||||
<DocLink app="elasticsearch" doc="configuring-stack-security.html">
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.enrollmentTokenForm.tokenHelpText"
|
||||
defaultMessage="Where do I find this?"
|
||||
/>
|
||||
</DocLink>
|
||||
)
|
||||
}
|
||||
fullWidth
|
||||
>
|
||||
<EuiTextArea
|
||||
|
@ -126,7 +135,7 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
|
|||
value={form.values.token}
|
||||
isInvalid={form.touched.token && !!form.errors.token}
|
||||
placeholder={i18n.translate('interactiveSetup.enrollmentTokenForm.tokenPlaceholder', {
|
||||
defaultMessage: 'Paste enrollment token from terminal',
|
||||
defaultMessage: 'Paste enrollment token from terminal.',
|
||||
})}
|
||||
fullWidth
|
||||
/>
|
||||
|
@ -152,7 +161,7 @@ export const EnrollmentTokenForm: FunctionComponent<EnrollmentTokenFormProps> =
|
|||
>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.enrollmentTokenForm.submitButton"
|
||||
defaultMessage="{isSubmitting, select, true{Connecting to cluster…} other{Connect to cluster}}"
|
||||
defaultMessage="{isSubmitting, select, true{Configuring Elastic…} other{Configure Elastic}}"
|
||||
values={{ isSubmitting: form.isSubmitting }}
|
||||
/>
|
||||
</EuiButton>
|
||||
|
|
|
@ -11,10 +11,10 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import type { CoreSetup, CoreStart, HttpSetup, Plugin } from 'src/core/public';
|
||||
import type { CoreSetup, CoreStart, Plugin } from 'src/core/public';
|
||||
|
||||
import { App } from './app';
|
||||
import { HttpProvider } from './use_http';
|
||||
import { KibanaProvider } from './use_kibana';
|
||||
import { VerificationProvider } from './use_verification';
|
||||
|
||||
export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
|
||||
|
@ -24,16 +24,17 @@ export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
|
|||
title: 'Configure Elastic to get started',
|
||||
appRoute: '/',
|
||||
chromeless: true,
|
||||
mount: (params) => {
|
||||
mount: async (params) => {
|
||||
const url = new URL(window.location.href);
|
||||
const defaultCode = url.searchParams.get('code') || undefined;
|
||||
const onSuccess = () => {
|
||||
url.searchParams.delete('code');
|
||||
window.location.replace(url.href);
|
||||
};
|
||||
const [services] = await core.getStartServices();
|
||||
|
||||
ReactDOM.render(
|
||||
<Providers defaultCode={defaultCode} http={core.http}>
|
||||
<Providers defaultCode={defaultCode} services={services}>
|
||||
<App onSuccess={onSuccess} />
|
||||
</Providers>,
|
||||
params.element
|
||||
|
@ -47,14 +48,18 @@ export class InteractiveSetupPlugin implements Plugin<void, void, {}, {}> {
|
|||
}
|
||||
|
||||
export interface ProvidersProps {
|
||||
http: HttpSetup;
|
||||
services: CoreStart;
|
||||
defaultCode?: string;
|
||||
}
|
||||
|
||||
export const Providers: FunctionComponent<ProvidersProps> = ({ defaultCode, http, children }) => (
|
||||
export const Providers: FunctionComponent<ProvidersProps> = ({
|
||||
defaultCode,
|
||||
services,
|
||||
children,
|
||||
}) => (
|
||||
<I18nProvider>
|
||||
<HttpProvider http={http}>
|
||||
<KibanaProvider services={services}>
|
||||
<VerificationProvider defaultCode={defaultCode}>{children}</VerificationProvider>
|
||||
</HttpProvider>
|
||||
</KibanaProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
|
|
@ -9,20 +9,21 @@
|
|||
import type { EuiStepProps } from '@elastic/eui';
|
||||
import { EuiPanel, EuiSteps } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import useTimeoutFn from 'react-use/lib/useTimeoutFn';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import { useHttp } from './use_http';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export interface ProgressIndicatorProps {
|
||||
onSuccess?(): void;
|
||||
}
|
||||
|
||||
export const ProgressIndicator: FunctionComponent<ProgressIndicatorProps> = ({ onSuccess }) => {
|
||||
const http = useHttp();
|
||||
const { http } = useKibana();
|
||||
const [status, checkStatus] = useAsyncFn(async () => {
|
||||
let isAvailable: boolean | undefined = false;
|
||||
let isPastPreboot: boolean | undefined = false;
|
||||
|
@ -30,7 +31,8 @@ export const ProgressIndicator: FunctionComponent<ProgressIndicatorProps> = ({ o
|
|||
const { response } = await http.get('/api/status', { asResponse: true });
|
||||
isAvailable = response ? response.status < 500 : undefined;
|
||||
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
|
||||
} catch ({ response }) {
|
||||
} catch (error) {
|
||||
const { response } = error as IHttpFetchError;
|
||||
isAvailable = response ? response.status < 500 : undefined;
|
||||
isPastPreboot = response?.headers.get('content-type')?.includes('application/json');
|
||||
}
|
||||
|
@ -91,16 +93,20 @@ export interface LoadingStepsProps {
|
|||
}
|
||||
|
||||
export const LoadingSteps: FunctionComponent<LoadingStepsProps> = ({ currentStepId, steps }) => {
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const currentStepIndex = steps.findIndex((step) => step.id === currentStepId);
|
||||
|
||||
// Ensure that loading progress doesn't move backwards
|
||||
useEffect(() => {
|
||||
if (currentStepIndex > stepIndex) {
|
||||
setStepIndex(currentStepIndex);
|
||||
}
|
||||
}, [currentStepIndex, stepIndex]);
|
||||
|
||||
return (
|
||||
<EuiSteps
|
||||
steps={steps.map((step, i) => ({
|
||||
status:
|
||||
i <= currentStepIndex
|
||||
? 'complete'
|
||||
: steps[i - 1]?.id === currentStepId
|
||||
? 'loading'
|
||||
: 'incomplete',
|
||||
status: i <= stepIndex ? 'complete' : i - 1 === stepIndex ? 'loading' : 'incomplete',
|
||||
children: null,
|
||||
...step,
|
||||
}))}
|
||||
|
|
|
@ -0,0 +1,389 @@
|
|||
/*
|
||||
* 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 { errors } from '@elastic/elasticsearch';
|
||||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
ERROR_CONFIGURE_FAILURE,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_ENROLL_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
ERROR_PING_FAILURE,
|
||||
} from '../common';
|
||||
import { interactiveSetupMock } from '../server/mocks'; // eslint-disable-line @kbn/eslint/no-restricted-paths
|
||||
import { SubmitErrorCallout } from './submit_error_callout';
|
||||
|
||||
describe('SubmitErrorCallout', () => {
|
||||
it('renders unknown errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout error={new Error('Unknown error')} defaultTitle="Something went wrong" />
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title="Something went wrong"
|
||||
>
|
||||
Unknown error
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders 403 errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 403,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Verification required"
|
||||
id="interactiveSetup.submitErrorCallout.forbiddenErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Retry to configure Elastic."
|
||||
id="interactiveSetup.submitErrorCallout.forbiddenErrorDescription"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders 404 errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 404,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Elastic is already configured"
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Continue to Kibana"
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_CONFIGURE_FAILURE errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_CONFIGURE_FAILURE },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title="Something went wrong"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Retry or update the {config} file manually."
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigFailureErrorDescription"
|
||||
values={
|
||||
Object {
|
||||
"config": <strong>
|
||||
kibana.yml
|
||||
</strong>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Elastic is already configured"
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Continue to Kibana"
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_ENROLL_FAILURE errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_ENROLL_FAILURE },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title="Something went wrong"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Generate a new enrollment token or configure manually."
|
||||
id="interactiveSetup.submitErrorCallout.EnrollFailureErrorDescription"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_KIBANA_CONFIG_FAILURE errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Couldn't write to config file"
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Retry or update the {config} file manually."
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigFailureErrorDescription"
|
||||
values={
|
||||
Object {
|
||||
"config": <strong>
|
||||
kibana.yml
|
||||
</strong>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_KIBANA_CONFIG_NOT_WRITABLE errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Couldn't write to config file"
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Check the file permissions and ensure {config} is writable by the Kibana process."
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorDescription"
|
||||
values={
|
||||
Object {
|
||||
"config": <strong>
|
||||
kibana.yml
|
||||
</strong>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_OUTSIDE_PREBOOT_STAGE errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Elastic is already configured"
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Continue to Kibana"
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders ERROR_PING_FAILURE errors correctly', async () => {
|
||||
const wrapper = shallow(
|
||||
<SubmitErrorCallout
|
||||
error={
|
||||
new errors.ResponseError(
|
||||
interactiveSetupMock.createApiResponse({
|
||||
body: {
|
||||
statusCode: 500,
|
||||
attributes: { type: ERROR_PING_FAILURE },
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
defaultTitle="Something went wrong"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={
|
||||
<FormattedMessage
|
||||
defaultMessage="Couldn't connect to cluster"
|
||||
id="interactiveSetup.submitErrorCallout.pingFailureErrorTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Check the address and retry."
|
||||
id="interactiveSetup.submitErrorCallout.pingFailureErrorDescription"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
`);
|
||||
});
|
||||
});
|
127
src/plugins/interactive_setup/public/submit_error_callout.tsx
Normal file
127
src/plugins/interactive_setup/public/submit_error_callout.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* 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 { EuiButton, EuiCallOut } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { IHttpFetchError } from 'kibana/public';
|
||||
|
||||
import {
|
||||
ERROR_CONFIGURE_FAILURE,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_ENROLL_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
ERROR_PING_FAILURE,
|
||||
} from '../common';
|
||||
|
||||
export interface SubmitErrorCalloutProps {
|
||||
error: Error;
|
||||
defaultTitle: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SubmitErrorCallout: FunctionComponent<SubmitErrorCalloutProps> = (props) => {
|
||||
const error = props.error as IHttpFetchError;
|
||||
|
||||
if (
|
||||
error.body?.statusCode === 404 ||
|
||||
error.body?.attributes?.type === ERROR_OUTSIDE_PREBOOT_STAGE ||
|
||||
error.body?.attributes?.type === ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED
|
||||
) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
color="primary"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredErrorTitle"
|
||||
defaultMessage="Elastic is already configured"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiButton
|
||||
onClick={() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.location.replace(url.href);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.elasticsearchConnectionConfiguredSubmitButton"
|
||||
defaultMessage="Continue to Kibana"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={
|
||||
error.body?.statusCode === 403 ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.forbiddenErrorTitle"
|
||||
defaultMessage="Verification required"
|
||||
/>
|
||||
) : error.body?.attributes?.type === ERROR_KIBANA_CONFIG_NOT_WRITABLE ||
|
||||
error.body?.attributes?.type === ERROR_KIBANA_CONFIG_FAILURE ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorTitle"
|
||||
defaultMessage="Couldn't write to config file"
|
||||
/>
|
||||
) : error.body?.attributes?.type === ERROR_PING_FAILURE ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.pingFailureErrorTitle"
|
||||
defaultMessage="Couldn't connect to cluster"
|
||||
/>
|
||||
) : (
|
||||
props.defaultTitle
|
||||
)
|
||||
}
|
||||
>
|
||||
{error.body?.statusCode === 403 ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.forbiddenErrorDescription"
|
||||
defaultMessage="Retry to configure Elastic."
|
||||
/>
|
||||
) : error.body?.attributes?.type === ERROR_KIBANA_CONFIG_NOT_WRITABLE ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigNotWritableErrorDescription"
|
||||
defaultMessage="Check the file permissions and ensure {config} is writable by the Kibana process."
|
||||
values={{
|
||||
config: <strong>kibana.yml</strong>,
|
||||
}}
|
||||
/>
|
||||
) : error.body?.attributes?.type === ERROR_KIBANA_CONFIG_FAILURE ||
|
||||
error.body?.attributes?.type === ERROR_CONFIGURE_FAILURE ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.kibanaConfigFailureErrorDescription"
|
||||
defaultMessage="Retry or update the {config} file manually."
|
||||
values={{
|
||||
config: <strong>kibana.yml</strong>,
|
||||
}}
|
||||
/>
|
||||
) : error.body?.attributes?.type === ERROR_ENROLL_FAILURE ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.EnrollFailureErrorDescription"
|
||||
defaultMessage="Generate a new enrollment token or configure manually."
|
||||
/>
|
||||
) : error.body?.attributes?.type === ERROR_PING_FAILURE ? (
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.submitErrorCallout.pingFailureErrorDescription"
|
||||
defaultMessage="Check the address and retry."
|
||||
/>
|
||||
) : (
|
||||
error.body?.message || error.message
|
||||
)}
|
||||
</EuiCallOut>
|
||||
);
|
||||
};
|
|
@ -8,8 +8,8 @@
|
|||
|
||||
import constate from 'constate';
|
||||
|
||||
import type { HttpSetup } from 'src/core/public';
|
||||
import type { CoreStart } from 'src/core/public';
|
||||
|
||||
export const [HttpProvider, useHttp] = constate(({ http }: { http: HttpSetup }) => {
|
||||
return http;
|
||||
export const [KibanaProvider, useKibana] = constate(({ services }: { services: CoreStart }) => {
|
||||
return services;
|
||||
});
|
|
@ -11,7 +11,9 @@ import constate from 'constate';
|
|||
import type { FunctionComponent } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useHttp } from './use_http';
|
||||
import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme';
|
||||
|
||||
import { useKibana } from './use_kibana';
|
||||
import { VerificationCodeForm } from './verification_code_form';
|
||||
|
||||
export interface VerificationProps {
|
||||
|
@ -37,7 +39,7 @@ const [OuterVerificationProvider, useVerification] = constate(
|
|||
);
|
||||
|
||||
const InnerVerificationProvider: FunctionComponent = ({ children }) => {
|
||||
const http = useHttp();
|
||||
const { http } = useKibana();
|
||||
const { status, setStatus, setCode } = useVerification();
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -54,7 +56,7 @@ const InnerVerificationProvider: FunctionComponent = ({ children }) => {
|
|||
return (
|
||||
<>
|
||||
{status === 'unverified' && (
|
||||
<EuiModal onClose={() => setStatus('unknown')}>
|
||||
<EuiModal onClose={() => setStatus('unknown')} maxWidth={euiThemeVars.euiBreakpoints.s}>
|
||||
<EuiModalHeader>
|
||||
<VerificationCodeForm
|
||||
onSuccess={(values) => {
|
||||
|
|
|
@ -28,7 +28,7 @@ describe('VerificationCodeForm', () => {
|
|||
const onSuccess = jest.fn();
|
||||
|
||||
const { findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<VerificationCodeForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
@ -65,14 +65,14 @@ describe('VerificationCodeForm', () => {
|
|||
const onSuccess = jest.fn();
|
||||
|
||||
const { findAllByText, findByRole, findByLabelText } = render(
|
||||
<Providers http={coreStart.http}>
|
||||
<Providers services={coreStart}>
|
||||
<VerificationCodeForm onSuccess={onSuccess} />
|
||||
</Providers>
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole('button', { name: 'Verify', hidden: true }));
|
||||
|
||||
await findAllByText(/Enter a verification code/i);
|
||||
await findAllByText(/Enter the verification code from the Kibana server/i);
|
||||
|
||||
fireEvent.input(await findByLabelText('Digit 1'), {
|
||||
target: { value: '1' },
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiCode,
|
||||
EuiEmptyPrompt,
|
||||
EuiForm,
|
||||
|
@ -25,9 +24,10 @@ import type { IHttpFetchError } from 'kibana/public';
|
|||
|
||||
import { VERIFICATION_CODE_LENGTH } from '../common';
|
||||
import { SingleCharsField } from './single_chars_field';
|
||||
import { SubmitErrorCallout } from './submit_error_callout';
|
||||
import type { ValidationErrors } from './use_form';
|
||||
import { useForm } from './use_form';
|
||||
import { useHttp } from './use_http';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export interface VerificationCodeFormValues {
|
||||
code: string;
|
||||
|
@ -44,7 +44,7 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
|
|||
},
|
||||
onSuccess,
|
||||
}) => {
|
||||
const http = useHttp();
|
||||
const { http } = useKibana();
|
||||
const [form, eventHandlers] = useForm({
|
||||
defaultValues,
|
||||
validate: async (values) => {
|
||||
|
@ -52,7 +52,7 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
|
|||
|
||||
if (!values.code) {
|
||||
errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeRequiredError', {
|
||||
defaultMessage: 'Enter a verification code.',
|
||||
defaultMessage: 'Enter the verification code from the Kibana server.',
|
||||
});
|
||||
} else if (values.code.length !== VERIFICATION_CODE_LENGTH) {
|
||||
errors.code = i18n.translate('interactiveSetup.verificationCodeForm.codeMinLengthError', {
|
||||
|
@ -97,24 +97,32 @@ export const VerificationCodeForm: FunctionComponent<VerificationCodeFormProps>
|
|||
<>
|
||||
{form.submitError && (
|
||||
<>
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
title={i18n.translate('interactiveSetup.verificationCodeForm.submitErrorTitle', {
|
||||
defaultMessage: "Couldn't verify code",
|
||||
})}
|
||||
>
|
||||
{(form.submitError as IHttpFetchError).body?.message}
|
||||
</EuiCallOut>
|
||||
<SubmitErrorCallout
|
||||
error={form.submitError}
|
||||
defaultTitle={i18n.translate(
|
||||
'interactiveSetup.verificationCodeForm.submitErrorTitle',
|
||||
{
|
||||
defaultMessage: "Couldn't verify code",
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiText>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="interactiveSetup.verificationCodeForm.codeDescription"
|
||||
defaultMessage="Copy the code from the Kibana server or run {command} to retrieve it."
|
||||
values={{
|
||||
command: <EuiCode lang="bash">./bin/kibana-verification-code</EuiCode>,
|
||||
command: (
|
||||
<EuiCode language="bash">
|
||||
{window.navigator.userAgent.includes('Win')
|
||||
? 'bin\\kibana-verification-code.bat'
|
||||
: 'bin/kibana-verification-code'}
|
||||
</EuiCode>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
|
|
@ -13,7 +13,14 @@ import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from
|
|||
import { kibanaResponseFactory } from 'src/core/server';
|
||||
import { httpServerMock } from 'src/core/server/mocks';
|
||||
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import {
|
||||
ElasticsearchConnectionStatus,
|
||||
ERROR_CONFIGURE_FAILURE,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
} from '../../common';
|
||||
import { interactiveSetupMock } from '../mocks';
|
||||
import { defineConfigureRoute } from './configure';
|
||||
import { routeDefinitionParamsMock } from './index.mock';
|
||||
|
@ -135,11 +142,17 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: { body: 'Cannot process request outside of preboot stage.' },
|
||||
payload: 'Cannot process request outside of preboot stage.',
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 400,
|
||||
payload: {
|
||||
attributes: {
|
||||
type: ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
},
|
||||
message: 'Cannot process request outside of preboot stage.',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -156,19 +169,15 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: {
|
||||
body: {
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 400,
|
||||
payload: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -186,20 +195,15 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: {
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
|
||||
},
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -225,14 +229,15 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: {
|
||||
message: 'Failed to configure.',
|
||||
attributes: { type: ERROR_CONFIGURE_FAILURE },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -253,20 +258,15 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host1' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: {
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
|
||||
},
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
|
||||
|
@ -285,11 +285,12 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host', username: 'username', password: 'password', caCert: 'der' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 204,
|
||||
options: {},
|
||||
payload: undefined,
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 204,
|
||||
payload: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.elasticsearch.authenticate).toHaveBeenCalledWith({
|
||||
|
|
|
@ -11,7 +11,14 @@ import { first } from 'rxjs/operators';
|
|||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '.';
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import {
|
||||
ElasticsearchConnectionStatus,
|
||||
ERROR_CONFIGURE_FAILURE,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
} from '../../common';
|
||||
import type { AuthenticateParameters } from '../elasticsearch_service';
|
||||
import { ElasticsearchService } from '../elasticsearch_service';
|
||||
import type { WriteConfigParameters } from '../kibana_config_writer';
|
||||
|
@ -66,7 +73,12 @@ export function defineConfigureRoute({
|
|||
|
||||
if (!preboot.isSetupOnHold()) {
|
||||
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
|
||||
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
|
||||
return response.badRequest({
|
||||
body: {
|
||||
message: 'Cannot process request outside of preboot stage.',
|
||||
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
|
||||
|
@ -77,7 +89,7 @@ export function defineConfigureRoute({
|
|||
return response.badRequest({
|
||||
body: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -93,7 +105,7 @@ export function defineConfigureRoute({
|
|||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -114,7 +126,7 @@ export function defineConfigureRoute({
|
|||
// request or we just couldn't connect to any of the provided hosts.
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Failed to configure.', attributes: { type: 'configure_failure' } },
|
||||
body: { message: 'Failed to configure.', attributes: { type: ERROR_CONFIGURE_FAILURE } },
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -126,7 +138,7 @@ export function defineConfigureRoute({
|
|||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -13,7 +13,14 @@ import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from
|
|||
import { kibanaResponseFactory } from 'src/core/server';
|
||||
import { httpServerMock } from 'src/core/server/mocks';
|
||||
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import {
|
||||
ElasticsearchConnectionStatus,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_ENROLL_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
} from '../../common';
|
||||
import { interactiveSetupMock } from '../mocks';
|
||||
import { defineEnrollRoutes } from './enroll';
|
||||
import { routeDefinitionParamsMock } from './index.mock';
|
||||
|
@ -153,11 +160,17 @@ describe('Enroll routes', () => {
|
|||
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: { body: 'Cannot process request outside of preboot stage.' },
|
||||
payload: 'Cannot process request outside of preboot stage.',
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 400,
|
||||
payload: {
|
||||
attributes: {
|
||||
type: ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
},
|
||||
message: 'Cannot process request outside of preboot stage.',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -174,19 +187,15 @@ describe('Enroll routes', () => {
|
|||
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: {
|
||||
body: {
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 400,
|
||||
payload: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -204,20 +213,15 @@ describe('Enroll routes', () => {
|
|||
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: {
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
|
||||
},
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -243,14 +247,12 @@ describe('Enroll routes', () => {
|
|||
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: { message: 'Failed to enroll.', attributes: { type: ERROR_ENROLL_FAILURE } },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled();
|
||||
|
@ -276,20 +278,15 @@ describe('Enroll routes', () => {
|
|||
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: {
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
|
||||
},
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1);
|
||||
|
@ -313,11 +310,12 @@ describe('Enroll routes', () => {
|
|||
body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'deadbeef' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 204,
|
||||
options: {},
|
||||
payload: undefined,
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 204,
|
||||
payload: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({
|
||||
|
|
|
@ -10,7 +10,14 @@ import { first } from 'rxjs/operators';
|
|||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { ElasticsearchConnectionStatus } from '../../common';
|
||||
import {
|
||||
ElasticsearchConnectionStatus,
|
||||
ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED,
|
||||
ERROR_ENROLL_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_FAILURE,
|
||||
ERROR_KIBANA_CONFIG_NOT_WRITABLE,
|
||||
ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
} from '../../common';
|
||||
import type { EnrollResult } from '../elasticsearch_service';
|
||||
import type { WriteConfigParameters } from '../kibana_config_writer';
|
||||
import type { RouteDefinitionParams } from './';
|
||||
|
@ -48,7 +55,12 @@ export function defineEnrollRoutes({
|
|||
|
||||
if (!preboot.isSetupOnHold()) {
|
||||
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
|
||||
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
|
||||
return response.badRequest({
|
||||
body: {
|
||||
message: 'Cannot process request outside of preboot stage.',
|
||||
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise();
|
||||
|
@ -59,7 +71,7 @@ export function defineEnrollRoutes({
|
|||
return response.badRequest({
|
||||
body: {
|
||||
message: 'Elasticsearch connection is already configured.',
|
||||
attributes: { type: 'elasticsearch_connection_configured' },
|
||||
attributes: { type: ERROR_ELASTICSEARCH_CONNECTION_CONFIGURED },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -75,7 +87,7 @@ export function defineEnrollRoutes({
|
|||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Kibana process does not have enough permissions to write to config file.',
|
||||
attributes: { type: 'kibana_config_not_writable' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_NOT_WRITABLE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -100,7 +112,7 @@ export function defineEnrollRoutes({
|
|||
// request or we just couldn't connect to any of the provided hosts.
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } },
|
||||
body: { message: 'Failed to enroll.', attributes: { type: ERROR_ENROLL_FAILURE } },
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -112,7 +124,7 @@ export function defineEnrollRoutes({
|
|||
statusCode: 500,
|
||||
body: {
|
||||
message: 'Failed to save configuration.',
|
||||
attributes: { type: 'kibana_config_failure' },
|
||||
attributes: { type: ERROR_KIBANA_CONFIG_FAILURE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from
|
|||
import { kibanaResponseFactory } from 'src/core/server';
|
||||
import { httpServerMock } from 'src/core/server/mocks';
|
||||
|
||||
import { ERROR_OUTSIDE_PREBOOT_STAGE, ERROR_PING_FAILURE } from '../../common';
|
||||
import { interactiveSetupMock } from '../mocks';
|
||||
import { routeDefinitionParamsMock } from './index.mock';
|
||||
import { definePingRoute } from './ping';
|
||||
|
@ -66,11 +67,17 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 400,
|
||||
options: { body: 'Cannot process request outside of preboot stage.' },
|
||||
payload: 'Cannot process request outside of preboot stage.',
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 400,
|
||||
payload: {
|
||||
attributes: {
|
||||
type: ERROR_OUTSIDE_PREBOOT_STAGE,
|
||||
},
|
||||
message: 'Cannot process request outside of preboot stage.',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.authenticate).not.toHaveBeenCalled();
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
|
@ -91,14 +98,12 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 500,
|
||||
options: {
|
||||
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
|
||||
statusCode: 500,
|
||||
},
|
||||
payload: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
payload: { message: 'Failed to ping cluster.', attributes: { type: ERROR_PING_FAILURE } },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled();
|
||||
|
@ -111,11 +116,12 @@ describe('Configure routes', () => {
|
|||
body: { host: 'host' },
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({
|
||||
status: 200,
|
||||
options: {},
|
||||
payload: undefined,
|
||||
});
|
||||
await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 200,
|
||||
payload: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouteParams.elasticsearch.ping).toHaveBeenCalledWith('host');
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { RouteDefinitionParams } from '.';
|
||||
import { ERROR_OUTSIDE_PREBOOT_STAGE, ERROR_PING_FAILURE } from '../../common';
|
||||
import type { PingResult } from '../../common/types';
|
||||
|
||||
export function definePingRoute({ router, logger, elasticsearch, preboot }: RouteDefinitionParams) {
|
||||
|
@ -25,7 +26,12 @@ export function definePingRoute({ router, logger, elasticsearch, preboot }: Rout
|
|||
async (context, request, response) => {
|
||||
if (!preboot.isSetupOnHold()) {
|
||||
logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`);
|
||||
return response.badRequest({ body: 'Cannot process request outside of preboot stage.' });
|
||||
return response.badRequest({
|
||||
body: {
|
||||
message: 'Cannot process request outside of preboot stage.',
|
||||
attributes: { type: ERROR_OUTSIDE_PREBOOT_STAGE },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let result: PingResult;
|
||||
|
@ -34,7 +40,7 @@ export function definePingRoute({ router, logger, elasticsearch, preboot }: Rout
|
|||
} catch {
|
||||
return response.customError({
|
||||
statusCode: 500,
|
||||
body: { message: 'Failed to ping cluster.', attributes: { type: 'ping_failure' } },
|
||||
body: { message: 'Failed to ping cluster.', attributes: { type: ERROR_PING_FAILURE } },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue