Clean up cloud_chat (#194571)

## Summary

Close https://github.com/elastic/kibana-team/issues/1017

This PR removes the unused Cloud Chat functionality from Kibana. The
chat was not used for some time. Moreover, we've seen some issues with
it where users saw it when it wasn't expected. Given the absence of
automated tests and the fact that the feature is no longer needed, we
are removing it to improve the overall maintainability and reliability
of the codebase. This will also decrease the amount of code loaded for
trial users of Kibana in cloud making the app slightly faster.
This commit is contained in:
Anton Dosov 2024-10-03 13:37:47 +02:00 committed by GitHub
parent 22e36117c4
commit 568e40acca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 41 additions and 2044 deletions

View file

@ -18,7 +18,6 @@ const STORYBOOKS = [
'canvas',
'cases',
'cell_actions',
'cloud_chat',
'coloring',
'chart_icons',
'content_management_examples',

View file

@ -487,7 +487,8 @@ The plugin exposes the static DefaultEditorController class to consume.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_chat/README.md[cloudChat]
|Integrates with DriftChat in order to provide live support to our Elastic Cloud users. This plugin should only run on Elastic Cloud.
|The plugin was meant to integrate with DriftChat in order to provide live support to our Elastic Cloud users.
It was removed, but the plugin was left behind to register no longer used config keys.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_data_migration/README.md[cloudDataMigration]

View file

@ -11,7 +11,6 @@ pageLoadAssetSize:
cases: 180037
charts: 55000
cloud: 21076
cloudChat: 19894
cloudDataMigration: 19170
cloudDefend: 18697
cloudExperiments: 109746

View file

@ -16,7 +16,6 @@ export const storybookAliases = {
canvas: 'x-pack/plugins/canvas/storybook',
cases: 'packages/kbn-cases-components/.storybook',
cell_actions: 'packages/kbn-cell-actions/.storybook',
cloud_chat: 'x-pack/plugins/cloud_integrations/cloud_chat/.storybook',
cloud: 'packages/cloud/.storybook',
coloring: 'packages/kbn-coloring/.storybook',
language_documentation_popover: 'packages/kbn-language-documentation/.storybook',

View file

@ -59,8 +59,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
// We want to test when the banner is shown
'--telemetry.banner=true',
// explicitly enable the cloud integration plugins to validate the rendered config keys
'--xpack.cloud_integrations.chat.enabled=true',
'--xpack.cloud_integrations.chat.chatURL=a_string',
'--xpack.cloud_integrations.experiments.enabled=true',
'--xpack.cloud_integrations.experiments.launch_darkly.sdk_key=a_string',
'--xpack.cloud_integrations.experiments.launch_darkly.client_id=a_string',

View file

@ -233,8 +233,6 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cloud.deployments_url (string?)',
'xpack.cloud.is_elastic_staff_owned (boolean?)',
'xpack.cloud.trial_end_date (string?)',
'xpack.cloud_integrations.chat.chatURL (string?)',
'xpack.cloud_integrations.chat.trialBuffer (number?)',
// Commented because it's inside a schema conditional, and the test is not able to resolve it. But it's shared.
// Added here for documentation purposes.
// 'xpack.cloud_integrations.experiments.launch_darkly.client_id (string)',

View file

@ -18,7 +18,6 @@
"xpack.canvas": "plugins/canvas",
"xpack.cases": "plugins/cases",
"xpack.cloud": "plugins/cloud",
"xpack.cloudChat": "plugins/cloud_integrations/cloud_chat",
"xpack.cloudDefend": "plugins/cloud_defend",
"xpack.cloudLinks": "plugins/cloud_integrations/cloud_links",
"xpack.cloudDataMigration": "plugins/cloud_integrations/cloud_data_migration",

View file

@ -1,38 +0,0 @@
/*
* 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, { FC, PropsWithChildren } from 'react';
import { DecoratorFn } from '@storybook/react';
import { ServicesProvider, CloudChatServices } from '../public/services';
// TODO: move to a storybook implementation of the service using parameters.
const services: CloudChatServices = {
chat: {
chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
chatVariant: 'bubble',
user: {
id: 'user-id',
email: 'test-user@elastic.co',
// this doesn't affect chat appearance,
// but a user identity in Drift only
jwt: 'identity-jwt',
trialEndDate: new Date(),
kbnVersion: '8.9.0',
kbnBuildNum: 12345,
},
},
};
export const getCloudContextProvider: () => FC<PropsWithChildren<unknown>> =
() =>
({ children }) =>
<ServicesProvider {...services}>{children}</ServicesProvider>;
export const getCloudContextDecorator: DecoratorFn = (storyFn) => {
const CloudContextProvider = getCloudContextProvider();
return <CloudContextProvider>{storyFn()}</CloudContextProvider>;
};

View file

@ -1,8 +0,0 @@
/*
* 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 { getCloudContextDecorator, getCloudContextProvider } from './decorator';

View file

@ -1,10 +0,0 @@
/*
* 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 { defaultConfig } from '@kbn/storybook';
module.exports = defaultConfig;

View file

@ -1,20 +0,0 @@
/*
* 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 { addons } from '@storybook/addons';
import { create } from '@storybook/theming';
import { PANEL_ID } from '@storybook/addon-actions';
addons.setConfig({
theme: create({
base: 'light',
brandTitle: 'Cloud Storybook',
brandUrl: 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud',
}),
showPanel: true.valueOf,
selectedPanel: PANEL_ID,
});

View file

@ -1,11 +0,0 @@
/*
* 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 { addDecorator } from '@storybook/react';
import { getCloudContextDecorator } from './decorator';
addDecorator(getCloudContextDecorator);

View file

@ -1,3 +1,4 @@
# Cloud Chat
# Cloud Chat - Deprecated / Removed
Integrates with DriftChat in order to provide live support to our Elastic Cloud users. This plugin should only run on Elastic Cloud.
The plugin was meant to integrate with DriftChat in order to provide live support to our Elastic Cloud users.
It was removed, but the plugin was left behind to register no longer used config keys.

View file

@ -1,9 +0,0 @@
/*
* 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_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';
export const DEFAULT_TRIAL_BUFFER = 90;

View file

@ -1,15 +0,0 @@
/*
* 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 type ChatVariant = 'header' | 'bubble';
export interface GetChatUserDataResponseBody {
token: string;
email: string;
id: string;
chatVariant: ChatVariant;
}

View file

@ -1,19 +0,0 @@
/*
* 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.
*/
/**
* Returns true if today's date is within the an end date + buffer, false otherwise.
*
* @param endDate The end date of the trial.
* @param buffer The number of days to add to the end date.
* @returns true if today's date is within the an end date + buffer, false otherwise.
*/
export const isTodayInDateWindow = (endDate: Date, buffer: number) => {
const endDateWithBuffer = new Date(endDate);
endDateWithBuffer.setDate(endDateWithBuffer.getDate() + buffer);
return endDateWithBuffer > new Date();
};

View file

@ -1,18 +0,0 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../',
roots: ['<rootDir>/x-pack/plugins/cloud_integrations/cloud_chat'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_chat',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/cloud_integrations/cloud_chat/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -6,7 +6,7 @@
"plugin": {
"id": "cloudChat",
"server": true,
"browser": true,
"browser": false,
"configPath": [
"xpack",
"cloud_integrations",

View file

@ -1,70 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { WhenIdle } from './when_idle';
import { useChatConfig, ChatApi } from './use_chat_config';
export type { ChatApi } from './use_chat_config';
export interface Props {
/** Handler invoked when the chat widget signals it is ready. */
onReady?: (chatApi: ChatApi) => void;
/** Handler invoked when the chat widget signals to be resized. */
onResize?: () => void;
/** Handler invoked when the playbook is fired. */
onPlaybookFired?: () => void;
/** The offset from the top of the page to the chat widget. */
topOffset?: number;
}
/**
* A component that will display a trigger that will allow the user to chat with a human operator,
* when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = ({ onReady, onResize, onPlaybookFired, topOffset = 0 }: Props) => {
const config = useChatConfig({
onReady,
onResize,
onPlaybookFired,
});
if (!config.enabled) {
return null;
}
return (
<WhenIdle>
<iframe
data-test-subj="cloud-chat-frame"
title={i18n.translate('xpack.cloudChat.chatFrameTitle', {
defaultMessage: 'Chat',
})}
src={config.src}
ref={config.ref}
style={
config.isReady
? {
position: 'fixed',
...config.style,
// reset default button positioning
bottom: 'auto',
inset: 'initial',
// force position to the top and of the page
top: topOffset,
right: 0,
// TODO: if the page height is smaller than widget height + topOffset,
// the widget will be cut off from the bottom.
// @ts-ignore - fixes white background on iframe in chrome/system dark mode
colorScheme: 'light',
}
: { display: 'none' }
}
/>
</WhenIdle>
);
};

View file

@ -1,65 +0,0 @@
/*
* 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 { getChatContext } from './get_chat_context';
const PROTOCOL = 'http:';
const PORT = '1234';
const HASH = '#/discover?_g=()&_a=()';
const HOST_NAME = 'www.kibana.com';
const PATH_NAME = '/app/kibana';
const HOST = `${HOST_NAME}:${PORT}`;
const ORIGIN = `${PROTOCOL}//${HOST}`;
const HREF = `${ORIGIN}${PATH_NAME}${HASH}`;
const USER_AGENT = 'user-agent';
const LANGUAGE = 'la-ng';
const TITLE = 'title';
const REFERRER = 'referrer';
describe('getChatContext', () => {
const url = new URL(HREF);
test('retrieve the context', () => {
Object.defineProperty(window, 'location', { value: url });
Object.defineProperty(window, 'navigator', {
value: {
language: LANGUAGE,
userAgent: USER_AGENT,
},
});
Object.defineProperty(window.document, 'referrer', { value: REFERRER });
window.document.title = TITLE;
const context = getChatContext();
expect(context).toStrictEqual({
window: {
location: {
hash: HASH,
host: HOST,
hostname: HOST_NAME,
href: HREF,
origin: ORIGIN,
pathname: PATH_NAME,
port: PORT,
protocol: PROTOCOL,
search: '',
},
navigator: {
language: LANGUAGE,
userAgent: USER_AGENT,
},
innerHeight: 768,
innerWidth: 1024,
},
document: {
title: TITLE,
referrer: REFERRER,
},
});
});
});

View file

@ -1,36 +0,0 @@
/*
* 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 getChatContext = () => {
const { location, navigator, innerHeight, innerWidth } = window;
const { hash, host, hostname, href, origin, pathname, port, protocol, search } = location;
const { language, userAgent } = navigator;
const { title, referrer } = document;
return {
window: {
location: {
hash,
host,
hostname,
href,
origin,
pathname,
port,
protocol,
search,
},
navigator: { language, userAgent },
innerHeight,
innerWidth,
},
document: {
title,
referrer,
},
};
};

View file

@ -1,28 +0,0 @@
/*
* 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, { Suspense } from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
import type { Props } from './chat';
export type { ChatApi, Props } from './chat';
/**
* A suspense-compatible version of the Chat component.
*/
export const LazyChat = React.lazy(() => import('./chat').then(({ Chat }) => ({ default: Chat })));
/**
* A lazily-loaded component that will display a trigger that will allow the user to chat with a
* human operator when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = (props: Props) => (
<EuiErrorBoundary>
<Suspense fallback={<></>}>
<LazyChat {...props} />
</Suspense>
</EuiErrorBoundary>
);

View file

@ -1,213 +0,0 @@
/*
* 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, useRef, useState, CSSProperties } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { useChat } from '../../services';
import { getChatContext } from './get_chat_context';
import { Props as ChatProps } from './chat';
type UseChatType =
| { enabled: false }
| {
enabled: true;
src: string;
ref: React.MutableRefObject<HTMLIFrameElement | null>;
style: CSSProperties;
isReady: boolean;
isResized: boolean;
};
export interface ChatApi {
show: () => void;
hide: () => void;
toggle: () => void;
}
const MESSAGE_WIDGET_READY = 'driftWidgetReady';
const MESSAGE_IFRAME_READY = 'driftIframeReady';
const MESSAGE_RESIZE = 'driftIframeResize';
const MESSAGE_SET_CONTEXT = 'driftSetContext';
const MESSAGE_CHAT_CLOSED = 'driftChatClosed';
const MESSAGE_PLAYBOOK_FIRED = 'driftPlaybookFired';
type ChatConfigParams = Exclude<ChatProps, 'onHide'> & {
/** if the chat visibility is controlled from the outside */
controlled?: boolean;
};
/**
* Hook which handles positioning and communication with the chat widget.
*/
export const useChatConfig = ({
onReady = () => {},
onResize = () => {},
onPlaybookFired = () => {},
controlled = true,
}: ChatConfigParams): UseChatType => {
const ref = useRef<HTMLIFrameElement>(null);
const chat = useChat();
const [style, setStyle] = useState<CSSProperties>({ height: 0, width: 0 });
const [isReady, setIsReady] = useState(false);
const [isResized, setIsResized] = useState(false);
const isChatOpenRef = useRef<boolean>(false);
const [hasPlaybookFiredOnce, setPlaybookFiredOnce] = useLocalStorage(
'cloudChatPlaybookFiredOnce',
false
);
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
const { current: chatIframe } = ref;
if (!chat || !chatIframe?.contentWindow || event.source !== chatIframe?.contentWindow) {
return;
}
const context = getChatContext();
const { data: message } = event;
const { user: userConfig, chatVariant } = chat;
const { id, email, jwt, trialEndDate, kbnVersion, kbnBuildNum } = userConfig;
const chatApi: ChatApi = {
show: () => {
ref.current?.contentWindow?.postMessage(
{
type: `driftShow`,
},
'*'
);
ref.current?.contentWindow?.postMessage(
{
type: `driftOpenChat`,
},
'*'
);
isChatOpenRef.current = true;
},
hide: () => {
ref.current?.contentWindow?.postMessage(
{
type: `driftHide`,
},
'*'
);
isChatOpenRef.current = false;
},
toggle: () => {
if (isChatOpenRef.current) {
chatApi.hide();
} else {
chatApi.show();
}
},
};
switch (message.type) {
// The IFRAME is ready to receive messages.
case MESSAGE_IFRAME_READY: {
const user = {
id,
attributes: {
email,
trial_end_date: trialEndDate,
kbn_version: kbnVersion,
kbn_build_num: kbnBuildNum,
kbn_chat_variant: chatVariant,
},
jwt,
};
chatIframe.contentWindow.postMessage(
{
type: MESSAGE_SET_CONTEXT,
data: { context, user },
},
'*'
);
break;
}
// Drift is attempting to resize the IFRAME based on interactions with
// its interface.
case MESSAGE_RESIZE: {
const styles = message.data.styles || ({} as CSSProperties);
// camelize to avoid style warnings from react
const camelize = (s: string) => s.replace(/-./g, (x) => x[1].toUpperCase());
const camelStyles = Object.keys(styles).reduce((acc, key) => {
acc[camelize(key)] = styles[key];
return acc;
}, {} as Record<string, string>) as CSSProperties;
setStyle({ ...style, ...camelStyles });
if (!isResized) {
setIsResized(true);
}
onResize();
break;
}
// The chat widget is ready.
case MESSAGE_WIDGET_READY:
if (controlled) chatApi.hide();
setIsReady(true);
onReady(chatApi);
if (hasPlaybookFiredOnce) {
// The `MESSAGE_PLAYBOOK_FIRED` event is only fired until the interaction,
// so we need to manually trigger the callback if the event has already fired.
// otherwise, users might have an ongoing conversion, but they can't get back to it
onPlaybookFired();
}
break;
case MESSAGE_CHAT_CLOSED:
if (controlled) chatApi.hide();
break;
case MESSAGE_PLAYBOOK_FIRED:
onPlaybookFired();
setPlaybookFiredOnce(true);
break;
default:
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [
chat,
style,
onReady,
onResize,
isReady,
isResized,
controlled,
hasPlaybookFiredOnce,
onPlaybookFired,
setPlaybookFiredOnce,
]);
if (chat) {
return {
enabled: true,
src: chat.chatURL,
ref,
style,
isReady,
isResized,
};
}
return { enabled: false };
};

View file

@ -1,34 +0,0 @@
/*
* 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, { FC, PropsWithChildren } from 'react';
export function whenIdle(doWork: () => void) {
const requestIdleCallback = window.requestIdleCallback || window.setTimeout;
if (document.readyState === 'complete') {
requestIdleCallback(() => doWork());
} else {
window.addEventListener('load', () => {
requestIdleCallback(() => doWork());
});
}
}
/**
* Postpone rendering of children until the page is loaded and browser is idle.
*/
export const WhenIdle: FC<PropsWithChildren<unknown>> = ({ children }) => {
const [idleFired, setIdleFired] = React.useState(false);
React.useEffect(() => {
whenIdle(() => {
setIdleFired(true);
});
}, []);
return idleFired ? <>{children}</> : null;
};

View file

@ -1,40 +0,0 @@
/*
* 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 { Observable } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import type { ChatVariant } from '../../../common/types';
import { ChatHeaderMenuItem } from '../chat_header_menu_item';
import { Chat as ChatFloatingBubble } from '../chat_floating_bubble';
const BUBBLE_ALLOWED_LOCATIONS = [
'app/home#/getting_started',
'app/enterprise_search/overview',
'app/observability/overview',
'app/security/get_started',
'app/integrations',
];
export function ChatExperimentSwitcher(props: {
location$: Observable<string>;
variant: ChatVariant;
}) {
const location = useObservable(props.location$);
if (!location) return null;
if (props.variant === 'bubble') {
if (BUBBLE_ALLOWED_LOCATIONS.some((loc) => location.includes(loc))) {
return <ChatFloatingBubble />;
} else {
return null;
}
} else {
return <ChatHeaderMenuItem />;
}
}

View file

@ -1,123 +0,0 @@
/*
* 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, { useState } from 'react';
import {
EuiButton,
EuiGlobalToastList,
EuiGlobalToastListProps,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { forceReRender } from '@storybook/react';
import { Chat } from './chat_floating_bubble';
import { ServicesProvider } from '../../services';
export default {
title: 'Chat Widget',
description:
'A Chat widget, enabled in Cloud, that allows a person to talk to Elastic about their deployment',
};
const Toaster = () => {
const [toasts, setToasts] = useState<EuiGlobalToastListProps['toasts']>([]);
return (
<>
<EuiButton
onClick={() =>
setToasts([
{
id: 'toast',
title: 'Download complete!',
color: 'success',
text: <p>Thanks for your patience!</p>,
},
])
}
>
Show Toast
</EuiButton>
<EuiGlobalToastList
toasts={toasts}
toastLifeTimeMs={3000}
dismissToast={() => {
setToasts([]);
}}
/>
</>
);
};
interface Params {
id: string;
email: string;
chatURL: string;
jwt: string;
trialEndDate: string;
kbnVersion: string;
kbnBuildNum: number;
}
export const Component = ({
id,
email,
chatURL,
jwt,
kbnVersion,
trialEndDate,
kbnBuildNum,
}: Params) => {
const [isHidden, setIsHidden] = useState(false);
return (
<ServicesProvider
chat={{
chatURL,
chatVariant: 'bubble',
user: {
jwt,
id,
email,
trialEndDate: new Date(trialEndDate),
kbnVersion,
kbnBuildNum,
},
}}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<Toaster />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => {
setIsHidden(false);
forceReRender();
}}
disabled={!isHidden}
>
Reset
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isHidden ? null : <Chat onHide={() => setIsHidden(true)} />}
</ServicesProvider>
);
};
Component.args = {
id: '1234567890',
email: 'email.address@elastic.co',
chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
jwt: 'abcdefghijklmnopqrstuvwxyz',
trialEndDate: new Date().toISOString(),
kbnVersion: '8.9.0',
kbnBuildNum: 12345,
};

View file

@ -1,101 +0,0 @@
/*
* 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, { useRef, useState } from 'react';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiButtonEmpty } from '@elastic/eui';
import { useChatConfig, ChatApi } from '../chat/use_chat_config';
export type { ChatApi } from '../chat/use_chat_config';
export interface Props {
/** Handler invoked when chat is hidden by someone. */
onHide?: () => void;
/** Handler invoked when the chat widget signals it is ready. */
onReady?: (chatApi: ChatApi) => void;
/** Handler invoked when the chat widget signals to be resized. */
onResize?: () => void;
/** Handler invoked when the playbook is fired. */
onPlaybookFired?: () => void;
}
/**
* This Chat widget implementation uses the default floating chat bubble in the bottom right corner of the screen.
* It automatically appears if the playbook fires and isn't controlled from the outside
*/
export const Chat = ({ onHide = () => {}, onReady, onResize, onPlaybookFired }: Props) => {
const config = useChatConfig({
onReady,
onResize,
onPlaybookFired,
controlled: false /* makes this chat appear automatically */,
});
const ref = useRef<HTMLDivElement>(null);
const [isClosed, setIsClosed] = useState(false);
if (!config.enabled || isClosed) {
return null;
}
const { isReady, isResized, style } = config;
const { right } = style;
const buttonCSS = css`
bottom: ${euiThemeVars.euiSizeXS};
position: fixed;
right: calc(${right} + ${euiThemeVars.euiSizeXS});
visibility: ${isReady && isResized ? 'visible' : 'hidden'};
`;
const button = (
<EuiButtonEmpty
css={buttonCSS}
data-test-subj="cloud-chat-hide"
name="cloudChatHide"
onClick={() => {
onHide();
setIsClosed(true);
}}
size="xs"
>
{i18n.translate('xpack.cloudChat.hideChatButtonLabel', {
defaultMessage: 'Hide chat',
})}
</EuiButtonEmpty>
);
const containerCSS = css`
bottom: ${euiThemeVars.euiSizeXL};
position: fixed;
right: ${euiThemeVars.euiSizeXL};
z-index: ${euiThemeVars.euiZMaskBelowHeader - 1};
&:focus [name='cloudChatHide'],
&:hover [name='cloudChatHide'] {
visibility: visible;
}
`;
return (
<div css={containerCSS} ref={ref} data-test-subj="cloud-chat">
{button}
<iframe
data-test-subj="cloud-chat-frame"
title={i18n.translate('xpack.cloudChat.chatFrameTitle', {
defaultMessage: 'Chat',
})}
src={config.src}
ref={config.ref}
style={{
...config.style,
// @ts-ignore - fixes white background on iframe in chrome/system dark mode
colorScheme: 'light',
}}
/>
</div>
);
};

View file

@ -1,30 +0,0 @@
/*
* 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, { Suspense } from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
import type { Props } from './chat_floating_bubble';
export type { ChatApi, Props } from './chat_floating_bubble';
/**
* A suspense-compatible version of the Chat component.
*/
export const LazyChat = React.lazy(() =>
import('./chat_floating_bubble').then(({ Chat }) => ({ default: Chat }))
);
/**
* A lazily-loaded component that will display a trigger that will allow the user to chat with a
* human operator when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = (props: Props) => (
<EuiErrorBoundary>
<Suspense fallback={<></>}>
<LazyChat {...props} />
</Suspense>
</EuiErrorBoundary>
);

View file

@ -1,109 +0,0 @@
/*
* 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 {
EuiButtonEmpty,
useEuiTheme,
useIsWithinMinBreakpoint,
EuiTourStep,
EuiText,
EuiIcon,
} from '@elastic/eui';
import ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import chatIconDark from './chat_icon_dark.svg';
import chatIconLight from './chat_icon_light.svg';
import { Chat, ChatApi } from '../chat';
export function ChatHeaderMenuItem() {
const [showChatButton, setChatButtonShow] = React.useState(false);
const [chatApi, setChatApi] = React.useState<ChatApi | null>(null);
const [showTour, setShowTour] = useLocalStorage('cloudChatTour', true);
const { euiTheme, colorMode } = useEuiTheme();
const ref = React.useRef<HTMLButtonElement>(null);
// chat top offset is used to properly position the chat widget
// it can't be static because of an edge case with banners
const [chatTopOffset, setChatTopOffset] = React.useState(0);
React.useEffect(() => {
const chatButton = ref.current;
if (!chatButton) return;
const chatButtonClientRect = chatButton.getBoundingClientRect();
setChatTopOffset(chatButtonClientRect.top + chatButtonClientRect.height - 8);
}, [showChatButton]);
const isLargeScreen = useIsWithinMinBreakpoint('m');
if (!isLargeScreen) return null;
return (
<>
{showChatButton && (
<EuiTourStep
title={
<>
<EuiIcon
type={colorMode === 'DARK' ? chatIconLight : chatIconDark}
size={'l'}
css={{ marginRight: euiTheme.size.s }}
/>
{i18n.translate('xpack.cloudChat.chatTourHeaderText', {
defaultMessage: 'Live Chat Now',
})}
</>
}
content={
<EuiText size={'s'}>
<p>
{i18n.translate('xpack.cloudChat.chatTourText', {
defaultMessage:
'Open chat for assistance with topics such as ingesting data, configuring your instance, and troubleshooting.',
})}
</p>
</EuiText>
}
isStepOpen={showTour}
onFinish={() => setShowTour(false)}
minWidth={300}
maxWidth={360}
step={1}
stepsTotal={1}
anchorPosition="downRight"
>
<EuiButtonEmpty
buttonRef={ref}
css={{ color: euiTheme.colors.ghost, marginRight: euiTheme.size.m }}
size="s"
iconType={chatIconLight}
data-test-subj="cloud-chat"
onClick={() => {
if (showTour) setShowTour(false);
chatApi?.toggle();
}}
>
{i18n.translate('xpack.cloudChat.chatButtonLabel', {
defaultMessage: 'Live Chat',
})}
</EuiButtonEmpty>
</EuiTourStep>
)}
{ReactDOM.createPortal(
<Chat
onReady={(_chatApi) => {
setChatApi(_chatApi);
}}
onPlaybookFired={() => {
setChatButtonShow(true);
}}
topOffset={chatTopOffset}
/>,
document.body
)}
</>
);
}

View file

@ -1,5 +0,0 @@
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.96672 8.8637C5.29876 9.6512 6.05226 10.2009 6.92862 10.2009H10.6021L13.3572 13V10.2009C14.5407 10.2009 15.5001 9.1984 15.5001 7.9617V4.60283C15.5001 3.36613 14.5407 2.36359 13.3572 2.36359H12.2858V6.69704C12.2858 7.89366 11.3903 8.8637 10.2858 8.8637H4.96672Z" fill="black"/>
<path d="M4.90517 6.71774H9.07143C9.66316 6.71774 10.1429 6.21646 10.1429 5.59811V2.23925C10.1429 1.62089 9.66316 1.11962 9.07143 1.11962H2.64286C2.05112 1.11962 1.57143 1.62089 1.57143 2.23925V5.59811C1.57143 6.21646 2.05112 6.71774 2.64286 6.71774H3.71429V8.1696L4.90517 6.71774ZM5.39796 7.83736L2.64286 10.6364V7.83736C1.45939 7.83736 0.5 6.83481 0.5 5.59811V2.23925C0.5 1.00254 1.45939 0 2.64286 0H9.07143C10.2549 0 11.2143 1.00254 11.2143 2.23925V5.59811C11.2143 6.83481 10.2549 7.83736 9.07143 7.83736H5.39796Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 929 B

View file

@ -1,5 +0,0 @@
<svg width="16" height="13" viewBox="0 0 16 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.96672 8.8637C5.29876 9.6512 6.05226 10.2009 6.92862 10.2009H10.6021L13.3572 13V10.2009C14.5407 10.2009 15.5001 9.1984 15.5001 7.9617V4.60283C15.5001 3.36613 14.5407 2.36359 13.3572 2.36359H12.2858V6.69704C12.2858 7.89366 11.3903 8.8637 10.2858 8.8637H4.96672Z" fill="white"/>
<path d="M4.90517 6.71774H9.07143C9.66316 6.71774 10.1429 6.21646 10.1429 5.59811V2.23925C10.1429 1.62089 9.66316 1.11962 9.07143 1.11962H2.64286C2.05112 1.11962 1.57143 1.62089 1.57143 2.23925V5.59811C1.57143 6.21646 2.05112 6.71774 2.64286 6.71774H3.71429V8.1696L4.90517 6.71774ZM5.39796 7.83736L2.64286 10.6364V7.83736C1.45939 7.83736 0.5 6.83481 0.5 5.59811V2.23925C0.5 1.00254 1.45939 0 2.64286 0H9.07143C10.2549 0 11.2143 1.00254 11.2143 2.23925V5.59811C11.2143 6.83481 10.2549 7.83736 9.07143 7.83736H5.39796Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 929 B

View file

@ -1,8 +0,0 @@
/*
* 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 { ChatHeaderMenuItem } from './chat_header_menu_items';

View file

@ -1,9 +0,0 @@
/*
* 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 { Chat, type ChatApi, type Props as ChatProps } from './chat';
export { ChatHeaderMenuItem } from './chat_header_menu_item';

View file

@ -1,13 +0,0 @@
/*
* 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 { PluginInitializerContext } from '@kbn/core/public';
import { CloudChatPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudChatPlugin(initializerContext);
}

View file

@ -1,103 +0,0 @@
/*
* 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 { coreMock } from '@kbn/core/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import type { CloudChatConfigType } from '../server/config';
import { CloudChatPlugin } from './plugin';
import { type MockedLogger } from '@kbn/logging-mocks';
describe('Cloud Chat Plugin', () => {
describe('#setup', () => {
describe('setupChat', () => {
let newTrialEndDate: Date;
let logger: MockedLogger;
beforeEach(() => {
newTrialEndDate = new Date();
newTrialEndDate.setDate(new Date().getDate() + 14);
});
const setupPlugin = async ({
config = {},
isCloudEnabled = true,
failHttp = false,
trialEndDate = newTrialEndDate,
}: {
config?: Partial<CloudChatConfigType>;
isCloudEnabled?: boolean;
failHttp?: boolean;
trialEndDate?: Date;
}) => {
const initContext = coreMock.createPluginInitializerContext(config);
logger = initContext.logger as MockedLogger;
const plugin = new CloudChatPlugin(initContext);
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
if (failHttp) {
coreSetup.http.get.mockImplementation(async () => {
throw new Error('HTTP request failed');
});
}
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
const cloud = cloudMock.createSetup();
plugin.setup(coreSetup, {
cloud: { ...cloud, isCloudEnabled, trialEndDate },
});
// Wait for the async processes to complete
await new Promise((resolve) => process.nextTick(resolve));
return { initContext, plugin, coreSetup };
};
it('chatConfig is not retrieved if cloud is not enabled', async () => {
const { coreSetup } = await setupPlugin({ isCloudEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if chat is enabled but url is not provided', async () => {
// @ts-expect-error 2741
const { coreSetup } = await setupPlugin({ config: { chat: { enabled: true } } });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if internal API fails', async () => {
const { coreSetup } = await setupPlugin({
config: { chatURL: 'http://chat.elastic.co', trialBuffer: 30 },
failHttp: true,
});
expect(coreSetup.http.get).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining(`Error setting up Chat`));
});
it('chatConfig is not retrieved if chat is enabled and url is provided but trial has expired', async () => {
const date = new Date();
date.setDate(new Date().getDate() - 44);
const { coreSetup } = await setupPlugin({
config: { chatURL: 'http://chat.elastic.co', trialBuffer: 30 },
trialEndDate: date,
});
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is retrieved if chat is enabled and url is provided and trial is active', async () => {
const { coreSetup } = await setupPlugin({
config: { chatURL: 'http://chat.elastic.co', trialBuffer: 30 },
trialEndDate: new Date(),
});
expect(coreSetup.http.get).toHaveBeenCalled();
});
});
});
});

View file

@ -1,139 +0,0 @@
/*
* 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, { type FC, type PropsWithChildren } from 'react';
import ReactDOM from 'react-dom';
import useObservable from 'react-use/lib/useObservable';
import { ReplaySubject, first } from 'rxjs';
import type { Logger } from '@kbn/logging';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { HttpSetup } from '@kbn/core-http-browser';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { ChatVariant, GetChatUserDataResponseBody } from '../common/types';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../common/constants';
import { ChatConfig, ServicesProvider } from './services';
import { isTodayInDateWindow } from '../common/util';
import { ChatExperimentSwitcher } from './components/chat_experiment_switcher';
interface CloudChatSetupDeps {
cloud: CloudSetup;
}
interface CloudChatStartDeps {
cloud: CloudStart;
}
interface SetupChatDeps extends CloudChatSetupDeps {
http: HttpSetup;
}
interface CloudChatConfig {
chatURL?: string;
trialBuffer: number;
}
export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps, CloudChatStartDeps> {
private readonly config: CloudChatConfig;
private readonly logger: Logger;
private chatConfig$ = new ReplaySubject<ChatConfig>(1);
private kbnVersion: string;
private kbnBuildNum: number;
constructor(initializerContext: PluginInitializerContext<CloudChatConfig>) {
this.kbnVersion = initializerContext.env.packageInfo.version;
this.kbnBuildNum = initializerContext.env.packageInfo.buildNum;
this.config = initializerContext.config.get();
this.logger = initializerContext.logger.get();
}
public setup(core: CoreSetup, { cloud }: CloudChatSetupDeps) {
this.setupChat({ http: core.http, cloud }).catch((e) =>
this.logger.debug(`Error setting up Chat: ${e.toString()}`)
);
}
public start(core: CoreStart) {
const CloudChatContextProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
// There's a risk that the request for chat config will take too much time to complete, and the provider
// will maintain a stale value. To avoid this, we'll use an Observable.
const chatConfig = useObservable(this.chatConfig$, undefined);
return <ServicesProvider chat={chatConfig}>{children}</ServicesProvider>;
};
function ConnectedChat(props: { chatVariant: ChatVariant }) {
return (
<CloudChatContextProvider>
<KibanaRenderContextProvider {...core}>
<ChatExperimentSwitcher
location$={core.application.currentLocation$}
variant={props.chatVariant}
/>
</KibanaRenderContextProvider>
</CloudChatContextProvider>
);
}
this.chatConfig$.pipe(first((config) => config != null)).subscribe((config) => {
core.chrome.navControls.registerExtension({
order: 50,
mount: (e) => {
ReactDOM.render(<ConnectedChat chatVariant={config.chatVariant} />, e);
return () => {
ReactDOM.unmountComponentAtNode(e);
};
},
});
});
}
public stop() {}
private async setupChat({ cloud, http }: SetupChatDeps) {
const { isCloudEnabled, trialEndDate } = cloud;
const { chatURL, trialBuffer } = this.config;
if (
!isCloudEnabled ||
!chatURL ||
!trialEndDate ||
!isTodayInDateWindow(trialEndDate, trialBuffer)
) {
return;
}
try {
const {
email,
id,
token: jwt,
chatVariant,
} = await http.get<GetChatUserDataResponseBody>(GET_CHAT_USER_DATA_ROUTE_PATH);
if (!email || !id || !jwt) {
return;
}
this.chatConfig$.next({
chatURL,
chatVariant,
user: {
email,
id,
jwt,
trialEndDate: trialEndDate!,
kbnVersion: this.kbnVersion,
kbnBuildNum: this.kbnBuildNum,
},
});
} catch (e) {
this.logger.debug(
`[cloud.chat] Could not retrieve chat config: ${e.response.status} ${e.message}`,
e
);
}
}
}

View file

@ -1,45 +0,0 @@
/*
* 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, { FC, createContext, useContext, PropsWithChildren } from 'react';
import type { ChatVariant } from '../../common/types';
export interface ChatConfig {
chatURL: string;
chatVariant: ChatVariant;
user: {
jwt: string;
id: string;
email: string;
trialEndDate: Date;
kbnVersion: string;
kbnBuildNum: number;
};
}
export interface CloudChatServices {
chat?: ChatConfig;
}
const ServicesContext = createContext<CloudChatServices>({});
export const ServicesProvider: FC<PropsWithChildren<CloudChatServices>> = ({
children,
...services
}) => <ServicesContext.Provider value={services}>{children}</ServicesContext.Provider>;
/**
* React hook for accessing the pre-wired `CloudChatServices`.
*/
export function useServices() {
return useContext(ServicesContext);
}
export function useChat(): ChatConfig | undefined {
const { chat } = useServices();
return chat;
}

View file

@ -5,74 +5,31 @@
* 2.0.
*/
import { get, has } from 'lodash';
import { schema, TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core/server';
import { DEFAULT_TRIAL_BUFFER } from '../common/constants';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
chatURL: schema.maybe(schema.string()),
chatIdentitySecret: schema.maybe(schema.string()),
trialBuffer: schema.number({ defaultValue: DEFAULT_TRIAL_BUFFER }),
});
export type CloudChatConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudChatConfigType> = {
exposeToBrowser: {
chatURL: true,
trialBuffer: true,
},
export const config: PluginConfigDescriptor = {
schema: configSchema,
deprecations: () => [
// Silently move the chat configuration from `xpack.cloud` to `xpack.cloud_integrations.chat`.
// No need to emit a deprecation log because it's an internal restructure
(cfg) => {
return {
set: [
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.chat.enabled',
toKey: 'xpack.cloud_integrations.chat.enabled',
}),
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.chat.chatURL',
toKey: 'xpack.cloud_integrations.chat.chatURL',
}),
...copyIfExists({
cfg,
fromKey: 'xpack.cloud.chatIdentitySecret',
toKey: 'xpack.cloud_integrations.chat.chatIdentitySecret',
}),
],
unset: [
{ path: 'xpack.cloud.chat.enabled' },
{ path: 'xpack.cloud.chat.chatURL' },
{ path: 'xpack.cloud.chatIdentitySecret' },
],
};
},
deprecations: ({ unusedFromRoot }) => [
// Deprecate the old chat configuration keys
unusedFromRoot('xpack.cloud.chat.enabled', { silent: true, level: 'warning' }),
unusedFromRoot('xpack.cloud.chat.chatURL', { silent: true, level: 'warning' }),
unusedFromRoot('xpack.cloud.chatIdentitySecret', { silent: true, level: 'warning' }),
// Deprecate the latest chat configuration keys
unusedFromRoot('xpack.cloud_integrations.chat.enabled', { silent: true, level: 'warning' }),
unusedFromRoot('xpack.cloud_integrations.chat.chatURL', { silent: true, level: 'warning' }),
unusedFromRoot('xpack.cloud_integrations.chat.chatIdentitySecret', {
silent: true,
level: 'warning',
}),
unusedFromRoot('xpack.cloud_integrations.chat.trialBuffer', {
silent: true,
level: 'warning',
}),
],
};
/**
* Defines the `set` action only if the key exists in the `fromKey` value.
* This is to avoid overwriting actual values with undefined.
* @param cfg The config object
* @param fromKey The key to copy from.
* @param toKey The key where the value should be copied to.
*/
function copyIfExists({
cfg,
fromKey,
toKey,
}: {
cfg: Readonly<{ [p: string]: unknown }>;
fromKey: string;
toKey: string;
}) {
return has(cfg, fromKey) ? [{ path: toKey, value: get(cfg, fromKey) }] : [];
}

View file

@ -5,11 +5,21 @@
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/server';
import { Plugin } from '@kbn/core-plugins-server';
export { config } from './config';
export async function plugin(initializerContext: PluginInitializerContext) {
const { CloudChatPlugin } = await import('./plugin');
return new CloudChatPlugin(initializerContext);
// The plugin exists only to register the deprecated config keys and to be cleaned up in the future
export async function plugin() {
class CloudChatPlugin implements Plugin {
constructor() {}
public setup() {}
public start() {}
public stop() {}
}
return new CloudChatPlugin();
}

View file

@ -1,45 +0,0 @@
/*
* 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 { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { registerChatRoute } from './routes';
import type { CloudChatConfigType } from './config';
interface CloudChatSetupDeps {
cloud: CloudSetup;
}
export class CloudChatPlugin implements Plugin<void, void, CloudChatSetupDeps> {
private readonly config: CloudChatConfigType;
private readonly isDev: boolean;
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get();
this.isDev = initializerContext.env.mode.dev;
}
public setup(core: CoreSetup, { cloud }: CloudChatSetupDeps) {
const { chatIdentitySecret, trialBuffer } = this.config;
const { isCloudEnabled, trialEndDate } = cloud;
if (isCloudEnabled && chatIdentitySecret) {
registerChatRoute({
router: core.http.createRouter(),
chatIdentitySecret,
trialEndDate,
trialBuffer,
isDev: this.isDev,
});
}
}
public start() {}
public stop() {}
}

View file

@ -1,369 +0,0 @@
/*
* 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.
*/
jest.mock('jsonwebtoken', () => ({
sign: () => {
return 'json-web-token';
},
}));
import {
httpServiceMock,
httpServerMock,
coreMock,
securityServiceMock,
coreFeatureFlagsMock,
} from '@kbn/core/server/mocks';
import { kibanaResponseFactory } from '@kbn/core/server';
import { type MetaWithSaml, registerChatRoute } from './chat';
describe('chat route', () => {
let security: ReturnType<typeof securityServiceMock.createRequestHandlerContext>;
let requestHandlerContextMock: ReturnType<typeof coreMock.createCustomRequestHandlerContext>;
let featureFlags: ReturnType<typeof coreFeatureFlagsMock.createRequestHandlerContext>;
beforeEach(() => {
const core = coreMock.createRequestHandlerContext();
security = core.security;
featureFlags = core.featureFlags;
featureFlags.getStringValue.mockResolvedValue('header');
featureFlags.getBooleanValue.mockResolvedValue(true);
requestHandlerContextMock = coreMock.createCustomRequestHandlerContext({ core });
});
test('error if no user', async () => {
security.authc.getCurrentUser.mockReturnValueOnce(null);
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {},
"payload": "Not Found",
"status": 404,
}
`);
});
test('error if no user is missing any details', async () => {
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username: undefined,
})
);
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": "User has no email or username",
},
"payload": "User has no email or username",
"status": 400,
}
`);
});
test('error if no trial end date specified', async () => {
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username,
metadata: {
saml_email: [email],
} as MetaWithSaml,
})
);
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 2,
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": "Chat can only be started if a trial end date is specified",
},
"payload": "Chat can only be started if a trial end date is specified",
"status": 400,
}
`);
});
test('error if not in trial window', async () => {
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username,
metadata: {
saml_email: [email],
} as MetaWithSaml,
})
);
const router = httpServiceMock.createRouter();
const trialEndDate = new Date();
trialEndDate.setDate(trialEndDate.getDate() - 30);
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 2,
trialEndDate,
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": "Chat can only be started during trial and trial chat buffer",
},
"payload": "Chat can only be started during trial and trial chat buffer",
"status": 400,
}
`);
});
test('error if disabled in experiments', async () => {
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username,
metadata: {
saml_email: [email],
} as MetaWithSaml,
})
);
const router = httpServiceMock.createRouter();
featureFlags.getBooleanValue.mockResolvedValueOnce(false);
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": "Chat is disabled through experiments",
},
"payload": "Chat is disabled through experiments",
"status": 400,
}
`);
});
test('returns user information taken from saml metadata and a token', async () => {
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username,
metadata: {
saml_email: [email],
} as MetaWithSaml,
})
);
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
"status": 200,
}
`);
});
test('returns placeholder user information and a token in dev mode', async () => {
const username = 'first.last';
const email = 'test+first.last@elasticsearch.com';
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username: undefined,
})
);
const router = httpServiceMock.createRouter();
registerChatRoute({
router,
isDev: true,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"chatVariant": "header",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
"status": 200,
}
`);
});
test('returns chat variant', async () => {
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce(
securityServiceMock.createMockAuthenticatedUser({
username,
metadata: {
saml_email: [email],
} as MetaWithSaml,
})
);
const router = httpServiceMock.createRouter();
featureFlags.getStringValue.mockResolvedValueOnce('bubble');
registerChatRoute({
router,
isDev: false,
chatIdentitySecret: 'secret',
trialBuffer: 60,
trialEndDate: new Date(),
});
const [_config, handler] = router.get.mock.calls[0];
await expect(
handler(
requestHandlerContextMock,
httpServerMock.createKibanaRequest(),
kibanaResponseFactory
)
).resolves.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": Object {
"chatVariant": "bubble",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"chatVariant": "bubble",
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
"status": 200,
}
`);
});
});

View file

@ -1,100 +0,0 @@
/*
* 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 { AuthenticatedUser, IRouter } from '@kbn/core/server';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants';
import type { GetChatUserDataResponseBody, ChatVariant } from '../../common/types';
import { generateSignedJwt } from '../util/generate_jwt';
import { isTodayInDateWindow } from '../../common/util';
export type MetaWithSaml = AuthenticatedUser['metadata'] & {
saml_name: [string];
saml_email: [string];
saml_roles: [string];
saml_principal: [string];
};
export const registerChatRoute = ({
router,
chatIdentitySecret,
trialEndDate,
trialBuffer,
isDev,
}: {
router: IRouter;
chatIdentitySecret: string;
trialEndDate?: Date;
trialBuffer: number;
isDev: boolean;
}) => {
router.get(
{
path: GET_CHAT_USER_DATA_ROUTE_PATH,
validate: {},
},
async (context, request, response) => {
const { security, featureFlags } = await context.core;
const user = security.authc.getCurrentUser();
if (!user) {
// Hide the API from unauthenticated users
return response.notFound();
}
let userId = user.username;
let [userEmail] = (user.metadata as MetaWithSaml)?.saml_email || [];
// In local development, these values are not populated. This is a workaround
// to allow for local testing.
if (isDev) {
if (!userId) {
userId = 'first.last';
}
if (!userEmail) {
userEmail = userEmail || `test+${userId}@elasticsearch.com`;
}
}
if (!userEmail || !userId) {
return response.badRequest({
body: 'User has no email or username',
});
}
if (!trialEndDate) {
return response.badRequest({
body: 'Chat can only be started if a trial end date is specified',
});
}
if (!trialEndDate || !isTodayInDateWindow(trialEndDate, trialBuffer)) {
return response.badRequest({
body: 'Chat can only be started during trial and trial chat buffer',
});
}
// Meant to be used as a runtime kill switch via LaunchDarkly
if (!(await featureFlags.getBooleanValue('cloud-chat.enabled', true).catch(() => false))) {
return response.badRequest({
body: 'Chat is disabled through experiments',
});
}
const token = generateSignedJwt(userId, chatIdentitySecret);
const body: GetChatUserDataResponseBody = {
token,
email: userEmail,
id: userId,
chatVariant: await featureFlags.getStringValue<ChatVariant>(
'cloud-chat.chat-variant',
'header'
),
};
return response.ok({ body });
}
);
};

View file

@ -1,8 +0,0 @@
/*
* 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 { registerChatRoute } from './chat';

View file

@ -1,23 +0,0 @@
/*
* 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.
*/
jest.mock('jsonwebtoken', () => ({
sign: (payload: {}, secret: string, options: {}) => {
return `${JSON.stringify(payload)}.${secret}.${JSON.stringify(options)}`;
},
}));
import { generateSignedJwt } from './generate_jwt';
describe('generateSignedJwt', () => {
test('creating a JWT token', () => {
const jwtToken = generateSignedJwt('test', '123456');
expect(jwtToken).toEqual(
'{"sub":"test"}.123456.{"header":{"alg":"HS256","typ":"JWT"},"expiresIn":300}'
);
});
});

View file

@ -1,24 +0,0 @@
/*
* 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 jwt from 'jsonwebtoken';
export const generateSignedJwt = (userId: string, secret: string): string => {
const options = {
header: {
alg: 'HS256',
typ: 'JWT',
},
expiresIn: 5 * 60, // 5m
};
const payload = {
sub: userId,
};
return jwt.sign(payload, secret, options);
};

View file

@ -12,15 +12,8 @@
],
"kbn_references": [
"@kbn/core",
"@kbn/cloud-plugin",
"@kbn/storybook",
"@kbn/core-http-browser",
"@kbn/i18n",
"@kbn/config-schema",
"@kbn/ui-theme",
"@kbn/react-kibana-context-render",
"@kbn/logging",
"@kbn/logging-mocks",
"@kbn/core-plugins-server",
],
"exclude": [
"target/**/*",

View file

@ -13498,11 +13498,6 @@
"xpack.cases.userProfiles.modifySearch": "Modifiez votre recherche ou vérifiez les privilèges de l'utilisateur.",
"xpack.cases.userProfiles.userDoesNotExist": "L'utilisateur n'existe pas ou n'est pas disponible",
"xpack.cases.visualizationActions.addToExistingCaseSuccessContent": "Visualisation correctement ajoutée au cas",
"xpack.cloudChat.chatButtonLabel": "Chat en live",
"xpack.cloudChat.chatFrameTitle": "Chat",
"xpack.cloudChat.chatTourHeaderText": "Chat en live maintenant",
"xpack.cloudChat.chatTourText": "Lancer le chat pour bénéficier d'une aide sur l'ingestion des données, la configuration de votre instance et la résolution de problèmes.",
"xpack.cloudChat.hideChatButtonLabel": "Masquer le chat",
"xpack.cloudDataMigration.breadcrumb.label": "Migrer vers Elastic Cloud",
"xpack.cloudDataMigration.deployInSeconds.text": "Déployer et scaler une suite Elastic en quelques minutes",
"xpack.cloudDataMigration.helpMenuMoveDataTitle": "Déplacer les données vers Elastic Cloud",

View file

@ -13247,11 +13247,6 @@
"xpack.cases.userProfiles.modifySearch": "検索を変更するか、ユーザーの権限を確認してください。",
"xpack.cases.userProfiles.userDoesNotExist": "ユーザーが存在しないか、使用できません",
"xpack.cases.visualizationActions.addToExistingCaseSuccessContent": "ビジュアライゼーションが正常にケースに追加されました",
"xpack.cloudChat.chatButtonLabel": "ライブチャット",
"xpack.cloudChat.chatFrameTitle": "チャット",
"xpack.cloudChat.chatTourHeaderText": "今すぐライブチャット",
"xpack.cloudChat.chatTourText": "データのインジェスト、インスタンスの設定、トラブルシューティングなどのトピックに関するサポートを受けるためのチャットを開きます。",
"xpack.cloudChat.hideChatButtonLabel": "グラフを非表示",
"xpack.cloudDataMigration.breadcrumb.label": "Elastic Cloudに移行する",
"xpack.cloudDataMigration.deployInSeconds.text": "数分で安全なElastic Stackをデプロイしてスケール",
"xpack.cloudDataMigration.helpMenuMoveDataTitle": "データをElastic Cloudに移動",

View file

@ -13272,11 +13272,6 @@
"xpack.cases.userProfiles.modifySearch": "请修改您的搜索或检查用户的权限。",
"xpack.cases.userProfiles.userDoesNotExist": "用户不存在或不可用",
"xpack.cases.visualizationActions.addToExistingCaseSuccessContent": "已成功将可视化添加到案例",
"xpack.cloudChat.chatButtonLabel": "实时聊天",
"xpack.cloudChat.chatFrameTitle": "聊天",
"xpack.cloudChat.chatTourHeaderText": "立即实时聊天",
"xpack.cloudChat.chatTourText": "打开聊天以获取有关主题的帮助,如采集数据、配置实例和故障排除。",
"xpack.cloudChat.hideChatButtonLabel": "隐藏聊天",
"xpack.cloudDataMigration.breadcrumb.label": "迁移到 Elastic Cloud",
"xpack.cloudDataMigration.deployInSeconds.text": "在数分钟内部署并扩展安全的 Elastic Stack",
"xpack.cloudDataMigration.helpMenuMoveDataTitle": "转移数据到 Elastic Cloud",

View file

@ -14,11 +14,6 @@ const FULLSTORY_ORG_ID = process.env.FULLSTORY_ORG_ID;
const FULLSTORY_API_KEY = process.env.FULLSTORY_API_KEY;
const RUN_FULLSTORY_TESTS = Boolean(FULLSTORY_ORG_ID && FULLSTORY_API_KEY);
const CHAT_URL = process.env.CHAT_URL;
const CHAT_IDENTITY_SECRET = process.env.CHAT_IDENTITY_SECRET;
const CLOUD_TRIAL_END_DATE = new Date().toISOString(); // needed for chat to appear
const RUN_CHAT_TESTS = Boolean(CHAT_URL);
// the default export of config files must be a config provider
// that returns an object with the projects config values
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
@ -34,11 +29,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const samlIdPPlugin = resolve(__dirname, './plugins/saml_provider');
return {
testFiles: [
...(RUN_FULLSTORY_TESTS ? [resolve(__dirname, './tests/fullstory')] : []),
...(RUN_CHAT_TESTS ? [resolve(__dirname, './tests/chat')] : []),
...(!RUN_CHAT_TESTS ? [resolve(__dirname, './tests/chat_disabled')] : []),
],
testFiles: [...(RUN_FULLSTORY_TESTS ? [resolve(__dirname, './tests/fullstory')] : [])],
services,
pageObjects,
@ -78,15 +69,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
`--xpack.cloud.full_story.org_id=${FULLSTORY_ORG_ID}`,
]
: []),
...(RUN_CHAT_TESTS
? [
'--xpack.cloud.id=5b2de169-2785-441b-ae8c-186a1936b17d',
'--xpack.cloud.chat.enabled=true',
`--xpack.cloud.chat.chatURL=${CHAT_URL}`,
`--xpack.cloud.chatIdentitySecret=${CHAT_IDENTITY_SECRET}`,
`--xpack.cloud.trial_end_date="${CLOUD_TRIAL_END_DATE}"`,
]
: []),
],
},
uiSettings: {

View file

@ -1,31 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
// TODO: the whole suite doesn't run on ci https://github.com/elastic/kibana/issues/159401
describe('Cloud Chat integration', function () {
it('chat widget is present in header', async () => {
await PageObjects.common.navigateToUrl('home');
// button is visible
await testSubjects.existOrFail('cloud-chat');
// the chat widget is not visible (but in DOM)
await testSubjects.missingOrFail('cloud-chat-frame', {
allowHidden: true,
});
await testSubjects.click('cloud-chat');
await testSubjects.existOrFail('cloud-chat-frame');
});
});
}