mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security solution] Implement dashboard to track Gen AI Token Usage (#159075)
This commit is contained in:
parent
03f86a9450
commit
6c80b49a15
30 changed files with 1475 additions and 222 deletions
|
@ -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<Config, Secrets> {
|
|||
private subActions: Map<string, SubAction> = new Map();
|
||||
private configurationUtilities: ActionsConfigurationUtilities;
|
||||
protected logger: Logger;
|
||||
protected esClient: ElasticsearchClient;
|
||||
protected savedObjectsClient: SavedObjectsClientContract;
|
||||
protected connector: ServiceParams<Config, Secrets>['connector'];
|
||||
protected config: Config;
|
||||
protected secrets: Secrets;
|
||||
|
@ -37,6 +41,8 @@ export abstract class SubActionConnector<Config, Secrets> {
|
|||
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();
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -11,9 +11,13 @@ import {
|
|||
GenAiSecretsSchema,
|
||||
GenAiRunActionParamsSchema,
|
||||
GenAiRunActionResponseSchema,
|
||||
GenAiDashboardActionParamsSchema,
|
||||
GenAiDashboardActionResponseSchema,
|
||||
} from './schema';
|
||||
|
||||
export type GenAiConfig = TypeOf<typeof GenAiConfigSchema>;
|
||||
export type GenAiSecrets = TypeOf<typeof GenAiSecretsSchema>;
|
||||
export type GenAiRunActionParams = TypeOf<typeof GenAiRunActionParamsSchema>;
|
||||
export type GenAiRunActionResponse = TypeOf<typeof GenAiRunActionResponseSchema>;
|
||||
export type GenAiDashboardActionParams = TypeOf<typeof GenAiDashboardActionParamsSchema>;
|
||||
export type GenAiDashboardActionResponse = TypeOf<typeof GenAiDashboardActionResponseSchema>;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<ActionTypeExecutorResult<{ available: boolean }>> {
|
||||
const res = await http.post<ConnectorExecutorResult<{ available: boolean }>>(
|
||||
`${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
params: { subAction: SUB_ACTION.DASHBOARD, subActionParams: { dashboardId } },
|
||||
}),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
return rewriteResponseToCamelCase(res);
|
||||
}
|
|
@ -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<typeof useKibana>;
|
||||
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(
|
||||
<ConnectorFormTestProvider connector={actionConnector}>
|
||||
<ConnectorFormTestProvider connector={openAiConnector}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
|
@ -37,31 +64,18 @@ describe('GenerativeAiConnectorFields renders', () => {
|
|||
</ConnectorFormTestProvider>
|
||||
);
|
||||
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(
|
||||
<ConnectorFormTestProvider connector={actionConnector}>
|
||||
<ConnectorFormTestProvider connector={azureConnector}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
|
@ -69,31 +83,72 @@ describe('GenerativeAiConnectorFields renders', () => {
|
|||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ConnectorFormTestProvider connector={openAiConnector}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
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(
|
||||
<ConnectorFormTestProvider connector={openAiConnector}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument();
|
||||
});
|
||||
it('Renders if isEdit is true and dashboardUrl is defined', async () => {
|
||||
const { getByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={openAiConnector}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={true}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
expect(getByTestId('link-gen-ai-token-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
it('On click triggers redirect with correct saved object id', async () => {
|
||||
const { getByTestId } = render(
|
||||
<ConnectorFormTestProvider connector={openAiConnector}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={true}
|
||||
registerPreSubmitValidator={() => {}}
|
||||
/>
|
||||
</ConnectorFormTestProvider>
|
||||
);
|
||||
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(
|
||||
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
|
||||
<ConnectorFormTestProvider connector={openAiConnector} onSubmit={onSubmit}>
|
||||
<GenerativeAiConnectorFields
|
||||
readOnly={false}
|
||||
isEdit={false}
|
||||
|
@ -119,16 +174,16 @@ describe('GenerativeAiConnectorFields renders', () => {
|
|||
});
|
||||
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.openAiDocumentation"
|
||||
values={{
|
||||
genAiAPIUrlDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="open-ai-api-doc"
|
||||
href="https://platform.openai.com/docs/api-reference"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const azureAiConfig: ConfigFieldSchema[] = [
|
||||
{
|
||||
id: 'apiUrl',
|
||||
label: i18n.API_URL_LABEL,
|
||||
isUrlField: true,
|
||||
defaultValue: DEFAULT_URL_AZURE,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The Azure OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.azureAiDocumentation"
|
||||
values={{
|
||||
genAiAPIUrlDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="azure-ai-api-doc"
|
||||
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const openAiSecrets: SecretsFieldSchema[] = [
|
||||
{
|
||||
id: 'apiKey',
|
||||
label: i18n.API_KEY_LABEL,
|
||||
isPasswordField: true,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The OpenAI API authentication key for HTTP Basic authentication. For more details about generating OpenAI API keys, refer to the {genAiAPIKeyDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.openAiApiKeyDocumentation"
|
||||
values={{
|
||||
genAiAPIKeyDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="open-ai-api-keys-doc"
|
||||
href="https://platform.openai.com/account/api-keys"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const azureAiSecrets: SecretsFieldSchema[] = [
|
||||
{
|
||||
id: 'apiKey',
|
||||
label: i18n.API_KEY_LABEL,
|
||||
isPasswordField: true,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The Azure API key for HTTP Basic authentication. For more details about generating Azure OpenAI API keys, refer to the {genAiAPIKeyDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.azureAiApiKeyDocumentation"
|
||||
values={{
|
||||
genAiAPIKeyDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="azure-ai-api-keys-doc"
|
||||
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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<ActionConnectorFieldsProps> = ({
|
||||
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<OpenAiProviderType>('config.apiProvider') ?? OpenAiProviderType.OpenAi,
|
||||
|
@ -199,6 +107,11 @@ const GenerativeAiConnectorFields: React.FC<ActionConnectorFieldsProps> = ({
|
|||
secretsFormSchema={azureAiSecrets}
|
||||
/>
|
||||
)}
|
||||
{isEdit && dashboardUrl != null && (
|
||||
<EuiLink data-test-subj="link-gen-ai-token-dashboard" onClick={onClick}>
|
||||
{i18n.USAGE_DASHBOARD_LINK(selectedProviderDefaultValue, name)}
|
||||
</EuiLink>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
}]
|
||||
}`;
|
|
@ -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: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.openAiDocumentation"
|
||||
values={{
|
||||
genAiAPIUrlDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="open-ai-api-doc"
|
||||
href="https://platform.openai.com/docs/api-reference"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const azureAiConfig: ConfigFieldSchema[] = [
|
||||
{
|
||||
id: 'apiUrl',
|
||||
label: i18n.API_URL_LABEL,
|
||||
isUrlField: true,
|
||||
defaultValue: DEFAULT_URL_AZURE,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The Azure OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.azureAiDocumentation"
|
||||
values={{
|
||||
genAiAPIUrlDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="azure-ai-api-doc"
|
||||
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const openAiSecrets: SecretsFieldSchema[] = [
|
||||
{
|
||||
id: 'apiKey',
|
||||
label: i18n.API_KEY_LABEL,
|
||||
isPasswordField: true,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The OpenAI API authentication key for HTTP Basic authentication. For more details about generating OpenAI API keys, refer to the {genAiAPIKeyDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.openAiApiKeyDocumentation"
|
||||
values={{
|
||||
genAiAPIKeyDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="open-ai-api-keys-doc"
|
||||
href="https://platform.openai.com/account/api-keys"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const azureAiSecrets: SecretsFieldSchema[] = [
|
||||
{
|
||||
id: 'apiKey',
|
||||
label: i18n.API_KEY_LABEL,
|
||||
isPasswordField: true,
|
||||
helpText: (
|
||||
<FormattedMessage
|
||||
defaultMessage="The Azure API key for HTTP Basic authentication. For more details about generating Azure OpenAI API keys, refer to the {genAiAPIKeyDocs}."
|
||||
id="xpack.stackConnectors.components.genAi.azureAiApiKeyDocumentation"
|
||||
values={{
|
||||
genAiAPIKeyDocs: (
|
||||
<EuiLink
|
||||
data-test-subj="azure-ai-api-keys-doc"
|
||||
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication"
|
||||
target="_blank"
|
||||
>
|
||||
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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}`;
|
|
@ -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(
|
||||
<GenerativeAiParamsFields
|
||||
actionParams={actionParams}
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: { body: '{"message": "test"}' },
|
||||
}}
|
||||
errors={{ body: [] }}
|
||||
editAction={() => {}}
|
||||
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(
|
||||
<GenerativeAiParamsFields
|
||||
actionParams={actionParams}
|
||||
actionParams={{
|
||||
subAction: SUB_ACTION.RUN,
|
||||
subActionParams: {
|
||||
body: '{"key": "value"}',
|
||||
},
|
||||
}}
|
||||
editAction={editAction}
|
||||
index={0}
|
||||
messageVariables={messageVariables}
|
||||
|
|
|
@ -18,13 +18,6 @@ export const API_KEY_LABEL = i18n.translate('xpack.stackConnectors.components.ge
|
|||
defaultMessage: 'API Key',
|
||||
});
|
||||
|
||||
export const API_PROVIDER_HEADING = i18n.translate(
|
||||
'xpack.stackConnectors.components.genAi.providerHeading',
|
||||
{
|
||||
defaultMessage: 'OpenAI provider',
|
||||
}
|
||||
);
|
||||
|
||||
export const API_PROVIDER_LABEL = i18n.translate(
|
||||
'xpack.stackConnectors.components.genAi.apiProviderLabel',
|
||||
{
|
||||
|
@ -87,3 +80,16 @@ export const API_PROVIDER_REQUIRED = i18n.translate(
|
|||
defaultMessage: 'API provider is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const USAGE_DASHBOARD_LINK = (apiProvider: string, connectorName: string) =>
|
||||
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.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<string | null>(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<string | null>(null);
|
||||
const [dashboardCheckComplete, setDashboardCheckComplete] = useState<boolean>(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,
|
||||
};
|
||||
};
|
|
@ -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<Logger>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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<DashboardAttributes>('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<DashboardAttributes>(
|
||||
'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 },
|
||||
};
|
||||
}
|
||||
};
|
|
@ -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<DashboardAttributes> => {
|
||||
const ids: Record<string, string> = {
|
||||
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: [],
|
||||
};
|
||||
};
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<GenAiConfig, GenAiSecrets> {
|
||||
private url;
|
||||
|
@ -46,6 +52,12 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
method: 'runApi',
|
||||
schema: GenAiRunActionParamsSchema,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: SUB_ACTION.DASHBOARD,
|
||||
method: 'getDashboard',
|
||||
schema: GenAiDashboardActionParamsSchema,
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError): string {
|
||||
|
@ -73,4 +85,34 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
|
|||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getDashboard({
|
||||
dashboardId,
|
||||
}: GenAiDashboardActionParams): Promise<GenAiDashboardActionResponse> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -21,7 +21,8 @@
|
|||
"dataViews",
|
||||
"dataViewEditor",
|
||||
"alerting",
|
||||
"actions"
|
||||
"actions",
|
||||
"dashboard",
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"cloud",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -30,6 +30,7 @@ export async function getMatchingIndices({
|
|||
http: HttpSetup;
|
||||
}): Promise<Record<string, any>> {
|
||||
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<ReturnType<typeof getMatchingIndices>>(
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -52,6 +52,7 @@
|
|||
"@kbn/ecs",
|
||||
"@kbn/alerts-as-data-utils",
|
||||
"@kbn/core-ui-settings-common",
|
||||
"@kbn/dashboard-plugin",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue