diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index 043eb3585c3ae..a795b0eedc2ac 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -9,6 +9,8 @@ import { isPlainObject, isEmpty } from 'lodash'; import { Type } from '@kbn/config-schema'; import { Logger } from '@kbn/logging'; import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestHeaders } from 'axios'; +import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { assertURL } from './helpers/validators'; import { ActionsConfigurationUtilities } from '../actions_config'; import { SubAction, SubActionRequestParams } from './types'; @@ -28,6 +30,8 @@ export abstract class SubActionConnector { private subActions: Map = new Map(); private configurationUtilities: ActionsConfigurationUtilities; protected logger: Logger; + protected esClient: ElasticsearchClient; + protected savedObjectsClient: SavedObjectsClientContract; protected connector: ServiceParams['connector']; protected config: Config; protected secrets: Secrets; @@ -37,6 +41,8 @@ export abstract class SubActionConnector { this.logger = params.logger; this.config = params.config; this.secrets = params.secrets; + this.savedObjectsClient = params.services.savedObjectsClient; + this.esClient = params.services.scopedClusterClient; this.configurationUtilities = params.configurationUtilities; this.axiosInstance = axios.create(); } diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json index 596d793c6ea8f..5644f3ed6d849 100644 --- a/x-pack/plugins/actions/tsconfig.json +++ b/x-pack/plugins/actions/tsconfig.json @@ -36,6 +36,8 @@ "@kbn/core-http-server-mocks", "@kbn/tinymath", "@kbn/core-saved-objects-utils-server", + "@kbn/core-saved-objects-api-server", + "@kbn/core-elasticsearch-server", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts index 8ed871ef4575a..81038092fd9f2 100644 --- a/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts +++ b/x-pack/plugins/stack_connectors/common/gen_ai/constants.ts @@ -16,6 +16,7 @@ export const GEN_AI_TITLE = i18n.translate( export const GEN_AI_CONNECTOR_ID = '.gen-ai'; export enum SUB_ACTION { RUN = 'run', + DASHBOARD = 'getDashboard', TEST = 'test', } export enum OpenAiProviderType { diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts index b2c7f3a7db963..6ff201b3d7d6e 100644 --- a/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts +++ b/x-pack/plugins/stack_connectors/common/gen_ai/schema.ts @@ -44,3 +44,12 @@ export const GenAiRunActionResponseSchema = schema.object( }, { unknowns: 'ignore' } ); + +// Run action schema +export const GenAiDashboardActionParamsSchema = schema.object({ + dashboardId: schema.string(), +}); + +export const GenAiDashboardActionResponseSchema = schema.object({ + available: schema.boolean(), +}); diff --git a/x-pack/plugins/stack_connectors/common/gen_ai/types.ts b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts index 9f27aafa0b3a9..f509d85641189 100644 --- a/x-pack/plugins/stack_connectors/common/gen_ai/types.ts +++ b/x-pack/plugins/stack_connectors/common/gen_ai/types.ts @@ -11,9 +11,13 @@ import { GenAiSecretsSchema, GenAiRunActionParamsSchema, GenAiRunActionResponseSchema, + GenAiDashboardActionParamsSchema, + GenAiDashboardActionResponseSchema, } from './schema'; export type GenAiConfig = TypeOf; export type GenAiSecrets = TypeOf; export type GenAiRunActionParams = TypeOf; export type GenAiRunActionResponse = TypeOf; +export type GenAiDashboardActionParams = TypeOf; +export type GenAiDashboardActionResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/api.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/api.test.ts new file mode 100644 index 0000000000000..cf1197c487cd9 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/api.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { getDashboard } from './api'; +import { SUB_ACTION } from '../../../common/gen_ai/constants'; +const response = { + available: true, +}; + +describe('Gen AI Dashboard API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + describe('getDashboard', () => { + test('should call get dashboard API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(response); + const res = await getDashboard({ + http, + signal: abortCtrl.signal, + connectorId: 'te/st', + dashboardId: 'cool-dashboard', + }); + + expect(res).toEqual(response); + expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', { + body: `{"params":{"subAction":"${SUB_ACTION.DASHBOARD}","subActionParams":{"dashboardId":"cool-dashboard"}}}`, + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/api.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/api.ts new file mode 100644 index 0000000000000..da35d608239b1 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/api.ts @@ -0,0 +1,34 @@ +/* + * 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 { HttpSetup } from '@kbn/core-http-browser'; +import { ActionTypeExecutorResult, BASE_ACTION_API_PATH } from '@kbn/actions-plugin/common'; +import { SUB_ACTION } from '../../../common/gen_ai/constants'; +import { ConnectorExecutorResult, rewriteResponseToCamelCase } from '../lib/rewrite_response_body'; + +export async function getDashboard({ + http, + signal, + dashboardId, + connectorId, +}: { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + dashboardId: string; +}): Promise> { + const res = await http.post>( + `${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`, + { + body: JSON.stringify({ + params: { subAction: SUB_ACTION.DASHBOARD, subActionParams: { dashboardId } }, + }), + signal, + } + ); + return rewriteResponseToCamelCase(res); +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx index 5376daa5027ba..59d47bdb1b4ac 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.test.tsx @@ -8,27 +8,54 @@ import React from 'react'; import GenerativeAiConnectorFields from './connector'; import { ConnectorFormTestProvider } from '../lib/test_utils'; -import { act, render, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { OpenAiProviderType } from '../../../common/gen_ai/constants'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; +import { useGetDashboard } from './use_get_dashboard'; + +jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); +jest.mock('./use_get_dashboard'); + +const useKibanaMock = useKibana as jest.Mocked; +const mockDashboard = useGetDashboard as jest.Mock; +const openAiConnector = { + actionTypeId: '.gen-ai', + name: 'genAi', + id: '123', + config: { + apiUrl: 'https://openaiurl.com', + apiProvider: OpenAiProviderType.OpenAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, + isDeprecated: false, +}; +const azureConnector = { + ...openAiConnector, + config: { + apiUrl: 'https://azureaiurl.com', + apiProvider: OpenAiProviderType.AzureAi, + }, + secrets: { + apiKey: 'thats-a-nice-looking-key', + }, +}; + +const navigateToUrl = jest.fn(); describe('GenerativeAiConnectorFields renders', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock().services.application.navigateToUrl = navigateToUrl; + mockDashboard.mockImplementation(({ connectorId }) => ({ + dashboardUrl: `https://dashboardurl.com/${connectorId}`, + })); + }); test('open ai connector fields are rendered', async () => { - const actionConnector = { - actionTypeId: '.gen-ai', - name: 'genAi', - config: { - apiUrl: 'https://openaiurl.com', - apiProvider: OpenAiProviderType.OpenAi, - }, - secrets: { - apiKey: 'thats-a-nice-looking-key', - }, - isDeprecated: false, - }; - const { getAllByTestId } = render( - + { ); expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); - expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(openAiConnector.config.apiUrl); expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( - actionConnector.config.apiProvider + openAiConnector.config.apiProvider ); expect(getAllByTestId('open-ai-api-doc')[0]).toBeInTheDocument(); expect(getAllByTestId('open-ai-api-keys-doc')[0]).toBeInTheDocument(); }); test('azure ai connector fields are rendered', async () => { - const actionConnector = { - actionTypeId: '.gen-ai', - name: 'genAi', - config: { - apiUrl: 'https://azureaiurl.com', - apiProvider: OpenAiProviderType.AzureAi, - }, - secrets: { - apiKey: 'thats-a-nice-looking-key', - }, - isDeprecated: false, - }; - const { getAllByTestId } = render( - + { /> ); - expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument(); - expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl); + expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(azureConnector.config.apiUrl); expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument(); expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue( - actionConnector.config.apiProvider + azureConnector.config.apiProvider ); expect(getAllByTestId('azure-ai-api-doc')[0]).toBeInTheDocument(); expect(getAllByTestId('azure-ai-api-keys-doc')[0]).toBeInTheDocument(); }); + describe('Dashboard link', () => { + it('Does not render if isEdit is false and dashboardUrl is defined', async () => { + const { queryByTestId } = render( + + {}} + /> + + ); + expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument(); + }); + it('Does not render if isEdit is true and dashboardUrl is null', async () => { + mockDashboard.mockImplementation((id: string) => ({ + dashboardUrl: null, + })); + const { queryByTestId } = render( + + {}} + /> + + ); + expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument(); + }); + it('Renders if isEdit is true and dashboardUrl is defined', async () => { + const { getByTestId } = render( + + {}} + /> + + ); + expect(getByTestId('link-gen-ai-token-dashboard')).toBeInTheDocument(); + }); + it('On click triggers redirect with correct saved object id', async () => { + const { getByTestId } = render( + + {}} + /> + + ); + fireEvent.click(getByTestId('link-gen-ai-token-dashboard')); + expect(navigateToUrl).toHaveBeenCalledWith(`https://dashboardurl.com/123`); + }); + }); describe('Validation', () => { const onSubmit = jest.fn(); - const actionConnector = { - actionTypeId: '.gen-ai', - name: 'genAi', - config: { - apiUrl: 'https://openaiurl.com', - apiProvider: OpenAiProviderType.OpenAi, - }, - secrets: { - apiKey: 'thats-a-nice-looking-key', - }, - isDeprecated: false, - }; beforeEach(() => { jest.clearAllMocks(); @@ -101,7 +156,7 @@ describe('GenerativeAiConnectorFields renders', () => { it('connector validation succeeds when connector config is valid', async () => { const { getByTestId } = render( - + { }); expect(onSubmit).toBeCalledWith({ - data: actionConnector, + data: openAiConnector, isValid: true, }); }); it('validates correctly if the apiUrl is empty', async () => { const connector = { - ...actionConnector, + ...openAiConnector, config: { - ...actionConnector.config, + ...openAiConnector.config, apiUrl: '', }, }; @@ -159,9 +214,9 @@ describe('GenerativeAiConnectorFields renders', () => { ]; it.each(tests)('validates correctly %p', async (field, value) => { const connector = { - ...actionConnector, + ...openAiConnector, config: { - ...actionConnector.config, + ...openAiConnector.config, headers: [], }, }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx index f75edba2d57b4..99b8bb701e60e 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/connector.tsx @@ -5,151 +5,59 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ActionConnectorFieldsProps, - ConfigFieldSchema, - SecretsFieldSchema, SimpleConnectorForm, } from '@kbn/triggers-actions-ui-plugin/public'; import { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { EuiLink, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; import { UseField, useFormContext, useFormData, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { useGetDashboard } from './use_get_dashboard'; import { OpenAiProviderType } from '../../../common/gen_ai/constants'; import * as i18n from './translations'; -import { DEFAULT_URL, DEFAULT_URL_AZURE } from './constants'; +import { + azureAiConfig, + azureAiSecrets, + openAiConfig, + openAiSecrets, + providerOptions, +} from './constants'; const { emptyField } = fieldValidators; -const openAiConfig: ConfigFieldSchema[] = [ - { - id: 'apiUrl', - label: i18n.API_URL_LABEL, - isUrlField: true, - defaultValue: DEFAULT_URL, - helpText: ( - - {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} - - ), - }} - /> - ), - }, -]; - -const azureAiConfig: ConfigFieldSchema[] = [ - { - id: 'apiUrl', - label: i18n.API_URL_LABEL, - isUrlField: true, - defaultValue: DEFAULT_URL_AZURE, - helpText: ( - - {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} - - ), - }} - /> - ), - }, -]; - -const openAiSecrets: SecretsFieldSchema[] = [ - { - id: 'apiKey', - label: i18n.API_KEY_LABEL, - isPasswordField: true, - helpText: ( - - {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} - - ), - }} - /> - ), - }, -]; - -const azureAiSecrets: SecretsFieldSchema[] = [ - { - id: 'apiKey', - label: i18n.API_KEY_LABEL, - isPasswordField: true, - helpText: ( - - {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} - - ), - }} - /> - ), - }, -]; - -const providerOptions = [ - { - value: OpenAiProviderType.OpenAi, - text: i18n.OPEN_AI, - label: i18n.OPEN_AI, - }, - { - value: OpenAiProviderType.AzureAi, - text: i18n.AZURE_AI, - label: i18n.AZURE_AI, - }, -]; - const GenerativeAiConnectorFields: React.FC = ({ readOnly, isEdit, }) => { const { getFieldDefaultValue } = useFormContext(); - const [{ config }] = useFormData({ + const [{ config, id, name }] = useFormData({ watch: ['config.apiProvider'], }); + const { + services: { + application: { navigateToUrl }, + }, + } = useKibana(); + + const { dashboardUrl } = useGetDashboard({ connectorId: id }); + + const onClick = useCallback( + (e) => { + e.preventDefault(); + if (dashboardUrl) { + navigateToUrl(dashboardUrl); + } + }, + [dashboardUrl, navigateToUrl] + ); + const selectedProviderDefaultValue = useMemo( () => getFieldDefaultValue('config.apiProvider') ?? OpenAiProviderType.OpenAi, @@ -199,6 +107,11 @@ const GenerativeAiConnectorFields: React.FC = ({ secretsFormSchema={azureAiSecrets} /> )} + {isEdit && dashboardUrl != null && ( + + {i18n.USAGE_DASHBOARD_LINK(selectedProviderDefaultValue, name)} + + )} ); }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts deleted file mode 100644 index 9fb836c8aae78..0000000000000 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.ts +++ /dev/null @@ -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. - */ - -export const DEFAULT_URL = 'https://api.openai.com/v1/chat/completions' as const; -export const DEFAULT_URL_AZURE = - 'https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/chat/completions?api-version={api-version}' as const; - -export const DEFAULT_BODY = `{ - "model":"gpt-3.5-turbo", - "messages": [{ - "role":"user", - "content":"Hello world" - }] -}`; -export const DEFAULT_BODY_AZURE = `{ - "messages": [{ - "role":"user", - "content":"Hello world" - }] -}`; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.tsx new file mode 100644 index 0000000000000..706abf215295a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/constants.tsx @@ -0,0 +1,148 @@ +/* + * 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 { ConfigFieldSchema, SecretsFieldSchema } from '@kbn/triggers-actions-ui-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiLink } from '@elastic/eui'; +import { OpenAiProviderType } from '../../../common/gen_ai/constants'; +import * as i18n from './translations'; + +export const DEFAULT_URL = 'https://api.openai.com/v1/chat/completions' as const; +export const DEFAULT_URL_AZURE = + 'https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/chat/completions?api-version={api-version}' as const; + +export const DEFAULT_BODY = `{ + "model":"gpt-3.5-turbo", + "messages": [{ + "role":"user", + "content":"Hello world" + }] +}`; +export const DEFAULT_BODY_AZURE = `{ + "messages": [{ + "role":"user", + "content":"Hello world" + }] +}`; + +export const openAiConfig: ConfigFieldSchema[] = [ + { + id: 'apiUrl', + label: i18n.API_URL_LABEL, + isUrlField: true, + defaultValue: DEFAULT_URL, + helpText: ( + + {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +export const azureAiConfig: ConfigFieldSchema[] = [ + { + id: 'apiUrl', + label: i18n.API_URL_LABEL, + isUrlField: true, + defaultValue: DEFAULT_URL_AZURE, + helpText: ( + + {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +export const openAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +export const azureAiSecrets: SecretsFieldSchema[] = [ + { + id: 'apiKey', + label: i18n.API_KEY_LABEL, + isPasswordField: true, + helpText: ( + + {`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`} + + ), + }} + /> + ), + }, +]; + +export const providerOptions = [ + { + value: OpenAiProviderType.OpenAi, + text: i18n.OPEN_AI, + label: i18n.OPEN_AI, + }, + { + value: OpenAiProviderType.AzureAi, + text: i18n.AZURE_AI, + label: i18n.AZURE_AI, + }, +]; + +export const getDashboardId = (spaceId: string): string => `generative-ai-token-usage-${spaceId}`; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx index a1260e32c841f..f37ab8b39d573 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/params.test.tsx @@ -33,14 +33,12 @@ const messageVariables = [ describe('Gen AI Params Fields renders', () => { test('all params fields are rendered', () => { - const actionParams = { - subAction: SUB_ACTION.RUN, - subActionParams: { body: '{"message": "test"}' }, - }; - const { getByTestId } = render( {}} index={0} @@ -118,17 +116,16 @@ describe('Gen AI Params Fields renders', () => { }); it('calls editAction function with the correct arguments ', () => { - const actionParams = { - subAction: SUB_ACTION.RUN, - subActionParams: { - body: '{"key": "value"}', - }, - }; const editAction = jest.fn(); const errors = {}; const { getByTestId } = render( + i18n.translate('xpack.stackConnectors.components.genAi.dashboardLink', { + values: { apiProvider, connectorName }, + defaultMessage: 'View {apiProvider} Usage Dashboard for "{ connectorName }" Connector', + }); + +export const GET_DASHBOARD_API_ERROR = i18n.translate( + 'xpack.stackConnectors.components.genAi.error.dashboardApiError', + { + defaultMessage: 'Error finding Generative AI Token Usage Dashboard.', + } +); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/use_get_dashboard.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/use_get_dashboard.test.ts new file mode 100644 index 0000000000000..255e2aba1b000 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/use_get_dashboard.test.ts @@ -0,0 +1,130 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useGetDashboard } from './use_get_dashboard'; +import { getDashboard } from './api'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; + +jest.mock('./api'); +const mockToasts = { addDanger: jest.fn() }; +const mockSpace = { + id: 'space', + name: 'space', + disabledFeatures: [], +}; +const mockHttp = jest.fn(); +const mockGetRedirectUrl = jest.fn(); +jest.mock('@kbn/triggers-actions-ui-plugin/public'); +const connectorId = '123'; + +const mockServices = { + http: mockHttp, + notifications: { toasts: mockToasts }, + dashboard: { + locator: { + getRedirectUrl: mockGetRedirectUrl.mockImplementation( + ({ dashboardId }) => `http://localhost:5601/app/dashboards#/view/${dashboardId}` + ), + }, + }, + spaces: { + getActiveSpace: jest.fn().mockResolvedValue(mockSpace), + }, +}; +const mockDashboard = getDashboard as jest.Mock; +const mockKibana = useKibana as jest.Mock; + +describe('useGetDashboard_function', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDashboard.mockResolvedValue({ data: { available: true } }); + mockKibana.mockReturnValue({ + services: mockServices, + }); + }); + + it('fetches the dashboard and sets the dashboard URL', async () => { + const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId })); + await waitForNextUpdate(); + expect(mockDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId, + dashboardId: 'generative-ai-token-usage-space', + }) + ); + expect(mockGetRedirectUrl).toHaveBeenCalledWith({ + query: { + language: 'kuery', + query: `kibana.saved_objects: { id : ${connectorId} }`, + }, + dashboardId: 'generative-ai-token-usage-space', + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.dashboardUrl).toBe( + 'http://localhost:5601/app/dashboards#/view/generative-ai-token-usage-space' + ); + }); + + it('handles the case where the dashboard is not available.', async () => { + mockDashboard.mockResolvedValue({ data: { available: false } }); + const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId })); + await waitForNextUpdate(); + expect(mockDashboard).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId, + dashboardId: 'generative-ai-token-usage-space', + }) + ); + expect(mockGetRedirectUrl).not.toHaveBeenCalled(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.dashboardUrl).toBe(null); + }); + + it('handles the case where the spaces API is not available.', async () => { + mockKibana.mockReturnValue({ + services: { ...mockServices, spaces: null }, + }); + + const { result } = renderHook(() => useGetDashboard({ connectorId })); + expect(mockDashboard).not.toHaveBeenCalled(); + expect(mockGetRedirectUrl).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.dashboardUrl).toBe(null); + }); + + it('handles the case where connectorId is empty string', async () => { + const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId: '' })); + await waitForNextUpdate(); + expect(mockDashboard).not.toHaveBeenCalled(); + expect(mockGetRedirectUrl).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.dashboardUrl).toBe(null); + }); + + it('handles the case where the dashboard locator is not available.', async () => { + mockKibana.mockReturnValue({ + services: { ...mockServices, dashboard: {} }, + }); + const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId })); + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(false); + expect(result.current.dashboardUrl).toBe(null); + }); + + it('correctly handles errors and displays the appropriate toast messages.', async () => { + mockDashboard.mockRejectedValue(new Error('Error fetching dashboard')); + const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId })); + await waitForNextUpdate(); + expect(result.current.isLoading).toBe(false); + expect(mockToasts.addDanger).toHaveBeenCalledWith({ + title: 'Error finding Generative AI Token Usage Dashboard.', + text: 'Error fetching dashboard', + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/use_get_dashboard.ts b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/use_get_dashboard.ts new file mode 100644 index 0000000000000..557cf2e331ca6 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/gen_ai/use_get_dashboard.ts @@ -0,0 +1,120 @@ +/* + * 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 { useState, useEffect, useRef, useCallback } from 'react'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; +import { getDashboardId } from './constants'; +import { getDashboard } from './api'; +import * as i18n from './translations'; + +interface Props { + connectorId: string; +} + +export interface UseGetDashboard { + dashboardUrl: string | null; + isLoading: boolean; +} + +export const useGetDashboard = ({ connectorId }: Props): UseGetDashboard => { + const { + dashboard, + http, + notifications: { toasts }, + spaces, + } = useKibana().services; + + const [spaceId, setSpaceId] = useState(null); + + useEffect(() => { + let didCancel = false; + if (spaces) { + spaces.getActiveSpace().then((space) => { + if (!didCancel) setSpaceId(space.id); + }); + } + + return () => { + didCancel = true; + }; + }, [spaces]); + + const [isLoading, setIsLoading] = useState(false); + const [dashboardUrl, setDashboardUrl] = useState(null); + const [dashboardCheckComplete, setDashboardCheckComplete] = useState(false); + const abortCtrl = useRef(new AbortController()); + + const setUrl = useCallback( + (dashboardId: string) => { + const url = dashboard?.locator?.getRedirectUrl({ + query: { + language: 'kuery', + query: `kibana.saved_objects: { id : ${connectorId} }`, + }, + dashboardId, + }); + setDashboardUrl(url ?? null); + }, + [connectorId, dashboard?.locator] + ); + + useEffect(() => { + let didCancel = false; + const fetchData = async (dashboardId: string) => { + abortCtrl.current = new AbortController(); + if (!didCancel) setIsLoading(true); + try { + const res = await getDashboard({ + http, + signal: abortCtrl.current.signal, + connectorId, + dashboardId, + }); + + if (!didCancel) { + setDashboardCheckComplete(true); + setIsLoading(false); + if (res.data?.available) { + setUrl(dashboardId); + } + + if (res.status && res.status === 'error') { + toasts.addDanger({ + title: i18n.GET_DASHBOARD_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } + } + } catch (error) { + if (!didCancel) { + setDashboardCheckComplete(true); + setIsLoading(false); + toasts.addDanger({ + title: i18n.GET_DASHBOARD_API_ERROR, + text: error.message, + }); + } + } + }; + + if (spaceId != null && connectorId.length > 0 && !dashboardCheckComplete) { + abortCtrl.current.abort(); + fetchData(getDashboardId(spaceId)); + } + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + }, [connectorId, dashboardCheckComplete, dashboardUrl, http, setUrl, spaceId, toasts]); + + return { + isLoading, + dashboardUrl, + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts new file mode 100644 index 0000000000000..b13fa276beacb --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { initGenAiDashboard } from './create_dashboard'; +import { getGenAiDashboard } from './dashboard'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { Logger } from '@kbn/logging'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('12345'), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +const savedObjectsClient = savedObjectsClientMock.create(); +describe('createDashboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('fetches the Gen Ai Dashboard saved object', async () => { + const dashboardId = 'test-dashboard-id'; + const result = await initGenAiDashboard({ logger, savedObjectsClient, dashboardId }); + expect(result.success).toBe(true); + expect(logger.error).not.toHaveBeenCalled(); + expect(savedObjectsClient.get).toHaveBeenCalledWith('dashboard', dashboardId); + }); + + it('creates the Gen Ai Dashboard saved object when the dashboard saved object does not exist', async () => { + const dashboardId = 'test-dashboard-id'; + const soClient = { + ...savedObjectsClient, + get: jest.fn().mockRejectedValue({ + output: { + statusCode: 404, + payload: { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [dashboard/generative-ai-token-usage-default] not found', + }, + headers: {}, + }, + }), + }; + const result = await initGenAiDashboard({ logger, savedObjectsClient: soClient, dashboardId }); + + expect(soClient.get).toHaveBeenCalledWith('dashboard', dashboardId); + expect(soClient.create).toHaveBeenCalledWith( + 'dashboard', + getGenAiDashboard(dashboardId).attributes, + { overwrite: true, id: dashboardId } + ); + expect(result.success).toBe(true); + }); + + it('handles an error when fetching the dashboard saved object', async () => { + const soClient = { + ...savedObjectsClient, + get: jest.fn().mockRejectedValue({ + output: { + statusCode: 500, + payload: { + statusCode: 500, + error: 'Internal Server Error', + message: 'Error happened', + }, + headers: {}, + }, + }), + }; + const dashboardId = 'test-dashboard-id'; + const result = await initGenAiDashboard({ logger, savedObjectsClient: soClient, dashboardId }); + expect(result.success).toBe(false); + expect(result.error?.message).toBe('Internal Server Error: Error happened'); + expect(result.error?.statusCode).toBe(500); + expect(soClient.get).toHaveBeenCalledWith('dashboard', dashboardId); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts new file mode 100644 index 0000000000000..d1d09a2e50afd --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/create_dashboard.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; + +import { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import { Logger } from '@kbn/logging'; +import { getGenAiDashboard } from './dashboard'; + +export interface OutputError { + message: string; + statusCode: number; +} + +export const initGenAiDashboard = async ({ + logger, + savedObjectsClient, + dashboardId, +}: { + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + dashboardId: string; +}): Promise<{ + success: boolean; + error?: OutputError; +}> => { + try { + await savedObjectsClient.get('dashboard', dashboardId); + return { + success: true, + }; + } catch (error) { + // if 404, does not yet exist. do not error, continue to create + if (error.output.statusCode !== 404) { + return { + success: false, + error: { + message: `${error.output.payload.error}${ + error.output.payload.message ? `: ${error.output.payload.message}` : '' + }`, + statusCode: error.output.statusCode, + }, + }; + } + } + + try { + await savedObjectsClient.create( + 'dashboard', + getGenAiDashboard(dashboardId).attributes, + { + overwrite: true, + id: dashboardId, + } + ); + logger.info(`Successfully created Gen Ai Dashboard ${dashboardId}`); + return { success: true }; + } catch (error) { + return { + success: false, + error: { message: error.message, statusCode: error.output.statusCode }, + }; + } +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts new file mode 100644 index 0000000000000..46a4fa5145de5 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/dashboard.ts @@ -0,0 +1,407 @@ +/* + * 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 { DashboardAttributes } from '@kbn/dashboard-plugin/common'; +import { v4 as uuidv4 } from 'uuid'; +import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types'; + +export const dashboardTitle = `Generative AI Token Usage`; + +export const getGenAiDashboard = (dashboardId: string): SavedObject => { + const ids: Record = { + genAiSavedObjectId: dashboardId, + tokens: uuidv4(), + totalTokens: uuidv4(), + tag: uuidv4(), + }; + return { + attributes: { + description: 'Displays OpenAI token consumption per Kibana user', + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"query":{"query":"kibana.saved_objects: { type_id : \\".gen-ai\\" } ","language":"kuery"},"filter":[]}', + }, + optionsJSON: + '{"useMargins":true,"syncColors":false,"syncCursor":true,"syncTooltips":false,"hidePanelTitles":false}', + panelsJSON: JSON.stringify([ + { + version: '8.9.0', + type: 'visualization', + gridData: { + x: 0, + y: 0, + w: 48, + h: 4, + i: '1c425103-57a6-4598-a092-03b8d550b440', + }, + panelIndex: '1c425103-57a6-4598-a092-03b8d550b440', + embeddableConfig: { + savedVis: { + id: '', + title: '', + description: '', + type: 'markdown', + params: { + fontSize: 12, + openLinksInNewTab: false, + markdown: + // TODO: update with better copy and link to the docs page for the Gen AI connector before 8.9 release! + 'The data powering this dashboard requires special index permissions. To access the dashboard data, contact a Kibana admin to set up a "read only" role for non-admin users who may want to view this dashboard. ', + }, + uiState: {}, + data: { + aggs: [], + searchSource: { + query: { + query: '', + language: 'kuery', + }, + filter: [], + }, + }, + }, + hidePanelTitles: false, + enhancements: {}, + }, + title: 'Permissions note', + }, + { + version: '8.9.0', + type: 'lens', + gridData: { + x: 0, + y: 0, + w: 48, + h: 20, + i: '1e45fe29-05d3-4dbd-a0bb-fc3bc5ee3d6d', + }, + panelIndex: '1e45fe29-05d3-4dbd-a0bb-fc3bc5ee3d6d', + embeddableConfig: { + attributes: { + title: '', + description: '', + visualizationType: 'lnsXY', + type: 'lens', + references: [], + state: { + visualization: { + title: 'Empty XY chart', + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '475e8ca0-e78e-454a-8597-a5492f70dce3', + accessors: [ + '0f9814ec-0964-4efa-93a3-c7f173df2483', + 'b0e390e4-d754-4eb4-9fcc-4347dadda394', + ], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + layerType: 'data', + xAccessor: '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519', + yConfig: [ + { + forAccessor: '0f9814ec-0964-4efa-93a3-c7f173df2483', + color: '#9170b8', + }, + { + forAccessor: 'b0e390e4-d754-4eb4-9fcc-4347dadda394', + color: '#3383cd', + }, + ], + }, + ], + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + yTitle: 'Sum of GenAi Completion + Prompt Tokens', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + }, + query: { + query: 'kibana.saved_objects:{ type_id: ".gen-ai" }', + language: 'kuery', + }, + filters: [], + datasourceStates: { + formBased: { + layers: { + '475e8ca0-e78e-454a-8597-a5492f70dce3': { + columns: { + '0f9814ec-0964-4efa-93a3-c7f173df2483': { + label: 'GenAI Completion Tokens', + dataType: 'number', + operationType: 'sum', + sourceField: 'kibana.action.execution.gen_ai.usage.completion_tokens', + isBucketed: false, + scale: 'ratio', + params: { + emptyAsNull: true, + }, + customLabel: true, + }, + '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519': { + label: 'user.name', + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: 'user.name', + isBucketed: true, + params: { + size: 10000, + orderBy: { + type: 'custom', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + include: [], + exclude: [], + includeIsRegex: false, + excludeIsRegex: false, + orderAgg: { + label: 'Sum of kibana.action.execution.gen_ai.usage.total_tokens', + dataType: 'number', + operationType: 'sum', + sourceField: 'kibana.action.execution.gen_ai.usage.total_tokens', + isBucketed: false, + scale: 'ratio', + params: { + emptyAsNull: true, + }, + }, + secondaryFields: [], + }, + customLabel: true, + }, + 'b0e390e4-d754-4eb4-9fcc-4347dadda394': { + label: 'GenAi Prompt Tokens', + dataType: 'number', + operationType: 'sum', + sourceField: 'kibana.action.execution.gen_ai.usage.prompt_tokens', + isBucketed: false, + scale: 'ratio', + params: { + emptyAsNull: true, + }, + customLabel: true, + }, + }, + columnOrder: [ + '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519', + '0f9814ec-0964-4efa-93a3-c7f173df2483', + 'b0e390e4-d754-4eb4-9fcc-4347dadda394', + ], + sampling: 1, + incompleteColumns: {}, + }, + }, + }, + textBased: { + layers: {}, + }, + }, + internalReferences: [ + { + type: 'index-pattern', + id: ids.tokens, + name: 'indexpattern-datasource-layer-475e8ca0-e78e-454a-8597-a5492f70dce3', + }, + ], + adHocDataViews: { + [ids.tokens]: { + id: ids.tokens, + title: '.kibana-event-log-*', + timeFieldName: '@timestamp', + sourceFilters: [], + fieldFormats: {}, + runtimeFieldMap: { + 'kibana.action.execution.gen_ai.usage.completion_tokens': { + type: 'long', + }, + 'kibana.action.execution.gen_ai.usage.prompt_tokens': { + type: 'long', + }, + }, + fieldAttrs: {}, + allowNoIndex: false, + name: 'Event Log', + }, + }, + }, + }, + hidePanelTitles: false, + enhancements: {}, + }, + title: 'Prompt + Completion Tokens per User', + }, + { + version: '8.9.0', + type: 'lens', + gridData: { + x: 0, + y: 20, + w: 48, + h: 20, + i: '80f745c6-a18b-492b-bacf-4a2499a2f95d', + }, + panelIndex: '80f745c6-a18b-492b-bacf-4a2499a2f95d', + embeddableConfig: { + attributes: { + title: '', + description: '', + visualizationType: 'lnsXY', + type: 'lens', + references: [], + state: { + visualization: { + title: 'Empty XY chart', + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: '475e8ca0-e78e-454a-8597-a5492f70dce3', + accessors: ['b0e390e4-d754-4eb4-9fcc-4347dadda394'], + position: 'top', + seriesType: 'bar_stacked', + showGridlines: false, + layerType: 'data', + xAccessor: '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519', + yConfig: [ + { + forAccessor: 'b0e390e4-d754-4eb4-9fcc-4347dadda394', + color: '#332182', + }, + ], + }, + ], + }, + query: { + query: 'kibana.saved_objects: { type_id : ".gen-ai" } ', + language: 'kuery', + }, + filters: [], + datasourceStates: { + formBased: { + layers: { + '475e8ca0-e78e-454a-8597-a5492f70dce3': { + columns: { + '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519': { + label: 'user.name', + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: 'user.name', + isBucketed: true, + params: { + size: 10000, + orderBy: { + type: 'column', + columnId: 'b0e390e4-d754-4eb4-9fcc-4347dadda394', + }, + orderDirection: 'desc', + otherBucket: true, + missingBucket: false, + parentFormat: { + id: 'terms', + }, + include: [], + exclude: [], + includeIsRegex: false, + excludeIsRegex: false, + }, + customLabel: true, + }, + 'b0e390e4-d754-4eb4-9fcc-4347dadda394': { + label: 'Sum of GenAI Total Tokens', + dataType: 'number', + operationType: 'sum', + sourceField: 'kibana.action.execution.gen_ai.usage.total_tokens', + isBucketed: false, + scale: 'ratio', + params: { + emptyAsNull: true, + }, + customLabel: true, + }, + }, + columnOrder: [ + '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519', + 'b0e390e4-d754-4eb4-9fcc-4347dadda394', + ], + sampling: 1, + incompleteColumns: {}, + }, + }, + }, + textBased: { + layers: {}, + }, + }, + internalReferences: [ + { + type: 'index-pattern', + id: ids.totalTokens, + name: 'indexpattern-datasource-layer-475e8ca0-e78e-454a-8597-a5492f70dce3', + }, + ], + adHocDataViews: { + [ids.totalTokens]: { + id: ids.totalTokens, + title: '.kibana-event-log-*', + timeFieldName: '@timestamp', + sourceFilters: [], + fieldFormats: {}, + runtimeFieldMap: { + 'kibana.action.execution.gen_ai.usage.total_tokens': { + type: 'long', + }, + }, + fieldAttrs: {}, + allowNoIndex: false, + name: 'Event Log', + }, + }, + }, + }, + hidePanelTitles: false, + enhancements: {}, + }, + title: 'Total Tokens per User', + }, + ]), + timeRestore: false, + title: dashboardTitle, + version: 1, + }, + coreMigrationVersion: '8.8.0', + created_at: '2023-06-01T19:00:04.629Z', + id: ids.genAiSavedObjectId, + managed: false, + type: 'dashboard', + typeMigrationVersion: '8.7.0', + updated_at: '2023-06-01T19:00:04.629Z', + references: [], + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts index 1033dda773d0a..1accca9ba9dd0 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.test.ts @@ -11,6 +11,8 @@ import { GEN_AI_CONNECTOR_ID, OpenAiProviderType } from '../../../common/gen_ai/ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { GenAiRunActionResponseSchema } from '../../../common/gen_ai/schema'; +import { initGenAiDashboard } from './create_dashboard'; +jest.mock('./create_dashboard'); describe('GenAiConnector', () => { const sampleBody = JSON.stringify({ @@ -96,4 +98,80 @@ describe('GenAiConnector', () => { expect(response).toEqual({ result: 'success' }); }); }); + + describe('Token dashboard', () => { + const connector = new GenAiConnector({ + configurationUtilities: actionsConfigMock.create(), + connector: { id: '1', type: GEN_AI_CONNECTOR_ID }, + config: { apiUrl: 'https://example.com/api', apiProvider: OpenAiProviderType.AzureAi }, + secrets: { apiKey: '123' }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + const mockGenAi = initGenAiDashboard as jest.Mock; + beforeEach(() => { + // @ts-ignore + connector.esClient.transport.request = mockRequest; + mockRequest.mockResolvedValue({ has_all_requested: true }); + mockGenAi.mockResolvedValue({ success: true }); + jest.clearAllMocks(); + }); + it('the create dashboard API call returns available: true when user has correct permissions', async () => { + const response = await connector.getDashboard({ dashboardId: '123' }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + index: [ + { + names: ['.kibana-event-log-*'], + allow_restricted_indices: true, + privileges: ['read'], + }, + ], + }, + }); + expect(response).toEqual({ available: true }); + }); + it('the create dashboard API call returns available: false when user has correct permissions', async () => { + mockRequest.mockResolvedValue({ has_all_requested: false }); + const response = await connector.getDashboard({ dashboardId: '123' }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + index: [ + { + names: ['.kibana-event-log-*'], + allow_restricted_indices: true, + privileges: ['read'], + }, + ], + }, + }); + expect(response).toEqual({ available: false }); + }); + + it('the create dashboard API call returns available: false when init dashboard fails', async () => { + mockGenAi.mockResolvedValue({ success: false }); + const response = await connector.getDashboard({ dashboardId: '123' }); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + index: [ + { + names: ['.kibana-event-log-*'], + allow_restricted_indices: true, + privileges: ['read'], + }, + ], + }, + }); + expect(response).toEqual({ available: false }); + }); + }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts index f9a5d5112ed89..797dbdd0ee569 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/gen_ai/gen_ai.ts @@ -7,9 +7,11 @@ import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import type { AxiosError } from 'axios'; +import { initGenAiDashboard } from './create_dashboard'; import { GenAiRunActionParamsSchema, GenAiRunActionResponseSchema, + GenAiDashboardActionParamsSchema, } from '../../../common/gen_ai/schema'; import type { GenAiConfig, @@ -18,6 +20,10 @@ import type { GenAiRunActionResponse, } from '../../../common/gen_ai/types'; import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants'; +import { + GenAiDashboardActionParams, + GenAiDashboardActionResponse, +} from '../../../common/gen_ai/types'; export class GenAiConnector extends SubActionConnector { private url; @@ -46,6 +52,12 @@ export class GenAiConnector extends SubActionConnector { + const privilege = (await this.esClient.transport.request({ + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + index: [ + { + names: ['.kibana-event-log-*'], + allow_restricted_indices: true, + privileges: ['read'], + }, + ], + }, + })) as { has_all_requested: boolean }; + + if (!privilege?.has_all_requested) { + return { available: false }; + } + + const response = await initGenAiDashboard({ + logger: this.logger, + savedObjectsClient: this.savedObjectsClient, + dashboardId, + }); + + return { available: response.success }; + } } diff --git a/x-pack/plugins/stack_connectors/tsconfig.json b/x-pack/plugins/stack_connectors/tsconfig.json index f95d55265cebf..7cc6696f04368 100644 --- a/x-pack/plugins/stack_connectors/tsconfig.json +++ b/x-pack/plugins/stack_connectors/tsconfig.json @@ -27,6 +27,12 @@ "@kbn/test-jest-helpers", "@kbn/securitysolution-io-ts-utils", "@kbn/safer-lodash-set", + "@kbn/dashboard-plugin", + "@kbn/core-http-browser", + "@kbn/core-saved-objects-api-server", + "@kbn/core-saved-objects-common", + "@kbn/core-http-browser-mocks", + "@kbn/core-saved-objects-api-server-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/triggers_actions_ui/kibana.jsonc b/x-pack/plugins/triggers_actions_ui/kibana.jsonc index 77dceae07cd2c..b11efd92eb7f2 100644 --- a/x-pack/plugins/triggers_actions_ui/kibana.jsonc +++ b/x-pack/plugins/triggers_actions_ui/kibana.jsonc @@ -21,7 +21,8 @@ "dataViews", "dataViewEditor", "alerting", - "actions" + "actions", + "dashboard", ], "optionalPlugins": [ "cloud", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 5b04a6bb5d68a..f389d180772dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -28,6 +28,7 @@ import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { ruleDetailsRoute } from '@kbn/rule-data-utils'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { ActionTypeRegistryContract, @@ -52,6 +53,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; + dashboard: DashboardStart; charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/connectors_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/connectors_app.tsx index 8790b0bea8b87..985a4670122d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/connectors_app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/connectors_app.tsx @@ -26,6 +26,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { ActionTypeRegistryContract, @@ -47,6 +48,7 @@ export interface TriggersAndActionsUiServices extends CoreStart { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; + dashboard: DashboardStart; charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts index 0486912ceacae..b145aab0116b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/data_apis.ts @@ -30,6 +30,7 @@ export async function getMatchingIndices({ http: HttpSetup; }): Promise> { try { + // prepend and append index search requests with `*` to match the given text in middle of index names const formattedPattern = formatPattern(pattern); const { indices } = await http.post>( diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts index c6b8a8376183a..0d441d09e038f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -10,6 +10,7 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; +import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { TriggersAndActionsUiServices } from '../../../application/app'; @@ -40,6 +41,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => { }, history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), + dashboard: dashboardPluginMock.createStartContract(), data: dataPluginMock.createStartContract(), dataViews: dataViewPluginMocks.createStartContract(), dataViewEditor: { diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 56ef2665052be..ce838c93ef16a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -24,6 +24,7 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { triggersActionsRoute } from '@kbn/rule-data-utils'; +import { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar'; import { TypeRegistry } from './application/type_registry'; @@ -148,6 +149,7 @@ interface PluginsStart { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; + dashboard: DashboardStart; charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; @@ -259,6 +261,7 @@ export class Plugin return renderApp({ ...coreStart, actions: plugins.actions, + dashboard: pluginsStart.dashboard, data: pluginsStart.data, dataViews: pluginsStart.dataViews, dataViewEditor: pluginsStart.dataViewEditor, @@ -306,6 +309,7 @@ export class Plugin return renderApp({ ...coreStart, actions: plugins.actions, + dashboard: pluginsStart.dashboard, data: pluginsStart.data, dataViews: pluginsStart.dataViews, dataViewEditor: pluginsStart.dataViewEditor, diff --git a/x-pack/plugins/triggers_actions_ui/tsconfig.json b/x-pack/plugins/triggers_actions_ui/tsconfig.json index 3222559bf2b9f..c49230eab3fea 100644 --- a/x-pack/plugins/triggers_actions_ui/tsconfig.json +++ b/x-pack/plugins/triggers_actions_ui/tsconfig.json @@ -52,6 +52,7 @@ "@kbn/ecs", "@kbn/alerts-as-data-utils", "@kbn/core-ui-settings-common", + "@kbn/dashboard-plugin", ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/alerting_api_integration/common/lib/object_remover.ts b/x-pack/test/alerting_api_integration/common/lib/object_remover.ts index 8f0c832ade73b..4528d9cd987f2 100644 --- a/x-pack/test/alerting_api_integration/common/lib/object_remover.ts +++ b/x-pack/test/alerting_api_integration/common/lib/object_remover.ts @@ -41,7 +41,7 @@ export class ObjectRemover { `${getUrlPrefix(spaceId)}/${isInternal ? 'internal' : 'api'}/${plugin}/${type}/${id}` ) .set('kbn-xsrf', 'foo') - .expect(204); + .expect(plugin === 'saved_objects' ? 200 : 204); }) ); this.objectsToRemove = []; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts index 14101b338ba8d..828e19470f4fe 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/gen_ai.ts @@ -12,6 +12,7 @@ import { genAiSuccessResponse, } from '@kbn/actions-simulators-plugin/server/gen_ai_simulation'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; const connectorTypeId = '.gen-ai'; const name = 'A genAi action'; @@ -24,11 +25,13 @@ const defaultConfig = { apiProvider: 'OpenAI' }; // eslint-disable-next-line import/no-default-export export default function genAiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const objectRemover = new ObjectRemover(supertest); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const configService = getService('config'); - - const createConnector = async (apiUrl: string) => { + const retry = getService('retry'); + const createConnector = async (apiUrl: string, spaceId?: string) => { const { body } = await supertest - .post('/api/actions/connector') + .post(`${getUrlPrefix(spaceId ?? 'default')}/api/actions/connector`) .set('kbn-xsrf', 'foo') .send({ name, @@ -38,10 +41,15 @@ export default function genAiTest({ getService }: FtrProviderContext) { }) .expect(200); + objectRemover.add(spaceId ?? 'default', body.id, 'connector', 'actions'); + return body.id; }; describe('GenAi', () => { + after(() => { + objectRemover.removeAll(); + }); describe('action creation', () => { const simulator = new GenAiSimulator({ returnError: false, @@ -271,6 +279,118 @@ export default function genAiTest({ getService }: FtrProviderContext) { data: genAiSuccessResponse, }); }); + describe('gen ai dashboard', () => { + const dashboardId = 'specific-dashboard-id-default'; + + it('should not create a dashboard when user does not have kibana event log permissions', async () => { + const { body } = await supertestWithoutAuth + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .auth('global_read', 'global_read-password') + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getDashboard', + subActionParams: { + dashboardId, + }, + }, + }) + .expect(200); + + // check dashboard has not been created + await supertest + .get(`/api/saved_objects/dashboard/${dashboardId}`) + .set('kbn-xsrf', 'foo') + .expect(404); + + expect(body).to.eql({ + status: 'ok', + connector_id: genAiActionId, + data: { available: false }, + }); + }); + + it('should create a dashboard when user has correct permissions', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getDashboard', + subActionParams: { + dashboardId, + }, + }, + }) + .expect(200); + + // check dashboard has been created + await retry.try(async () => + supertest + .get(`/api/saved_objects/dashboard/${dashboardId}`) + .set('kbn-xsrf', 'foo') + .expect(200) + ); + + objectRemover.add('default', dashboardId, 'dashboard', 'saved_objects'); + + expect(body).to.eql({ + status: 'ok', + connector_id: genAiActionId, + data: { available: true }, + }); + }); + }); + }); + describe('non-default space simulator', () => { + const simulator = new GenAiSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let apiUrl: string; + let genAiActionId: string; + + before(async () => { + apiUrl = await simulator.start(); + genAiActionId = await createConnector(apiUrl, 'space1'); + }); + after(() => { + simulator.close(); + }); + + const dashboardId = 'specific-dashboard-id-space1'; + + it('should create a dashboard in non-default space', async () => { + const { body } = await supertest + .post(`${getUrlPrefix('space1')}/api/actions/connector/${genAiActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'getDashboard', + subActionParams: { + dashboardId, + }, + }, + }) + .expect(200); + + // check dashboard has been created + await retry.try( + async () => + await supertest + .get(`${getUrlPrefix('space1')}/api/saved_objects/dashboard/${dashboardId}`) + .set('kbn-xsrf', 'foo') + .expect(200) + ); + objectRemover.add('space1', dashboardId, 'dashboard', 'saved_objects'); + + expect(body).to.eql({ + status: 'ok', + connector_id: genAiActionId, + data: { available: true }, + }); + }); }); describe('error response simulator', () => {