[Automatic Import] Migrating to UX design and adding support for generating auth (#202587)

## Summary

This PR adds authentication to the generated CEL programs, and overhauls
the UI flow to be closer to the forthcoming UX design.

## Details 

This PR provides the following updates related to CEL generation:

1. Adds support for generating auth - basic, oauth2, digest and api
tokens
2. Adds new tooling for working with the OpenAPI specs and support for
reducing the spec to minimally required information to the LLM (new
Kibana dep on [oas](https://www.npmjs.com/package/oas))
3. Addresses various feedback around the generated CEL program (error
handling, cursor, trimming the state.url, etc)
4. Migrates the CEL flow to be closer to the forthcoming design
specified by UX, now within a flyout on the datastream step.
5. Removes the dependency on the CEL generation feature flag

## Current screenshots

<details>
  <summary>Click me</summary>
  
the datastream setup page:
<img width="1724" alt="Screenshot 2024-12-13 at 4 33 28 PM"
src="https://github.com/user-attachments/assets/2d35f448-c5c4-4891-92fc-393f83549213"
/>

the user selects the cel input and the button to configure shows up:
<img width="1725" alt="Screenshot 2024-12-13 at 4 33 49 PM"
src="https://github.com/user-attachments/assets/e55be532-5eaa-4a46-80f1-16dd82430fc4"
/>

upon clicking the button, the flyout opens:
<img width="1722" alt="Screenshot 2024-12-13 at 4 34 02 PM"
src="https://github.com/user-attachments/assets/269248cb-21e7-4ebf-86af-f031facb5822"
/>

the user can upload the spec file (a json or yaml openapi file):
<img width="1722" alt="Screenshot 2024-12-13 at 4 34 30 PM"
src="https://github.com/user-attachments/assets/5f996ff3-194a-416b-a1ae-ba0d5ef89a1a"
/>

the llm will suggest paths to use, or the user can select to enter
manually and view all the GETs
<img width="865" alt="Screenshot 2024-12-13 at 4 35 26 PM"
src="https://github.com/user-attachments/assets/a0ad6d6f-5d82-442a-8f2c-235190b2078c"
/>

we will also suggest an auth method based on the spec, but allow the
user to select otherwise if they want:
<img width="1723" alt="Screenshot 2024-12-13 at 4 35 37 PM"
src="https://github.com/user-attachments/assets/840b0201-cae2-4313-bf5d-d7b3ab2034ed"
/>

if they choose an auth the spec doesn't specify, we will warn but not
block:
<img width="1336" alt="Screenshot 2024-12-16 at 9 07 52 AM"
src="https://github.com/user-attachments/assets/c2fb04b5-3d98-4c70-95b2-2fab259c0702"
/>

once path and auth are selected, they can save and continue (generate
the cel config):
<img width="1722" alt="Screenshot 2024-12-13 at 4 35 50 PM"
src="https://github.com/user-attachments/assets/3e54a435-3ddf-4e64-81ab-49dc25420210"
/>

generating:
<img width="1724" alt="Screenshot 2024-12-13 at 4 36 18 PM"
src="https://github.com/user-attachments/assets/0772c016-078c-44cb-ad72-b096f7d635e2"
/>

all configured:
<img width="1720" alt="Screenshot 2024-12-13 at 4 36 35 PM"
src="https://github.com/user-attachments/assets/5f92979c-1f40-43e3-90f3-941c20c99cc7"
/>

</details>

## Sample results 

> **_Note:_** All these sample integrations are built with the teleport
log samples.

### API key

[eset.json](https://github.com/user-attachments/files/18151638/eset.json)

[eset___api_key-1.0.0.zip](https://github.com/user-attachments/files/18151622/eset___api_key-1.0.0.zip)

### OAuth2

[bitwarden.json](https://github.com/user-attachments/files/18151635/bitwarden.json)

[bitwarden___oauth-1.0.0.zip](https://github.com/user-attachments/files/18151618/bitwarden___oauth-1.0.0.zip)

### Basic 

[sumlogic-api.yaml.zip](https://github.com/user-attachments/files/18151650/sumlogic-api.yaml.zip)

[sumologic___basic-1.0.0.zip](https://github.com/user-attachments/files/18151630/sumologic___basic-1.0.0.zip)

Relates:
- https://github.com/elastic/kibana/issues/197651
- https://github.com/elastic/kibana/issues/197653

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ilya Nikokoshev <ilya.nikokoshev@elastic.co>
This commit is contained in:
Kylie Meli 2025-01-10 12:09:39 -05:00 committed by GitHub
parent baf79bcd35
commit 0585712012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 5239 additions and 1095 deletions

View file

@ -1173,6 +1173,7 @@
"json-stable-stringify": "^1.0.1",
"json-stringify-pretty-compact": "1.2.0",
"json-stringify-safe": "5.0.1",
"jsonpath-plus": "^10.2.0",
"jsonwebtoken": "^9.0.2",
"jsts": "^1.6.2",
"kea": "^2.6.0",
@ -1204,6 +1205,7 @@
"nodemailer": "^6.9.15",
"normalize-path": "^3.0.0",
"nunjucks": "^3.2.4",
"oas": "^25.2.0",
"object-hash": "^1.3.1",
"object-path-immutable": "^3.1.1",
"openai": "^4.72.0",

View file

@ -23845,7 +23845,6 @@
"xpack.ingestPipelines.testPipelineFlyout.successNotificationText": "Pipeline exécuté",
"xpack.ingestPipelines.testPipelineFlyout.title": "Pipeline de test",
"xpack.integrationAssistant.bottomBar.addToElastic": "Ajouter à Elastic",
"xpack.integrationAssistant.bottomBar.analyzeCel": "Générer la configuration d'entrée CEL",
"xpack.integrationAssistant.bottomBar.analyzeLogs": "Analyser les logs",
"xpack.integrationAssistant.bottomBar.close": "Fermer",
"xpack.integrationAssistant.bottomBar.loading": "Chargement",
@ -23901,17 +23900,6 @@
"xpack.integrationAssistant.missingPrivileges.title": "Privilèges manquants",
"xpack.integrationAssistant.pages.header.avatarTitle": "Alimenté par lIA générative",
"xpack.integrationAssistant.pages.header.title": "Nouvelle intégration",
"xpack.integrationAssistant.step.celInput.apiDefinition.description": "Glissez et déposez un fichier ou parcourez les fichiers.",
"xpack.integrationAssistant.step.celInput.apiDefinition.description2": "Spécifications relatives à OpenAPI",
"xpack.integrationAssistant.step.celInput.apiDefinition.label": "Spéc. relatives à OpenAPI",
"xpack.integrationAssistant.step.celInput.celInputDescription": "Chargez un fichier de spécifications OpenAPI pour générer une configuration pour l'entrée CEL",
"xpack.integrationAssistant.step.celInput.celInputTitle": "Générer la configuration d'entrée CEL",
"xpack.integrationAssistant.step.celInput.generationError": "Une erreur s'est produite durant : Génération d'entrée CEL",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotRead": "Impossible de lire le fichier de logs exemple",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotReadWithReason": "Une erreur s'est produite lors de la lecture du fichier de spécifications : {reason}",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorTooLargeToParse": "Ce fichier de spécifications est trop volumineux pour être analysé",
"xpack.integrationAssistant.step.celInput.progress.relatedGraph": "Génération de la configuration d'entrée CEL",
"xpack.integrationAssistant.step.celInput.retryButtonLabel": "Réessayer",
"xpack.integrationAssistant.step.connector": "Connecteur",
"xpack.integrationAssistant.step.dataStream": "Flux de données",
"xpack.integrationAssistant.step.dataStream.analyzing": "Analyse",
@ -23963,12 +23951,6 @@
"xpack.integrationAssistant.step.review.ingestPipelineTitle": "Pipeline d'ingestion",
"xpack.integrationAssistant.step.review.save": "Enregistrer",
"xpack.integrationAssistant.step.review.title": "Examiner les résultats",
"xpack.integrationAssistant.step.reviewCel.description": "Vérifiez les paramètres de configuration d'entrée CEL générés pour votre intégration. Ces paramètres seront automatiquement renseignés dans la configuration d'intégration où la modification sera possible.",
"xpack.integrationAssistant.step.reviewCel.program": "Le programme CEL à exécuter pour chaque sondage",
"xpack.integrationAssistant.step.reviewCel.redact": "Champs à adapter",
"xpack.integrationAssistant.step.reviewCel.save": "Enregistrer",
"xpack.integrationAssistant.step.reviewCel.state": "État initial de l'évaluation du CEL",
"xpack.integrationAssistant.step.reviewCel.title": "Examiner les résultats",
"xpack.integrationAssistant.steps.connector.createConnectorLabel": "Créer un nouveau connecteur",
"xpack.integrationAssistant.steps.connector.description": "Sélectionnez un connecteur dIA pour vous aider à créer votre intégration personnalisée",
"xpack.integrationAssistant.steps.connector.supportedModelsInfo": "Pour une expérience optimale, nous recommandons actuellement d'utiliser un fournisseur prenant en charge les nouveaux modèles Claude. Nous travaillons actuellement à l'ajout d'une meilleure prise en charge de GPT-4 et de modèles similaires",

View file

@ -23706,7 +23706,6 @@
"xpack.ingestPipelines.testPipelineFlyout.successNotificationText": "パイプラインが実行されました",
"xpack.ingestPipelines.testPipelineFlyout.title": "パイプラインをテスト",
"xpack.integrationAssistant.bottomBar.addToElastic": "Elasticに追加",
"xpack.integrationAssistant.bottomBar.analyzeCel": "CEL入力構成を生成",
"xpack.integrationAssistant.bottomBar.analyzeLogs": "ログを分析",
"xpack.integrationAssistant.bottomBar.close": "閉じる",
"xpack.integrationAssistant.bottomBar.loading": "読み込み中",
@ -23762,17 +23761,6 @@
"xpack.integrationAssistant.missingPrivileges.title": "不足している権限",
"xpack.integrationAssistant.pages.header.avatarTitle": "生成AIを活用",
"xpack.integrationAssistant.pages.header.title": "新しい統合",
"xpack.integrationAssistant.step.celInput.apiDefinition.description": "ファイルをドラッグアンドドロップするか、ファイルを参照します。",
"xpack.integrationAssistant.step.celInput.apiDefinition.description2": "OpenAPI仕様",
"xpack.integrationAssistant.step.celInput.apiDefinition.label": "OpenAPI仕様",
"xpack.integrationAssistant.step.celInput.celInputDescription": "OpenAPI仕様ファイルをアップロードして、CEL入力の構成を生成",
"xpack.integrationAssistant.step.celInput.celInputTitle": "CEL入力構成を生成",
"xpack.integrationAssistant.step.celInput.generationError": "エラーが発生しましたCEL入力生成",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotRead": "ログサンプルファイルを読み取れませんでした",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotReadWithReason": "仕様ファイルの読み取り中にエラーが発生しました:{reason}",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorTooLargeToParse": "この仕様ファイルは大きすぎて解析できません",
"xpack.integrationAssistant.step.celInput.progress.relatedGraph": "CEL入力構成を生成中",
"xpack.integrationAssistant.step.celInput.retryButtonLabel": "再試行",
"xpack.integrationAssistant.step.connector": "コネクター",
"xpack.integrationAssistant.step.dataStream": "データストリーム",
"xpack.integrationAssistant.step.dataStream.analyzing": "分析中",
@ -23824,12 +23812,6 @@
"xpack.integrationAssistant.step.review.ingestPipelineTitle": "パイプラインを投入",
"xpack.integrationAssistant.step.review.save": "保存",
"xpack.integrationAssistant.step.review.title": "結果を確認",
"xpack.integrationAssistant.step.reviewCel.description": "統合の生成されたCEL入力構成設定を確認してください。これらの設定は、編集が可能な場合、統合構成に自動的に入力されます。",
"xpack.integrationAssistant.step.reviewCel.program": "各ポーリングで実行されるCELプログラム",
"xpack.integrationAssistant.step.reviewCel.redact": "改訂されたフィールド",
"xpack.integrationAssistant.step.reviewCel.save": "保存",
"xpack.integrationAssistant.step.reviewCel.state": "初期CEL評価状態",
"xpack.integrationAssistant.step.reviewCel.title": "結果を確認",
"xpack.integrationAssistant.steps.connector.createConnectorLabel": "新しいコネクターを作成",
"xpack.integrationAssistant.steps.connector.description": "カスタム統合の作成を支援するAIコネクターを選択",
"xpack.integrationAssistant.steps.connector.supportedModelsInfo": "現在、最高のエクスペリエンスが得られるように、新しいClaudeモデルをサポートするプロバイダーを利用することをお勧めします。現在、GPT-4や類似モデルへの改善されたサポートの追加に取り組んでいます。",

View file

@ -23297,7 +23297,6 @@
"xpack.ingestPipelines.testPipelineFlyout.successNotificationText": "管道已执行",
"xpack.ingestPipelines.testPipelineFlyout.title": "测试管道",
"xpack.integrationAssistant.bottomBar.addToElastic": "添加到 Elastic",
"xpack.integrationAssistant.bottomBar.analyzeCel": "生成 CEL 输入配置",
"xpack.integrationAssistant.bottomBar.analyzeLogs": "分析日志",
"xpack.integrationAssistant.bottomBar.close": "关闭",
"xpack.integrationAssistant.bottomBar.loading": "正在加载",
@ -23353,17 +23352,6 @@
"xpack.integrationAssistant.missingPrivileges.title": "缺少权限",
"xpack.integrationAssistant.pages.header.avatarTitle": "由生成式 AI 提供支持",
"xpack.integrationAssistant.pages.header.title": "新集成",
"xpack.integrationAssistant.step.celInput.apiDefinition.description": "拖放文件或浏览文件。",
"xpack.integrationAssistant.step.celInput.apiDefinition.description2": "OpenAPI 规范",
"xpack.integrationAssistant.step.celInput.apiDefinition.label": "OpenAPI 规范",
"xpack.integrationAssistant.step.celInput.celInputDescription": "上传 OpenAPI 规范文件以为 CEL 输入生成配置",
"xpack.integrationAssistant.step.celInput.celInputTitle": "生成 CEL 输入配置",
"xpack.integrationAssistant.step.celInput.generationError": "以下期间发生错误CEL 输入生成",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotRead": "无法读取日志样例文件",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotReadWithReason": "读取规范文件时发生错误:{reason}",
"xpack.integrationAssistant.step.celInput.openapiSpec.errorTooLargeToParse": "此规范文件太大,无法解析",
"xpack.integrationAssistant.step.celInput.progress.relatedGraph": "正在生成 CEL 输入配置",
"xpack.integrationAssistant.step.celInput.retryButtonLabel": "重试",
"xpack.integrationAssistant.step.connector": "连接器",
"xpack.integrationAssistant.step.dataStream": "数据流",
"xpack.integrationAssistant.step.dataStream.analyzing": "正在分析",
@ -23415,12 +23403,6 @@
"xpack.integrationAssistant.step.review.ingestPipelineTitle": "采集管道",
"xpack.integrationAssistant.step.review.save": "保存",
"xpack.integrationAssistant.step.review.title": "复查结果",
"xpack.integrationAssistant.step.reviewCel.description": "查看为您的集成生成的 CEL 输入配置设置。在可以进行编辑的情况下,会将这些设置自动填充到集成配置中。",
"xpack.integrationAssistant.step.reviewCel.program": "要为每次轮询运行的 CEL 程序",
"xpack.integrationAssistant.step.reviewCel.redact": "已编辑字段",
"xpack.integrationAssistant.step.reviewCel.save": "保存",
"xpack.integrationAssistant.step.reviewCel.state": "初始 CEL 评估状态",
"xpack.integrationAssistant.step.reviewCel.title": "复查结果",
"xpack.integrationAssistant.steps.connector.createConnectorLabel": "创建新连接器",
"xpack.integrationAssistant.steps.connector.description": "选择 AI 连接器以帮助您创建定制集成",
"xpack.integrationAssistant.steps.connector.supportedModelsInfo": "当前,我们建议使用支持更新 Claude 模型的提供商,以获得最佳体验。目前,我们正努力为 GPT-4 和类似模型添加更全面的支持",

View file

@ -0,0 +1,22 @@
/*
* 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 { ApiAnalysisState } from '../../server/types';
export const apiAnalysisTestState: ApiAnalysisState = {
dataStreamName: 'testDataStream',
lastExecutedChain: 'testchain',
results: { test: 'testResults' },
pathOptions: { '/path1': 'path1 description', '/path2': 'path2 description' },
suggestedPaths: ['/path1'],
};
export const apiAnalysisPathSuggestionsMockedResponse = ['/path1', '/path2', '/path3', '/path4'];
export const apiAnalysisExpectedResults = {
suggestedPaths: ['/path1', '/path2', '/path3', '/path4'],
};

View file

@ -5,18 +5,25 @@
* 2.0.
*/
export const celTestState = {
import { CelInputState } from '../../server/types';
export const celTestState: CelInputState = {
dataStreamName: 'testDataStream',
apiDefinition: 'apiDefinition',
lastExecutedChain: 'testchain',
finalized: false,
apiQuerySummary: 'testQuerySummary',
exampleCelPrograms: [],
currentProgram: 'testProgram',
stateVarNames: ['testVar'],
stateSettings: { test: 'testDetails' },
configFields: { test: { config1: 'config1testDetails' } },
redactVars: ['testRedact'],
results: { test: 'testResults' },
path: './testPath',
authType: 'basic',
openApiPathDetails: {},
openApiSchemas: {},
openApiAuthSchema: {},
hasProgramHeaders: false,
};
export const celQuerySummaryMockedResponse = `To cover all events in a chronological manner for the device_tasks endpoint, you should use the /v1/device_tasks GET route with pagination parameters. Specifically, use the pageSize and pageToken query parameters. Start with a large pageSize and use the nextPageToken from each response to fetch subsequent pages until all events are retrieved.
@ -83,16 +90,25 @@ export const celStateDetailsMockedResponse = [
name: 'config1',
default: 50,
redact: false,
configurable: true,
description: 'config1 description',
type: 'number',
},
{
name: 'config2',
default: '',
redact: true,
configurable: false,
description: 'config2 description',
type: 'string',
},
{
name: 'config3',
default: 'event',
redact: false,
configurable: false,
description: 'config3 description',
type: 'string',
},
];
@ -102,6 +118,14 @@ export const celStateSettings = {
config3: 'event',
};
export const celConfigFields = {
config1: {
default: 50,
type: 'number',
description: 'config1 description',
},
};
export const celRedact = ['config2'];
export const celExpectedResults = {
@ -111,5 +135,13 @@ export const celExpectedResults = {
config2: '',
config3: 'event',
},
configFields: {
config1: {
default: 50,
type: 'number',
description: 'config1 description',
},
},
needsAuthConfigBlock: false,
redactVars: ['config2'],
};

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Pipeline } from '../../common';
import type { CelAuthType, Pipeline } from '../../common';
const currentPipelineMock: Pipeline = {
description: 'Pipeline to process mysql_enterprise audit logs',
processors: [
@ -53,7 +53,16 @@ export const mockedRequestWithPipeline = {
currentPipeline: currentPipelineMock,
};
export const mockedRequestWithApiDefinition = {
apiDefinition: '{ "openapi": "3.0.0" }',
dataStreamName: 'audit',
export const mockedRequestWithCelDetails = {
dataStreamTitle: 'audit',
path: '/events',
authType: 'basic' as CelAuthType,
openApiDetails: {},
openApiSchema: {},
openApiAuthSchema: {},
};
export const mockedApiAnalysisRequest = {
dataStreamName: 'audit',
pathOptions: { '/path1': 'path1 description' },
};

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Automatic Import Analyze API specifications API endpoint
* version: 1
*/
import { z } from '@kbn/zod';
import { DataStreamTitle, Connector, LangSmithOptions } from '../model/common_attributes.gen';
import { PathOptions } from '../model/cel_input_attributes.gen';
import { AnalyzeApiAPIResponse } from '../model/response_schemas.gen';
export type AnalyzeApiRequestBody = z.infer<typeof AnalyzeApiRequestBody>;
export const AnalyzeApiRequestBody = z.object({
dataStreamTitle: DataStreamTitle,
pathOptions: PathOptions,
connectorId: Connector,
langSmithOptions: LangSmithOptions.optional(),
});
export type AnalyzeApiRequestBodyInput = z.input<typeof AnalyzeApiRequestBody>;
export type AnalyzeApiResponse = z.infer<typeof AnalyzeApiResponse>;
export const AnalyzeApiResponse = AnalyzeApiAPIResponse;

View file

@ -0,0 +1,39 @@
openapi: 3.0.3
info:
title: Automatic Import Analyze API specifications API endpoint
version: "1"
paths:
/internal/automatic_import/analyzeapi:
post:
summary: Analyzes API specifications.
operationId: AnalyzeApi
x-codegen-enabled: true
description: Analyzes API specifications.
tags:
- Analyze API spec
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- dataStreamTitle
- pathOptions
- connectorId
properties:
dataStreamTitle:
$ref: "../model/common_attributes.schema.yaml#/components/schemas/DataStreamTitle"
pathOptions:
$ref: "../model/cel_input_attributes.schema.yaml#/components/schemas/PathOptions"
connectorId:
$ref: "../model/common_attributes.schema.yaml#/components/schemas/Connector"
langSmithOptions:
$ref: "../model/common_attributes.schema.yaml#/components/schemas/LangSmithOptions"
responses:
200:
description: Indicates a successful call.
content:
application/json:
schema:
$ref: "../model/response_schemas.schema.yaml#/components/schemas/AnalyzeApiAPIResponse"

View file

@ -0,0 +1,20 @@
/*
* 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 { expectParseSuccess } from '@kbn/zod-helpers';
import { AnalyzeApiRequestBody } from './analyze_api_route.gen';
import { getAnalyzeApiRequestBody } from '../model/api_test.mock';
describe('Analyze API request schema', () => {
test('full request validate', () => {
const payload: AnalyzeApiRequestBody = getAnalyzeApiRequestBody();
const result = AnalyzeApiRequestBody.safeParse(payload);
expectParseSuccess(result);
expect(result.data).toEqual(payload);
});
});

View file

@ -16,14 +16,14 @@
import { z } from '@kbn/zod';
import { DataStreamName, Connector, LangSmithOptions } from '../model/common_attributes.gen';
import { ApiDefinition } from '../model/cel_input_attributes.gen';
import { DataStreamTitle, Connector, LangSmithOptions } from '../model/common_attributes.gen';
import { CelDetails } from '../model/cel_input_attributes.gen';
import { CelInputAPIResponse } from '../model/response_schemas.gen';
export type CelInputRequestBody = z.infer<typeof CelInputRequestBody>;
export const CelInputRequestBody = z.object({
dataStreamName: DataStreamName,
apiDefinition: ApiDefinition,
dataStreamTitle: DataStreamTitle,
celDetails: CelDetails,
connectorId: Connector,
langSmithOptions: LangSmithOptions.optional(),
});

View file

@ -18,14 +18,14 @@ paths:
schema:
type: object
required:
- apiDefinition
- dataStreamName
- dataStreamTitle
- celDetails
- connectorId
properties:
dataStreamName:
$ref: "../model/common_attributes.schema.yaml#/components/schemas/DataStreamName"
apiDefinition:
$ref: "../model/cel_input_attributes.schema.yaml#/components/schemas/ApiDefinition"
dataStreamTitle:
$ref: "../model/common_attributes.schema.yaml#/components/schemas/DataStreamTitle"
celDetails:
$ref: "../model/cel_input_attributes.schema.yaml#/components/schemas/CelDetails"
connectorId:
$ref: "../model/common_attributes.schema.yaml#/components/schemas/Connector"
langSmithOptions:

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { AnalyzeApiRequestBody } from '../analyze_api/analyze_api_route.gen';
import type { AnalyzeLogsRequestBody } from '../analyze_logs/analyze_logs_route.gen';
import type { BuildIntegrationRequestBody } from '../build_integration/build_integration.gen';
import type { CategorizationRequestBody } from '../categorization/categorization_route.gen';
@ -67,9 +68,17 @@ export const getCategorizationRequestMock = (): CategorizationRequestBody => ({
});
export const getCelRequestMock = (): CelInputRequestBody => ({
dataStreamName: 'test-data-stream-name',
apiDefinition: 'test-api-definition',
dataStreamTitle: 'test-data-stream-title',
connectorId: 'test-connector-id',
celDetails: {
path: 'test-cel-path',
auth: 'basic',
openApiDetails: {
operation: 'test-open-api-operation',
schemas: 'test-open-api-schemas',
auth: 'test-open-api-auth',
},
},
});
export const getBuildIntegrationRequestMock = (): BuildIntegrationRequestBody => ({
@ -101,3 +110,9 @@ export const getAnalyzeLogsRequestBody = (): AnalyzeLogsRequestBody => ({
connectorId: 'test-connector-id',
logSamples: rawSamples,
});
export const getAnalyzeApiRequestBody = (): AnalyzeApiRequestBody => ({
connectorId: 'test-connector-id',
dataStreamTitle: 'test-data-stream-name',
pathOptions: { '/v1/events': 'the path for events', '/v1/logs': 'the path for logs' },
});

View file

@ -16,18 +16,59 @@
import { z } from '@kbn/zod';
export type PathOptions = z.infer<typeof PathOptions>;
export const PathOptions = z.object({}).catchall(z.unknown());
/**
* String form of the Open API schema.
* The type of auth utilized for the input.
*/
export type ApiDefinition = z.infer<typeof ApiDefinition>;
export const ApiDefinition = z.string();
export type CelAuthType = z.infer<typeof CelAuthType>;
export const CelAuthType = z.enum(['basic', 'digest', 'oauth2', 'header']);
export type CelAuthTypeEnum = typeof CelAuthType.enum;
export const CelAuthTypeEnum = CelAuthType.enum;
/**
* Necessary OpenAPI spec details for building a CEL program.
*/
export type OpenApiDetails = z.infer<typeof OpenApiDetails>;
export const OpenApiDetails = z.object({
operation: z.string(),
schemas: z.string(),
auth: z.string().optional(),
});
/**
* Details for building a CEL program.
*/
export type CelDetails = z.infer<typeof CelDetails>;
export const CelDetails = z.object({
path: z.string(),
auth: CelAuthType,
openApiDetails: OpenApiDetails.optional(),
});
/**
* Generated CEL details.
*/
export type GeneratedCelDetails = z.infer<typeof GeneratedCelDetails>;
export const GeneratedCelDetails = z.object({
configFields: z.object({}).catchall(z.unknown()),
program: z.string(),
needsAuthConfigBlock: z.boolean(),
stateSettings: z.object({}).catchall(z.unknown()),
redactVars: z.array(z.string()),
});
/**
* Optional CEL input details.
*/
export type CelInput = z.infer<typeof CelInput>;
export const CelInput = z.object({
authType: CelAuthType,
configFields: z.object({}).catchall(z.unknown()),
needsAuthConfigBlock: z.boolean(),
program: z.string(),
stateSettings: z.object({}).catchall(z.unknown()),
redactVars: z.array(z.string()),
url: z.string(),
});

View file

@ -6,18 +6,82 @@ paths: {}
components:
x-codegen-enabled: true
schemas:
ApiDefinition:
type: string
description: String form of the Open API schema.
PathOptions:
type: object
additionalProperties: true
CelDetails:
type: object
description: Details for building a CEL program.
required:
- path
- auth
properties:
path:
type: string
auth:
$ref: "#/components/schemas/CelAuthType"
openApiDetails:
$ref: "#/components/schemas/OpenApiDetails"
OpenApiDetails:
type: object
description: Necessary OpenAPI spec details for building a CEL program.
required:
- operation
- schemas
properties:
operation:
type: string
schemas:
type: string
auth:
type: string
GeneratedCelDetails:
type: object
description: Generated CEL details.
required:
- configFields
- program
- needsAuthConfigBlock
- stateSettings
- redactVars
properties:
configFields:
type: object
additionalProperties: true
program:
type: string
needsAuthConfigBlock:
type: boolean
stateSettings:
type: object
additionalProperties: true
redactVars:
type: array
items:
type: string
CelInput:
type: object
description: Optional CEL input details.
required:
- authType
- configFields
- program
- needsAuthConfigBlock
- stateSettings
- redactVars
- url
properties:
authType:
$ref: "#/components/schemas/CelAuthType"
configFields:
type: object
additionalProperties: true
needsAuthConfigBlock:
type: boolean
program:
type: string
stateSettings:
@ -27,4 +91,15 @@ components:
type: array
items:
type: string
url:
type: string
CelAuthType:
type: string
description: The type of auth utilized for the input.
enum:
- basic
- digest
- oauth2
- header

View file

@ -18,7 +18,7 @@ import { z } from '@kbn/zod';
import { Mapping, Pipeline, Docs, SamplesFormat } from './common_attributes.gen';
import { ESProcessorItem } from './processor_attributes.gen';
import { CelInput } from './cel_input_attributes.gen';
import { GeneratedCelDetails } from './cel_input_attributes.gen';
export type EcsMappingAPIResponse = z.infer<typeof EcsMappingAPIResponse>;
export const EcsMappingAPIResponse = z.object({
@ -62,5 +62,12 @@ export const AnalyzeLogsAPIResponse = z.object({
export type CelInputAPIResponse = z.infer<typeof CelInputAPIResponse>;
export const CelInputAPIResponse = z.object({
results: CelInput,
results: GeneratedCelDetails,
});
export type AnalyzeApiAPIResponse = z.infer<typeof AnalyzeApiAPIResponse>;
export const AnalyzeApiAPIResponse = z.object({
results: z.object({
suggestedPaths: z.array(z.string()),
}),
});

View file

@ -95,4 +95,19 @@ components:
- results
properties:
results:
$ref: "./cel_input_attributes.schema.yaml#/components/schemas/CelInput"
$ref: "./cel_input_attributes.schema.yaml#/components/schemas/GeneratedCelDetails"
AnalyzeApiAPIResponse:
type: object
required:
- results
properties:
results:
type: object
required:
- suggestedPaths
properties:
suggestedPaths:
type: array
items:
type: string

View file

@ -20,6 +20,7 @@ export const ECS_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/ecs`;
export const CATEGORIZATION_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/categorization`;
export const ANALYZE_LOGS_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/analyzelogs`;
export const RELATED_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/related`;
export const ANALYZE_API_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/analyzeapi`;
export const CEL_INPUT_GRAPH_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/cel`;
export const CHECK_PIPELINE_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/pipeline`;
export const INTEGRATION_BUILDER_PATH = `${INTEGRATION_ASSISTANT_BASE_PATH}/build`;

View file

@ -9,6 +9,7 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues;
const _allowedExperimentalValues = {
/**
* -- Deprecated --
* Enables whether the user is able to utilize the LLM to generate the CEL input configuration.
*/
generateCel: false,

View file

@ -19,6 +19,7 @@ export {
AnalyzeLogsRequestBody,
AnalyzeLogsResponse,
} from './api/analyze_logs/analyze_logs_route.gen';
export { AnalyzeApiRequestBody, AnalyzeApiResponse } from './api/analyze_api/analyze_api_route.gen';
export { CelInputRequestBody, CelInputResponse } from './api/cel/cel_input_route.gen';
export { partialShuffleArray } from './utils';
@ -33,7 +34,7 @@ export type {
} from './api/model/common_attributes.gen';
export { SamplesFormat, SamplesFormatName } from './api/model/common_attributes.gen';
export type { ESProcessorItem } from './api/model/processor_attributes.gen';
export type { CelInput } from './api/model/cel_input_attributes.gen';
export type { CelInput, CelAuthType } from './api/model/cel_input_attributes.gen';
export {
CATEGORIZATION_GRAPH_PATH,
@ -46,4 +47,5 @@ export {
RELATED_GRAPH_PATH,
CHECK_PIPELINE_PATH,
ANALYZE_LOGS_PATH,
ANALYZE_API_PATH,
} from './constants';

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

View file

@ -20,6 +20,8 @@ import type {
AnalyzeLogsResponse,
CelInputRequestBody,
CelInputResponse,
AnalyzeApiRequestBody,
AnalyzeApiResponse,
} from '../../../common';
import {
INTEGRATION_BUILDER_PATH,
@ -29,7 +31,11 @@ import {
CEL_INPUT_GRAPH_PATH,
CHECK_PIPELINE_PATH,
} from '../../../common';
import { ANALYZE_LOGS_PATH, FLEET_PACKAGES_PATH } from '../../../common/constants';
import {
ANALYZE_API_PATH,
ANALYZE_LOGS_PATH,
FLEET_PACKAGES_PATH,
} from '../../../common/constants';
export interface EpmPackageResponse {
items: [{ id: string; type: string }];
@ -47,6 +53,16 @@ export interface RequestDeps {
abortSignal: AbortSignal;
}
export const runAnalyzeApiGraph = async (
body: AnalyzeApiRequestBody,
{ http, abortSignal }: RequestDeps
): Promise<AnalyzeApiResponse> =>
http.post<AnalyzeApiResponse>(ANALYZE_API_PATH, {
headers: defaultHeaders,
body: JSON.stringify(body),
signal: abortSignal,
});
export const runAnalyzeLogsGraph = async (
body: AnalyzeLogsRequestBody,
{ http, abortSignal }: RequestDeps

View file

@ -10,7 +10,6 @@ import { render, act } from '@testing-library/react';
import { TestProvider } from '../../../mocks/test_provider';
import { CreateIntegrationAssistant } from './create_integration_assistant';
import type { State } from './state';
import { ExperimentalFeaturesService } from '../../../services';
import { mockReportEvent } from '../../../services/telemetry/mocks/service';
import { TelemetryEventType } from '../../../services/telemetry/types';
@ -19,8 +18,9 @@ export const defaultInitialState: State = {
connector: undefined,
integrationSettings: undefined,
isGenerating: false,
hasCelInput: false,
result: undefined,
showCelCreateFlyout: false,
isFlyoutGenerating: false,
};
const mockInitialState = jest.fn((): State => defaultInitialState);
@ -31,23 +31,17 @@ jest.mock('./state', () => ({
},
}));
jest.mock('../../../services');
const mockedExperimentalFeaturesService = jest.mocked(ExperimentalFeaturesService);
const mockConnectorStep = jest.fn(() => <div data-test-subj="connectorStepMock" />);
const mockIntegrationStep = jest.fn(() => <div data-test-subj="integrationStepMock" />);
const mockDataStreamStep = jest.fn(() => <div data-test-subj="dataStreamStepMock" />);
const mockCelCreateFlyout = jest.fn(() => <div data-test-subj="celCreateFlyoutMock" />);
const mockReviewStep = jest.fn(() => <div data-test-subj="reviewStepMock" />);
const mockCelInputStep = jest.fn(() => <div data-test-subj="celInputStepMock" />);
const mockReviewCelStep = jest.fn(() => <div data-test-subj="reviewCelStepMock" />);
const mockDeployStep = jest.fn(() => <div data-test-subj="deployStepMock" />);
const mockIsConnectorStepReadyToComplete = jest.fn();
const mockIsIntegrationStepReadyToComplete = jest.fn();
const mockIsDataStreamStepReadyToComplete = jest.fn();
const mockIsReviewStepReadyToComplete = jest.fn();
const mockIsCelInputStepReadyToComplete = jest.fn();
const mockIsCelReviewStepReadyToComplete = jest.fn();
jest.mock('./steps/connector_step', () => ({
ConnectorStep: () => mockConnectorStep(),
@ -61,18 +55,13 @@ jest.mock('./steps/data_stream_step', () => ({
DataStreamStep: () => mockDataStreamStep(),
isDataStreamStepReadyToComplete: () => mockIsDataStreamStepReadyToComplete(),
}));
jest.mock('./flyout/cel_configuration', () => ({
CreateCelConfigFlyout: () => mockCelCreateFlyout(),
}));
jest.mock('./steps/review_step', () => ({
ReviewStep: () => mockReviewStep(),
isReviewStepReadyToComplete: () => mockIsReviewStepReadyToComplete(),
}));
jest.mock('./steps/cel_input_step', () => ({
CelInputStep: () => mockCelInputStep(),
isCelInputStepReadyToComplete: () => mockIsCelInputStepReadyToComplete(),
}));
jest.mock('./steps/review_cel_step', () => ({
ReviewCelStep: () => mockReviewCelStep(),
isCelReviewStepReadyToComplete: () => mockIsCelReviewStepReadyToComplete(),
}));
jest.mock('./steps/deploy_step', () => ({ DeployStep: () => mockDeployStep() }));
const mockNavigate = jest.fn();
@ -87,10 +76,6 @@ const renderIntegrationAssistant = () =>
describe('CreateIntegration', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedExperimentalFeaturesService.get.mockReturnValue({
generateCel: false,
} as never);
});
describe('when step is 1', () => {
@ -440,6 +425,21 @@ describe('CreateIntegration', () => {
});
});
describe('when step is 3 and showCelCreateFlyout=true', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({
...defaultInitialState,
step: 3,
showCelCreateFlyout: true,
});
});
it('should render cel creation flyout', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('celCreateFlyoutMock')).toBeInTheDocument();
});
});
describe('when step is 4', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 4 });
@ -567,289 +567,3 @@ describe('CreateIntegration', () => {
});
});
});
describe('CreateIntegration with generateCel enabled', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedExperimentalFeaturesService.get.mockReturnValue({
generateCel: true,
} as never);
});
describe('when step is 5 and has celInput', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5, hasCelInput: true });
});
it('should render cel input', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('celInputStepMock')).toBeInTheDocument();
});
it('should call isCelInputStepReadyToComplete', () => {
renderIntegrationAssistant();
expect(mockIsCelInputStepReadyToComplete).toHaveBeenCalled();
});
it('should show "Generate CEL input configuration" on the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent(
'Generate CEL input configuration'
);
});
it('should enable the back button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled();
});
describe('when cel input step is not done', () => {
beforeEach(() => {
mockIsCelInputStepReadyToComplete.mockReturnValue(false);
});
it('should disable the next button', () => {
const result = renderIntegrationAssistant();
// Not sure why there are two buttons when testing.
const nextButton = result
.getAllByTestId('buttonsFooter-nextButton')
.filter((button) => button.textContent !== 'Next')[0];
expect(nextButton).toBeDisabled();
});
});
describe('when cel input step is done', () => {
beforeEach(() => {
mockIsCelInputStepReadyToComplete.mockReturnValue(true);
});
it('should enable the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled();
});
describe('when next button is clicked', () => {
beforeEach(() => {
const result = renderIntegrationAssistant();
mockReportEvent.mockClear();
act(() => {
result.getByTestId('buttonsFooter-nextButton').click();
});
});
it('should report telemetry for cel input step completion', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantStepComplete,
{
sessionId: expect.any(String),
step: 5,
stepName: 'CEL Input Step',
durationMs: expect.any(Number),
sessionElapsedTime: expect.any(Number),
}
);
});
it('should show loader on the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('generatingLoader')).toBeInTheDocument();
});
it('should disable the next button', () => {
const result = renderIntegrationAssistant();
// Not sure why there are two buttons when testing.
const nextButton = result
.getAllByTestId('buttonsFooter-nextButton')
.filter((button) => button.textContent !== 'Next')[0];
expect(nextButton).toBeDisabled();
});
});
});
describe('when back button is clicked', () => {
let result: ReturnType<typeof renderIntegrationAssistant>;
beforeEach(() => {
result = renderIntegrationAssistant();
mockReportEvent.mockClear();
act(() => {
result.getByTestId('buttonsFooter-backButton').click();
});
});
it('should not report telemetry', () => {
expect(mockReportEvent).not.toHaveBeenCalled();
});
it('should show review step', () => {
expect(result.queryByTestId('reviewStepMock')).toBeInTheDocument();
});
it('should enable the next button', () => {
expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled();
});
});
});
describe('when step is 5 and does not have celInput', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5 });
});
it('should render deploy', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('deployStepMock')).toBeInTheDocument();
});
it('should hide the back button', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null);
});
it('should hide the next button', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null);
});
it('should enable the cancel button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled();
});
it('should show "Close" on the cancel button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-cancelButton')).toHaveTextContent('Close');
});
});
describe('when step is 6', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({
...defaultInitialState,
step: 6,
celInputResult: { program: 'program', stateSettings: {}, redactVars: [] },
});
});
it('should render review', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('reviewCelStepMock')).toBeInTheDocument();
});
it('should call isReviewCelStepReadyToComplete', () => {
renderIntegrationAssistant();
expect(mockIsCelReviewStepReadyToComplete).toHaveBeenCalled();
});
it('should show the "Add to Elastic" on the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent('Add to Elastic');
});
describe('when cel review step is not done', () => {
beforeEach(() => {
mockIsCelReviewStepReadyToComplete.mockReturnValue(false);
});
it('should disable the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-nextButton')).toBeDisabled();
});
it('should still enable the back button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled();
});
it('should still enable the cancel button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled();
});
});
describe('when cel review step is done', () => {
beforeEach(() => {
mockIsCelReviewStepReadyToComplete.mockReturnValue(true);
});
it('should enable the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled();
});
it('should enable the back button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled();
});
it('should enable the cancel button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled();
});
describe('when next button is clicked', () => {
beforeEach(() => {
const result = renderIntegrationAssistant();
mockReportEvent.mockClear();
act(() => {
result.getByTestId('buttonsFooter-nextButton').click();
});
});
it('should report telemetry for review step completion', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantStepComplete,
{
sessionId: expect.any(String),
step: 6,
stepName: 'CEL Review Step',
durationMs: expect.any(Number),
sessionElapsedTime: expect.any(Number),
}
);
});
it('should show deploy step', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('deployStepMock')).toBeInTheDocument();
});
it('should enable the next button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled();
});
});
});
});
describe('when step is 7', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 7 });
});
it('should render deploy', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('deployStepMock')).toBeInTheDocument();
});
it('should hide the back button', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null);
});
it('should hide the next button', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null);
});
it('should enable the cancel button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled();
});
it('should show "Close" on the cancel button', () => {
const result = renderIntegrationAssistant();
expect(result.getByTestId('buttonsFooter-cancelButton')).toHaveTextContent('Close');
});
});
});

View file

@ -9,48 +9,29 @@ import React, { useReducer, useMemo, useEffect, useCallback } from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { Header } from './header';
import { Footer } from './footer';
import { CreateCelConfigFlyout } from './flyout/cel_configuration';
import { useNavigate, Page } from '../../../common/hooks/use_navigate';
import { ConnectorStep, isConnectorStepReadyToComplete } from './steps/connector_step';
import { IntegrationStep, isIntegrationStepReadyToComplete } from './steps/integration_step';
import { DataStreamStep, isDataStreamStepReadyToComplete } from './steps/data_stream_step';
import { ReviewStep, isReviewStepReadyToComplete } from './steps/review_step';
import { CelInputStep, isCelInputStepReadyToComplete } from './steps/cel_input_step';
import { ReviewCelStep, isCelReviewStepReadyToComplete } from './steps/review_cel_step';
import { DeployStep } from './steps/deploy_step';
import { reducer, initialState, ActionsProvider, type Actions } from './state';
import { useTelemetry } from '../telemetry';
import { ExperimentalFeaturesService } from '../../../services';
const stepNames: Record<number | string, string> = {
1: 'Connector Step',
2: 'Integration Step',
3: 'DataStream Step',
4: 'Review Step',
cel_input: 'CEL Input Step',
cel_review: 'CEL Review Step',
deploy: 'Deploy Step',
5: 'Deploy Step',
};
export const CreateIntegrationAssistant = React.memo(() => {
const [state, dispatch] = useReducer(reducer, initialState);
const navigate = useNavigate();
const { generateCel: isGenerateCelEnabled } = ExperimentalFeaturesService.get();
const celInputStepIndex = isGenerateCelEnabled && state.hasCelInput ? 5 : null;
const celReviewStepIndex = isGenerateCelEnabled && state.celInputResult ? 6 : null;
const deployStepIndex =
celInputStepIndex !== null || celReviewStepIndex !== null || state.step === 7 ? 7 : 5;
const stepName =
state.step === deployStepIndex
? stepNames.deploy
: state.step === celReviewStepIndex
? stepNames.cel_review
: state.step === celInputStepIndex
? stepNames.cel_input
: state.step in stepNames
? stepNames[state.step]
: 'Unknown Step';
const stepName = stepNames[state.step];
const telemetry = useTelemetry();
useEffect(() => {
@ -66,13 +47,9 @@ export const CreateIntegrationAssistant = React.memo(() => {
return isDataStreamStepReadyToComplete(state);
} else if (state.step === 4) {
return isReviewStepReadyToComplete(state);
} else if (isGenerateCelEnabled && state.step === 5) {
return isCelInputStepReadyToComplete(state);
} else if (isGenerateCelEnabled && state.step === 6) {
return isCelReviewStepReadyToComplete(state);
}
return false;
}, [state, isGenerateCelEnabled]);
}, [state]);
const goBackStep = useCallback(() => {
if (state.step === 1) {
@ -88,12 +65,12 @@ export const CreateIntegrationAssistant = React.memo(() => {
return;
}
telemetry.reportAssistantStepComplete({ step: state.step, stepName });
if (state.step === 3 || state.step === celInputStepIndex) {
if (state.step === 3) {
dispatch({ type: 'SET_IS_GENERATING', payload: true });
} else {
dispatch({ type: 'SET_STEP', payload: state.step + 1 });
}
}, [telemetry, state.step, stepName, celInputStepIndex, isThisStepReadyToComplete]);
}, [telemetry, state.step, stepName, isThisStepReadyToComplete]);
const actions = useMemo<Actions>(
() => ({
@ -109,8 +86,11 @@ export const CreateIntegrationAssistant = React.memo(() => {
setIsGenerating: (payload) => {
dispatch({ type: 'SET_IS_GENERATING', payload });
},
setHasCelInput: (payload) => {
dispatch({ type: 'SET_HAS_CEL_INPUT', payload });
setShowCelCreateFlyout: (payload) => {
dispatch({ type: 'SET_SHOW_CEL_CREATE_FLYOUT', payload });
},
setIsFlyoutGenerating: (payload) => {
dispatch({ type: 'SET_IS_FLYOUT_GENERATING', payload });
},
setResult: (payload) => {
dispatch({ type: 'SET_GENERATED_RESULT', payload });
@ -133,10 +113,18 @@ export const CreateIntegrationAssistant = React.memo(() => {
{state.step === 3 && (
<DataStreamStep
integrationSettings={state.integrationSettings}
celInputResult={state.celInputResult}
connector={state.connector}
isGenerating={state.isGenerating}
/>
)}
{state.step === 3 && state.showCelCreateFlyout && (
<CreateCelConfigFlyout
integrationSettings={state.integrationSettings}
isFlyoutGenerating={state.isFlyoutGenerating}
connector={state.connector}
/>
)}
{state.step === 4 && (
<ReviewStep
integrationSettings={state.integrationSettings}
@ -144,21 +132,7 @@ export const CreateIntegrationAssistant = React.memo(() => {
result={state.result}
/>
)}
{state.step === celInputStepIndex && (
<CelInputStep
integrationSettings={state.integrationSettings}
connector={state.connector}
isGenerating={state.isGenerating}
/>
)}
{state.step === celReviewStepIndex && (
<ReviewCelStep
isGenerating={state.isGenerating}
celInputResult={state.celInputResult}
/>
)}
{state.step === deployStepIndex && (
{state.step === 5 && (
<DeployStep
integrationSettings={state.integrationSettings}
result={state.result}
@ -170,10 +144,9 @@ export const CreateIntegrationAssistant = React.memo(() => {
<Footer
isGenerating={state.isGenerating}
isAnalyzeStep={state.step === 3}
isAnalyzeCelStep={state.step === celInputStepIndex}
isLastStep={state.step === deployStepIndex}
isLastStep={state.step === 5}
isNextStepEnabled={isThisStepReadyToComplete && !state.isGenerating}
isNextAddingToElastic={state.step === deployStepIndex - 1}
isNextAddingToElastic={state.step === 4}
onBack={goBackStep}
onNext={completeStep}
/>

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiTitle,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useActions, type State } from '../../state';
import * as i18n from './translations';
import { CelInputStep, isCelInputStepReadyToComplete } from './steps/cel_input_step';
import { CelConfirmStep, isCelConfirmStepReadyToComplete } from './steps/cel_confirm_settings_step';
import { Footer } from './footer';
const flyoutBodyCss = css`
height: 100%;
.euiFlyoutBody__overflowContent {
height: 100%;
padding: 0;
}
`;
export type CelFlyoutStepName = 'upload_spec' | 'confirm_details' | 'success';
interface CreateCelConfigFlyoutProps {
integrationSettings: State['integrationSettings'];
isFlyoutGenerating: State['isFlyoutGenerating'];
connector: State['connector'];
}
export const CreateCelConfigFlyout = React.memo<CreateCelConfigFlyoutProps>(
({ integrationSettings, isFlyoutGenerating, connector }) => {
const { setShowCelCreateFlyout, setIsFlyoutGenerating } = useActions();
const [celStep, setCelStep] = useState<CelFlyoutStepName>('upload_spec');
const [suggestedPaths, setSuggestedPaths] = useState<string[]>([]);
const isThisStepReadyToComplete = useMemo(() => {
if (celStep === 'upload_spec') {
return isCelInputStepReadyToComplete(integrationSettings);
} else if (celStep === 'confirm_details') {
return isCelConfirmStepReadyToComplete(integrationSettings);
}
return false;
}, [celStep, integrationSettings]);
const completeStep = useCallback(() => {
if (!isThisStepReadyToComplete) {
// If the user tries to navigate to the next step without completing the current step.
return;
}
setIsFlyoutGenerating(true);
}, [isThisStepReadyToComplete, setIsFlyoutGenerating]);
const onClose = useCallback(() => {
setShowCelCreateFlyout(false);
}, [setShowCelCreateFlyout]);
return (
<EuiFlyout onClose={() => setShowCelCreateFlyout(false)}>
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiTitle size="m">
<h2>{i18n.OPEN_API_SPEC_TITLE}</h2>
</EuiTitle>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody css={flyoutBodyCss}>
{celStep === 'upload_spec' && (
<CelInputStep
integrationSettings={integrationSettings}
connector={connector}
isFlyoutGenerating={isFlyoutGenerating}
setCelStep={setCelStep}
setSuggestedPaths={setSuggestedPaths}
/>
)}
{celStep === 'confirm_details' && (
<CelConfirmStep
integrationSettings={integrationSettings}
suggestedPaths={suggestedPaths}
connector={connector}
isFlyoutGenerating={isFlyoutGenerating}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<Footer
isFlyoutGenerating={isFlyoutGenerating}
celStep={celStep}
isNextStepEnabled={isThisStepReadyToComplete && !isFlyoutGenerating}
onClose={onClose}
onNext={completeStep}
/>
</EuiFlyoutFooter>
</EuiFlyout>
);
}
);
CreateCelConfigFlyout.displayName = 'CreateCelConfigFlyout';

View file

@ -0,0 +1,93 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import { type State } from '../../state';
import * as i18n from './translations';
import type { CelFlyoutStepName } from './create_cel_config';
const AnalyzeApiButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating }) => {
if (!isGenerating) {
return <>{i18n.ANALYZE}</>;
}
return (
<>
<EuiLoadingSpinner size="s" data-test-subj="generatingLoader" />
{i18n.LOADING}
</>
);
});
AnalyzeApiButtonText.displayName = 'AnalyzeApiButtonText';
const AnalyzeCelButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating }) => {
if (!isGenerating) {
return <>{i18n.SAVE_AND_CONTINUE}</>;
}
return (
<>
<EuiLoadingSpinner size="s" data-test-subj="generatingLoader" />
{i18n.LOADING}
</>
);
});
AnalyzeCelButtonText.displayName = 'AnalyzeCelButtonText';
interface FooterProps {
isFlyoutGenerating?: State['isFlyoutGenerating'];
celStep: CelFlyoutStepName;
isNextStepEnabled?: boolean;
onNext?: () => void;
onClose?: () => void;
}
export const Footer = React.memo<FooterProps>(
({
isFlyoutGenerating = false,
celStep,
isNextStepEnabled = false,
onNext = () => {},
onClose = () => {},
}) => {
const nextButtonText = useMemo(() => {
if (celStep === 'upload_spec') {
return <AnalyzeApiButtonText isGenerating={isFlyoutGenerating} />;
}
if (celStep === 'confirm_details') {
return <AnalyzeCelButtonText isGenerating={isFlyoutGenerating} />;
}
}, [celStep, isFlyoutGenerating]);
return (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose} flush="left" data-test-subj="buttonsFooter-nextButton">
{i18n.CLOSE}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
color="primary"
onClick={onNext}
isDisabled={!isNextStepEnabled}
data-test-subj="buttonsFooter-nextButton"
>
{nextButtonText}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
Footer.displayName = 'Footer';

View file

@ -5,7 +5,4 @@
* 2.0.
*/
import type { State } from '../../state';
export const isCelReviewStepReadyToComplete = ({ isGenerating, celInputResult }: State) =>
isGenerating === false && celInputResult != null;
export { CreateCelConfigFlyout } from './create_cel_config';

View file

@ -0,0 +1,74 @@
/*
* 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 type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiBadge,
EuiCallOut,
EuiComboBox,
EuiFlexGroup,
EuiFormRow,
EuiText,
EuiTitle,
} from '@elastic/eui';
import * as i18n from './translations';
import { translatedAuthValue } from './cel_confirm_step';
const AUTH_OPTIONS = ['Basic', 'OAuth2', 'Digest', 'API Token'];
const isRecommended = (auth: string, specDefinedAuthTypes: string[]): boolean => {
return specDefinedAuthTypes.includes(translatedAuthValue(auth));
};
interface AuthSelectionProps {
selectedAuth: string | undefined;
specifiedAuthForPath: string[];
invalidAuth: boolean;
onChangeAuth(update: EuiComboBoxOptionOption[]): void;
}
export const AuthSelection = React.memo<AuthSelectionProps>(
({ selectedAuth, specifiedAuthForPath, invalidAuth, onChangeAuth }) => {
const options = AUTH_OPTIONS.map<EuiComboBoxOptionOption>((option) =>
isRecommended(option, specifiedAuthForPath)
? {
id: option,
label: option,
append: <EuiBadge>{i18n.RECOMMENDED}</EuiBadge>,
}
: { id: option, label: option }
);
return (
<EuiFlexGroup direction="column" gutterSize="l" data-test-subj="confirmAuth">
<EuiTitle size="s">
<h2>{i18n.CONFIRM_AUTH}</h2>
</EuiTitle>
<EuiText size="s">{i18n.CONFIRM_AUTH_DESCRIPTION}</EuiText>
<EuiFormRow label={i18n.AUTH_SELECTION_TITLE} fullWidth>
<EuiComboBox
singleSelection={{ asPlainText: true }}
fullWidth
options={options}
selectedOptions={selectedAuth === undefined ? undefined : [{ label: selectedAuth }]}
onChange={onChangeAuth}
/>
</EuiFormRow>
{invalidAuth && (
<EuiCallOut
title={i18n.AUTH_DOES_NOT_ALIGN}
size="s"
color="warning"
iconType="warning"
/>
)}
</EuiFlexGroup>
);
}
);
AuthSelection.displayName = 'AuthSelection';

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import type { CelAuthType } from '../../../../../../../../common';
import { useActions, type State } from '../../../../state';
import type { OnComplete } from './generation_modal';
import { GenerationModal } from './generation_modal';
import * as i18n from './translations';
import { EndpointSelection } from './endpoint_selection';
import type { IntegrationSettings } from '../../../../types';
import { AuthSelection } from './auth_selection';
interface CelConfirmStepProps {
integrationSettings: State['integrationSettings'];
connector: State['connector'];
isFlyoutGenerating: State['isFlyoutGenerating'];
suggestedPaths: string[];
}
export const translatedAuthValue = (auth: string): string => {
return auth === 'API Token' ? 'Header' : auth;
};
export const CelConfirmStep = React.memo<CelConfirmStepProps>(
({ integrationSettings, connector, isFlyoutGenerating, suggestedPaths }) => {
const {
setIsFlyoutGenerating,
setIntegrationSettings,
setCelInputResult,
setShowCelCreateFlyout,
} = useActions();
const [selectedPath, setSelectedPath] = useState<string>();
const [selectedOtherPath, setSelectedOtherPath] = useState<string | undefined>();
const [useOtherPath, setUseOtherPath] = useState<boolean>(false);
const [selectedAuth, setSelectedAuth] = useState<string | undefined>();
const [specifiedAuthForPath, setSpecifiedAuthForPath] = useState<string[]>([]);
const [invalidAuth, setInvalidAuth] = useState<boolean>(false);
useEffect(() => {
const path = selectedPath !== i18n.ENTER_MANUALLY ? selectedPath : selectedOtherPath;
if (path) {
const authMethods = integrationSettings?.apiSpec?.operation(path, 'get').prepareSecurity();
const specifiedAuth = authMethods ? Object.keys(authMethods) : [];
setSpecifiedAuthForPath(specifiedAuth);
}
}, [selectedPath, selectedOtherPath, integrationSettings?.apiSpec]);
const setIntegrationValues = useCallback(
(settings: Partial<IntegrationSettings>) =>
setIntegrationSettings({ ...integrationSettings, ...settings }),
[integrationSettings, setIntegrationSettings]
);
const onChangeSuggestedPath = useCallback(
(path: string) => {
setSelectedPath(path);
setUseOtherPath(path === i18n.ENTER_MANUALLY);
if (path !== i18n.ENTER_MANUALLY) {
setSelectedOtherPath(undefined);
setIntegrationValues({ celPath: path });
}
},
[setSelectedPath, setUseOtherPath, setIntegrationValues]
);
const onChangeOtherPath = useCallback(
(field: EuiComboBoxOptionOption[]) => {
const path = field && field.length ? field[0].label : undefined;
setSelectedOtherPath(path);
setIntegrationValues({ celPath: path });
},
[setSelectedOtherPath, setIntegrationValues]
);
const onChangeAuth = useCallback(
(field: EuiComboBoxOptionOption[]) => {
const auth = field && field.length ? field[0].label : undefined;
setSelectedAuth(auth);
if (auth) {
const translatedAuth = translatedAuthValue(auth);
setIntegrationValues({ celAuth: translatedAuth.toLowerCase() as CelAuthType });
if (specifiedAuthForPath) {
setInvalidAuth(!specifiedAuthForPath.includes(translatedAuth));
}
} else {
setIntegrationValues({ celAuth: undefined });
setInvalidAuth(false);
}
},
[setIntegrationValues, specifiedAuthForPath]
);
const onGenerationCompleted = useCallback<OnComplete>(
(result: State['celInputResult']) => {
if (result) {
setCelInputResult(result);
setIsFlyoutGenerating(false);
setShowCelCreateFlyout(false);
}
},
[setCelInputResult, setIsFlyoutGenerating, setShowCelCreateFlyout]
);
const onGenerationClosed = useCallback(() => {
setIsFlyoutGenerating(false); // aborts generation
setIntegrationValues({ celPath: undefined, celAuth: undefined }); // resets selected settings
}, [setIntegrationValues, setIsFlyoutGenerating]);
return (
<EuiFlexGroup direction="column" gutterSize="l" data-test-subj="celConfirmStep">
<EuiPanel hasShadow={false} hasBorder={false}>
<EuiFlexItem>
<EndpointSelection
integrationSettings={integrationSettings}
pathSuggestions={suggestedPaths}
selectedPath={selectedPath}
selectedOtherPath={selectedOtherPath}
useOtherEndpoint={useOtherPath}
onChangeSuggestedPath={onChangeSuggestedPath}
onChangeOtherPath={onChangeOtherPath}
/>
</EuiFlexItem>
<EuiSpacer size="xl" />
<EuiFlexItem>
<AuthSelection
selectedAuth={selectedAuth}
specifiedAuthForPath={specifiedAuthForPath}
invalidAuth={invalidAuth}
onChangeAuth={onChangeAuth}
/>
</EuiFlexItem>
</EuiPanel>
{isFlyoutGenerating && (
<GenerationModal
integrationSettings={integrationSettings}
connector={connector}
onComplete={onGenerationCompleted}
onClose={onGenerationClosed}
/>
)}
</EuiFlexGroup>
);
}
);
CelConfirmStep.displayName = 'CelConfirmStep';

View file

@ -0,0 +1,116 @@
/*
* 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 type { EuiRadioGroupOption, EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiBadge,
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiRadioGroup,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import * as i18n from './translations';
import type { IntegrationSettings } from '../../../../types';
const loadPaths = (integrationSettings: IntegrationSettings | undefined): string[] => {
const pathObjs = integrationSettings?.apiSpec?.getPaths();
if (!pathObjs) {
throw new Error('Unable to parse path options from OpenAPI spec');
}
return Object.keys(pathObjs).filter((path) => pathObjs[path].get);
};
interface EndpointSelectionProps {
integrationSettings: IntegrationSettings | undefined;
pathSuggestions: string[];
selectedPath: string | undefined;
selectedOtherPath: string | undefined;
useOtherEndpoint: boolean;
onChangeSuggestedPath(id: string): void;
onChangeOtherPath(path: EuiComboBoxOptionOption[]): void;
}
export const EndpointSelection = React.memo<EndpointSelectionProps>(
({
integrationSettings,
pathSuggestions,
selectedPath,
selectedOtherPath,
useOtherEndpoint,
onChangeSuggestedPath,
onChangeOtherPath,
}) => {
const allPaths = loadPaths(integrationSettings);
const otherPathOptions = allPaths.map<EuiComboBoxOptionOption>((p) => ({ label: p }));
const hasSuggestedPaths = pathSuggestions.length > 0;
const isShowingAllPaths = pathSuggestions.length === allPaths.length;
const options = (
isShowingAllPaths ? pathSuggestions : pathSuggestions.concat([i18n.ENTER_MANUALLY])
).map<EuiRadioGroupOption>((option, index) =>
// The LLM returns the path in preference order, so we know the first option is the recommended one
index === 0
? {
id: option,
label: (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiText size="s">{option}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge>{i18n.RECOMMENDED}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
),
}
: { id: option, label: option }
);
return (
<EuiFlexGroup direction="column" gutterSize="l" data-test-subj="confirmPath">
<EuiTitle size="s">
<h2>{i18n.CONFIRM_ENDPOINT}</h2>
</EuiTitle>
{hasSuggestedPaths && (
<EuiFlexItem>
<EuiText size="s">{i18n.CONFIRM_ENDPOINT_DESCRIPTION}</EuiText>
<EuiSpacer size="s" />
<EuiFlexItem>
<EuiRadioGroup
options={options}
idSelected={selectedPath}
onChange={onChangeSuggestedPath}
/>
</EuiFlexItem>
</EuiFlexItem>
)}
{(!hasSuggestedPaths || (useOtherEndpoint && !isShowingAllPaths)) && (
<EuiFlexGroup direction="column">
<EuiFormRow fullWidth>
<EuiComboBox
singleSelection={{ asPlainText: true }}
fullWidth
options={otherPathOptions}
selectedOptions={
selectedOtherPath === undefined ? undefined : [{ label: selectedOtherPath }]
}
onChange={onChangeOtherPath}
/>
</EuiFormRow>
</EuiFlexGroup>
)}
</EuiFlexGroup>
);
}
);
EndpointSelection.displayName = 'EndpointSelection';

View file

@ -0,0 +1,255 @@
/*
* 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 {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import { getLangSmithOptions } from '../../../../../../../common/lib/lang_smith';
import { type CelInputRequestBody } from '../../../../../../../../common';
import { runCelGraph } from '../../../../../../../common/lib/api';
import { useKibana } from '../../../../../../../common/hooks/use_kibana';
import type { State } from '../../../../state';
import * as i18n from './translations';
import { useTelemetry } from '../../../../../telemetry';
import { getAuthDetails, reduceSpecComponents } from '../../../../../../../util/oas';
export type OnComplete = (result: State['celInputResult']) => void;
interface UseGenerationProps {
integrationSettings: State['integrationSettings'];
connector: State['connector'];
onComplete: OnComplete;
}
export const useGeneration = ({
integrationSettings,
connector,
onComplete,
}: UseGenerationProps) => {
const { reportCelGenerationComplete } = useTelemetry();
const { http, notifications } = useKibana().services;
const [error, setError] = useState<null | string>(null);
const [isRequesting, setIsRequesting] = useState<boolean>(true);
useEffect(() => {
if (
!isRequesting ||
http == null ||
connector == null ||
integrationSettings == null ||
notifications?.toasts == null
) {
return;
}
const generationStartedAt = Date.now();
const abortController = new AbortController();
const deps = { http, abortSignal: abortController.signal };
(async () => {
try {
const oas = integrationSettings.apiSpec;
if (!oas) {
throw new Error('Missing OpenAPI spec');
}
if (!integrationSettings.celPath || !integrationSettings.celAuth) {
throw new Error('Missing path and auth selections');
}
const path = integrationSettings.celPath;
const auth = integrationSettings.celAuth;
const endpointOperation = oas?.operation(path, 'get');
if (!endpointOperation) {
throw new Error('Selected path is not found in OpenApi specification');
}
const authOptions = endpointOperation?.prepareSecurity();
const endpointAuth = getAuthDetails(integrationSettings.celAuth, authOptions);
const schemas = reduceSpecComponents(oas, path);
const celRequest: CelInputRequestBody = {
dataStreamTitle: integrationSettings.dataStreamTitle ?? '',
celDetails: {
path,
auth,
openApiDetails: {
operation: JSON.stringify(endpointOperation.schema),
auth: JSON.stringify(endpointAuth ?? {}),
schemas: JSON.stringify(schemas ?? {}),
},
},
connectorId: connector.id,
langSmithOptions: getLangSmithOptions(),
};
const celGraphResult = await runCelGraph(celRequest, deps);
if (abortController.signal.aborted) return;
if (isEmpty(celGraphResult?.results)) {
throw new Error('Results not found in response');
}
reportCelGenerationComplete({
connector,
integrationSettings,
durationMs: Date.now() - generationStartedAt,
});
const result = {
authType: integrationSettings.celAuth,
program: celGraphResult.results.program,
needsAuthConfigBlock: celGraphResult?.results.needsAuthConfigBlock,
stateSettings: celGraphResult.results.stateSettings,
configFields: celGraphResult.results.configFields,
redactVars: celGraphResult.results.redactVars,
url: oas.url(),
};
onComplete(result);
} catch (e) {
if (abortController.signal.aborted) return;
const errorMessage = `${e.message}${
e.body ? ` (${e.body.statusCode}): ${e.body.message}` : ''
}`;
reportCelGenerationComplete({
connector,
integrationSettings,
durationMs: Date.now() - generationStartedAt,
error: errorMessage,
});
setError(errorMessage);
} finally {
setIsRequesting(false);
}
})();
return () => {
abortController.abort();
};
}, [
isRequesting,
onComplete,
connector,
http,
integrationSettings,
reportCelGenerationComplete,
notifications?.toasts,
]);
const retry = useCallback(() => {
setError(null);
setIsRequesting(true);
}, []);
return { error, retry };
};
const useModalCss = () => {
const { euiTheme } = useEuiTheme();
return {
headerCss: css`
justify-content: center;
margin-top: ${euiTheme.size.m};
`,
bodyCss: css`
padding: ${euiTheme.size.xxxxl};
min-width: 600px;
`,
};
};
interface GenerationModalProps {
integrationSettings: State['integrationSettings'];
connector: State['connector'];
onComplete: OnComplete;
onClose: () => void;
}
export const GenerationModal = React.memo<GenerationModalProps>(
({ integrationSettings, connector, onComplete, onClose }) => {
const { headerCss, bodyCss } = useModalCss();
const { error, retry } = useGeneration({
integrationSettings,
connector,
onComplete,
});
return (
<EuiModal onClose={onClose} data-test-subj="celGenerationModal">
<EuiModalHeader css={headerCss}>
<EuiModalHeaderTitle>{i18n.ANALYZING}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody css={bodyCss}>
<EuiFlexGroup direction="column" gutterSize="l" justifyContent="center">
{error ? (
<EuiFlexItem>
<EuiCallOut
title={i18n.GENERATION_ERROR}
color="danger"
iconType="alert"
data-test-subj="celGenerationErrorCallout"
>
{error}
</EuiCallOut>
</EuiFlexItem>
) : (
<>
<EuiFlexItem>
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
justifyContent="center"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" color="subdued">
{i18n.PROGRESS_CEL_INPUT_GRAPH}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem />
</>
)}
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
{error ? (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="refresh" onClick={retry} data-test-subj="retryCelButton">
{i18n.RETRY}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiSpacer size="xl" />
)}
</EuiModalFooter>
</EuiModal>
);
}
);
GenerationModal.displayName = 'GenerationModal';

View file

@ -4,5 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ReviewCelStep } from './review_cel_step';
export { CelConfirmStep } from './cel_confirm_step';
export * from './is_step_ready';

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IntegrationSettings } from '../../../../types';
export const isCelConfirmStepReadyToComplete = (
integrationSettings: IntegrationSettings | undefined
) => Boolean(integrationSettings?.celPath && integrationSettings?.celAuth);

View file

@ -0,0 +1,88 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ANALYZING = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.analyzing',
{
defaultMessage: 'Analyzing',
}
);
export const RECOMMENDED = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.recommended',
{
defaultMessage: 'Recommended',
}
);
export const ENTER_MANUALLY = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.enterManually',
{
defaultMessage: 'Enter manually',
}
);
export const CONFIRM_ENDPOINT = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.confirmEndpoint',
{
defaultMessage: 'Choose API endpoint',
}
);
export const CONFIRM_ENDPOINT_DESCRIPTION = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.confirmEndpointDescription',
{
defaultMessage: 'Recommended API endpoints (chosen from your spec file):',
}
);
export const CONFIRM_AUTH = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.confirmAuth',
{
defaultMessage: 'Choose Authentication method',
}
);
export const CONFIRM_AUTH_DESCRIPTION = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.confirmAuthDescription',
{
defaultMessage: 'Please select the authentication method for the selected API endpoint.',
}
);
export const AUTH_SELECTION_TITLE = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.authSelectionTitle',
{
defaultMessage: 'Preferred method',
}
);
export const AUTH_DOES_NOT_ALIGN = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.authDoesNotAlign',
{
defaultMessage: 'This method does not align with your spec file',
}
);
export const PROGRESS_CEL_INPUT_GRAPH = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.progress.relatedGraph',
{
defaultMessage: 'Generating CEL input configuration',
}
);
export const GENERATION_ERROR = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.generationError',
{
defaultMessage: 'An error occurred during: CEL input generation',
}
);
export const RETRY = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celConfirm.retryButtonLabel',
{
defaultMessage: 'Retry',
}
);

View file

@ -0,0 +1,173 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { EuiFilePicker, EuiFlexItem, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui';
import Oas from 'oas';
import yaml from 'js-yaml';
import type { IntegrationSettings } from '../../../../types';
import * as i18n from './translations';
import { useActions } from '../../../../state';
interface PrepareOasErrorResult {
error: string;
}
interface PrepareOasSuccessResult {
oas: Oas | undefined;
}
type PrepareOasResult = PrepareOasSuccessResult | PrepareOasErrorResult;
/**
* Prepares the OpenAPI specification file to send to the backend from the user-uploaded file.
*
* This function will return an error message if the uploaded API definition cannot be parsed into an OAS Document
* from the uploaded JSON or YAML defintion file.
*
* @param fileContent The content of the user-provided API definition file.
* @returns The parsed OAS object or an error message.
*/
const prepareOas = (fileContent: string): PrepareOasResult => {
let parsedApiSpec: Oas | undefined;
try {
parsedApiSpec = new Oas(fileContent);
} catch (parseJsonOasError) {
try {
const specYaml = yaml.load(fileContent);
const specJson = JSON.stringify(specYaml);
parsedApiSpec = new Oas(specJson);
} catch (parseYamlOasError) {
return { error: i18n.API_DEFINITION_ERROR.INVALID_OAS };
}
}
return { oas: parsedApiSpec };
};
interface ApiDefinitionInputProps {
integrationSettings: IntegrationSettings | undefined;
isGenerating: boolean;
}
export const ApiDefinitionInput = React.memo<ApiDefinitionInputProps>(
({ integrationSettings, isGenerating }) => {
const { setIntegrationSettings } = useActions();
const [isParsing, setIsParsing] = useState(false);
const [apiFileError, setApiFileError] = useState<string>();
const onChangeApiDefinition = useCallback(
(files: FileList | null) => {
if (!files) {
return;
}
setApiFileError(undefined);
setIntegrationSettings({
...integrationSettings,
apiSpec: undefined,
});
const apiDefinitionFile = files[0];
const reader = new FileReader();
reader.onloadstart = function () {
setIsParsing(true);
};
reader.onloadend = function () {
setIsParsing(false);
};
reader.onload = function (e) {
const fileContent = e.target?.result as string | undefined; // We can safely cast to string since we call `readAsText` to load the file.
if (fileContent == null) {
setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ);
return;
}
if (fileContent === '' && e.loaded > 100000) {
// V8-based browsers can't handle large files and return an empty string
// instead of an error; see https://stackoverflow.com/a/61316641
setApiFileError(i18n.API_DEFINITION_ERROR.TOO_LARGE_TO_PARSE);
return;
}
const prepareResult = prepareOas(fileContent);
if ('error' in prepareResult) {
setApiFileError(prepareResult.error);
return;
}
const { oas } = prepareResult;
setIntegrationSettings({
...integrationSettings,
apiSpec: oas,
});
};
const handleReaderError = function () {
const message = reader.error?.message;
if (message) {
setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ_WITH_REASON(message));
} else {
setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ);
}
};
reader.onerror = handleReaderError;
reader.onabort = handleReaderError;
reader.readAsText(apiDefinitionFile);
},
[integrationSettings, setIntegrationSettings, setIsParsing]
);
return (
<EuiFlexItem>
<EuiText size="s">
<p>{i18n.OPEN_API_UPLOAD_INSTRUCTIONS}</p>
</EuiText>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
helpText={
<EuiText color="danger" size="xs">
{apiFileError}
</EuiText>
}
isInvalid={apiFileError != null}
>
<>
<EuiFilePicker
id="apiDefinitionFilePicker"
fullWidth
initialPromptText={
<>
<EuiText size="s" textAlign="center">
{i18n.API_DEFINITION_DESCRIPTION}
</EuiText>
<EuiText size="xs" color="subdued" textAlign="center">
{i18n.API_DEFINITION_DESCRIPTION_2}
</EuiText>
</>
}
onChange={onChangeApiDefinition}
display="large"
aria-label="Upload API definition file"
isLoading={isParsing}
data-test-subj="apiDefinitionFilePicker"
data-loading={isParsing}
/>
</>
</EuiFormRow>
</EuiFlexItem>
);
}
);
ApiDefinitionInput.displayName = 'ApiDefinitionInput';

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { useActions, type State } from '../../../../state';
import type { OnComplete } from './generation_modal';
import { GenerationModal } from './generation_modal';
import { ApiDefinitionInput } from './api_definition_input';
import * as i18n from './translations';
import * as il8n_ds from '../../../../steps/data_stream_step/translations';
import type { CelFlyoutStepName } from '../../create_cel_config';
interface CelInputStepProps {
integrationSettings: State['integrationSettings'];
connector: State['connector'];
isFlyoutGenerating: State['isFlyoutGenerating'];
setCelStep: (step: CelFlyoutStepName) => void;
setSuggestedPaths: (paths: string[]) => void;
}
export const CelInputStep = React.memo<CelInputStepProps>(
({ integrationSettings, connector, isFlyoutGenerating, setCelStep, setSuggestedPaths }) => {
const { setIntegrationSettings, setIsFlyoutGenerating } = useActions();
const onGenerationCompleted = useCallback<OnComplete>(
(result: string[]) => {
if (result) {
setSuggestedPaths(result);
setIsFlyoutGenerating(false);
setCelStep('confirm_details');
}
},
[setCelStep, setIsFlyoutGenerating, setSuggestedPaths]
);
const onGenerationClosed = useCallback(() => {
setIsFlyoutGenerating(false); // aborts generation
}, [setIsFlyoutGenerating]);
const [dataStreamTitle, setDataStreamTitle] = useState<string>(
integrationSettings?.dataStreamTitle ?? ''
);
const onChangeDataStreamName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const nextDataStreamName = e.target.value;
setDataStreamTitle(nextDataStreamName);
setIntegrationSettings({ ...integrationSettings, dataStreamTitle: nextDataStreamName });
},
[setIntegrationSettings, integrationSettings]
);
return (
<EuiFlexGroup direction="column" gutterSize="l" data-test-subj="celInputStep">
<EuiFlexItem>
<EuiPanel hasShadow={false} hasBorder={false}>
<EuiTitle size="s">
<h2>{i18n.API_DEFINITION_TITLE}</h2>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
label={il8n_ds.DATA_STREAM_TITLE_LABEL}
isInvalid={dataStreamTitle === ''}
error={[i18n.DATA_STREAM_TITLE_REQUIRED]}
>
<EuiFieldText
fullWidth
name="dataStreamTitle"
data-test-subj="dataStreamTitleInput"
value={dataStreamTitle}
onChange={onChangeDataStreamName}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<ApiDefinitionInput integrationSettings={integrationSettings} isGenerating={false} />
</EuiPanel>
{isFlyoutGenerating && (
<GenerationModal
integrationSettings={integrationSettings}
connector={connector}
onComplete={onGenerationCompleted}
onClose={onGenerationClosed}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
CelInputStep.displayName = 'CelInputStep';

View file

@ -23,15 +23,33 @@ import {
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import { getLangSmithOptions } from '../../../../../common/lib/lang_smith';
import { type CelInputRequestBody } from '../../../../../../common';
import { runCelGraph } from '../../../../../common/lib/api';
import { useKibana } from '../../../../../common/hooks/use_kibana';
import type { State } from '../../state';
import type Oas from 'oas';
import { getLangSmithOptions } from '../../../../../../../common/lib/lang_smith';
import { type AnalyzeApiRequestBody } from '../../../../../../../../common';
import { runAnalyzeApiGraph } from '../../../../../../../common/lib/api';
import { useKibana } from '../../../../../../../common/hooks/use_kibana';
import type { State } from '../../../../state';
import * as i18n from './translations';
import { useTelemetry } from '../../../telemetry';
import { useTelemetry } from '../../../../../telemetry';
import type { ApiPathOptions } from '../../../../types';
export type OnComplete = (result: State['celInputResult']) => void;
export type OnComplete = (result: string[]) => void;
const getApiPathsWithDescriptions = (apiSpec: Oas | undefined): ApiPathOptions => {
const pathMap: { [key: string]: string } = {};
const pathObjs = apiSpec?.getPaths();
if (pathObjs) {
for (const [path, pathObj] of Object.entries(pathObjs)) {
if (pathObj?.get) {
const val = pathObj?.get?.getDescription()
? pathObj?.get?.getDescription()
: pathObj?.get?.getSummary();
pathMap[path] = val;
}
}
}
return pathMap;
};
interface UseGenerationProps {
integrationSettings: State['integrationSettings'];
@ -58,38 +76,28 @@ export const useGeneration = ({
) {
return;
}
const generationStartedAt = Date.now();
const abortController = new AbortController();
const deps = { http, abortSignal: abortController.signal };
(async () => {
try {
const apiDefinition = integrationSettings.apiDefinition;
const celRequest: CelInputRequestBody = {
dataStreamName: integrationSettings.dataStreamName ?? '',
apiDefinition: apiDefinition ?? '',
const apiOptions = getApiPathsWithDescriptions(integrationSettings.apiSpec);
const analyzeApiRequest: AnalyzeApiRequestBody = {
dataStreamTitle: integrationSettings.dataStreamTitle ?? '',
pathOptions: apiOptions,
connectorId: connector.id,
langSmithOptions: getLangSmithOptions(),
};
const celGraphResult = await runCelGraph(celRequest, deps);
const apiAnalysisGraphResult = await runAnalyzeApiGraph(analyzeApiRequest, deps);
if (abortController.signal.aborted) return;
if (isEmpty(celGraphResult?.results)) {
if (isEmpty(apiAnalysisGraphResult?.results)) {
throw new Error('Results not found in response');
}
reportCelGenerationComplete({
connector,
integrationSettings,
durationMs: Date.now() - generationStartedAt,
});
const result = {
program: celGraphResult.results.program,
stateSettings: celGraphResult.results.stateSettings,
redactVars: celGraphResult.results.redactVars,
};
const result = apiAnalysisGraphResult.results.suggestedPaths;
onComplete(result);
} catch (e) {
@ -98,13 +106,6 @@ export const useGeneration = ({
e.body ? ` (${e.body.statusCode}): ${e.body.message}` : ''
}`;
reportCelGenerationComplete({
connector,
integrationSettings,
durationMs: Date.now() - generationStartedAt,
error: errorMessage,
});
setError(errorMessage);
} finally {
setIsRequesting(false);

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IntegrationSettings } from '../../../../types';
export const isCelInputStepReadyToComplete = (
integrationSettings: IntegrationSettings | undefined
) => Boolean(integrationSettings?.dataStreamTitle && integrationSettings?.apiSpec);

View file

@ -0,0 +1,101 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const DATA_STREAM_TITLE_REQUIRED = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.dataStreamTitleRequired',
{
defaultMessage: 'This field is mandatory',
}
);
export const OPEN_API_UPLOAD_INSTRUCTIONS = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.uploadInstructions',
{
defaultMessage:
"Upload an OpenAPI spec file to generate a configuration for the CEL input. This is typically found in vendor's API reference documentation.",
}
);
export const ANALYZING = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.analyzing',
{
defaultMessage: 'Analyzing',
}
);
export const API_DEFINITION_TITLE = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.apiDefinition.title',
{
defaultMessage: 'OpenAPI Specification',
}
);
export const API_DEFINITION_DESCRIPTION = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.apiDefinition.description',
{
defaultMessage: 'Drag and drop a file or browse files.',
}
);
export const API_DEFINITION_DESCRIPTION_2 = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.apiDefinition.description2',
{
defaultMessage: 'OpenAPI specification',
}
);
export const API_DEFINITION_ERROR = {
CAN_NOT_READ: i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.openapiSpec.errorCanNotRead',
{
defaultMessage: 'Failed to read the uploaded file',
}
),
INVALID_OAS: i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.openapiSpec.errorInvalidFormat',
{
defaultMessage: 'Uploaded file is not a valid OpenApi spec file',
}
),
CAN_NOT_READ_WITH_REASON: (reason: string) =>
i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.openapiSpec.errorCanNotReadWithReason',
{
values: { reason },
defaultMessage: 'An error occurred when reading spec file: {reason}',
}
),
TOO_LARGE_TO_PARSE: i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.openapiSpec.errorTooLargeToParse',
{
defaultMessage: 'This spec file is too large to parse',
}
),
};
export const PROGRESS_CEL_INPUT_GRAPH = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.generating',
{
defaultMessage: 'Analyzing the uploaded API specification',
}
);
export const GENERATION_ERROR = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.generationError',
{
defaultMessage: 'An error occurred during API analysis',
}
);
export const RETRY = i18n.translate(
'xpack.integrationAssistant.celFlyout.step.celInput.retryButtonLabel',
{
defaultMessage: 'Retry',
}
);

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
/**
* flyout container
*/
export const OPEN_API_SPEC_TITLE = i18n.translate(
'xpack.integrationAssistant.celFlyout.createCel.openApiSpecTitle',
{
defaultMessage: 'OpenAPI spec',
}
);
/**
* footer
*/
export const LOADING = i18n.translate('xpack.integrationAssistant.celFlyout.footer.loading', {
defaultMessage: 'Loading',
});
export const ANALYZE = i18n.translate('xpack.integrationAssistant.celFlyout.footer.analyze', {
defaultMessage: 'Analyze',
});
export const SAVE_AND_CONTINUE = i18n.translate(
'xpack.integrationAssistant.celFlyout.footer.saveAndContinue',
{
defaultMessage: 'Save & Continue',
}
);
export const CLOSE = i18n.translate('xpack.integrationAssistant.celFlyout.footer.close', {
defaultMessage: 'Close',
});

View file

@ -25,24 +25,9 @@ const AnalyzeButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating
});
AnalyzeButtonText.displayName = 'AnalyzeButtonText';
// Generation button for Step 5
const AnalyzeCelButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating }) => {
if (!isGenerating) {
return <>{i18n.ANALYZE_CEL}</>;
}
return (
<>
<EuiLoadingSpinner size="s" data-test-subj="generatingLoader" />
{i18n.LOADING}
</>
);
});
AnalyzeCelButtonText.displayName = 'AnalyzeCelButtonText';
interface FooterProps {
isGenerating?: State['isGenerating'];
isAnalyzeStep?: boolean;
isAnalyzeCelStep?: boolean;
isLastStep?: boolean;
isNextStepEnabled?: boolean;
isNextAddingToElastic?: boolean;
@ -54,7 +39,6 @@ export const Footer = React.memo<FooterProps>(
({
isGenerating = false,
isAnalyzeStep = false,
isAnalyzeCelStep = false,
isLastStep = false,
isNextStepEnabled = false,
isNextAddingToElastic = false,
@ -67,10 +51,8 @@ export const Footer = React.memo<FooterProps>(
i18n.ADD_TO_ELASTIC
) : isAnalyzeStep ? (
<AnalyzeButtonText isGenerating={isGenerating} />
) : isAnalyzeCelStep ? (
<AnalyzeCelButtonText isGenerating={isGenerating} />
) : null,
[isNextAddingToElastic, isAnalyzeStep, isGenerating, isAnalyzeCelStep]
[isNextAddingToElastic, isAnalyzeStep, isGenerating]
);
return isLastStep ? (

View file

@ -11,10 +11,6 @@ export const ANALYZE_LOGS = i18n.translate('xpack.integrationAssistant.bottomBar
defaultMessage: 'Analyze logs',
});
export const ANALYZE_CEL = i18n.translate('xpack.integrationAssistant.bottomBar.analyzeCel', {
defaultMessage: 'Generate CEL input configuration',
});
export const LOADING = i18n.translate('xpack.integrationAssistant.bottomBar.loading', {
defaultMessage: 'Loading',
});

View file

@ -423,8 +423,18 @@ export const mockState: State = {
logSamples: rawSamples,
},
isGenerating: false,
hasCelInput: false,
result,
showCelCreateFlyout: false,
isFlyoutGenerating: false,
celInputResult: {
url: 'https://sample.com',
program: 'line1\nline2',
authType: 'basic',
stateSettings: { setting1: 100, setting2: '' },
redactVars: ['setting2'],
configFields: { setting1: {}, setting2: {} },
needsAuthConfigBlock: false,
},
};
export const mockActions: Actions = {
@ -432,8 +442,9 @@ export const mockActions: Actions = {
setConnector: jest.fn(),
setIntegrationSettings: jest.fn(),
setIsGenerating: jest.fn(),
setHasCelInput: jest.fn(),
setResult: jest.fn(),
setCelInputResult: jest.fn(),
setShowCelCreateFlyout: jest.fn(),
setIsFlyoutGenerating: jest.fn(),
completeStep: jest.fn(),
};

View file

@ -13,7 +13,8 @@ export interface State {
connector?: AIConnector;
integrationSettings?: IntegrationSettings;
isGenerating: boolean;
hasCelInput: boolean;
showCelCreateFlyout: boolean;
isFlyoutGenerating: boolean;
result?: {
pipeline: Pipeline;
docs: Docs;
@ -27,7 +28,8 @@ export const initialState: State = {
connector: undefined,
integrationSettings: undefined,
isGenerating: false,
hasCelInput: false,
showCelCreateFlyout: false,
isFlyoutGenerating: false,
result: undefined,
};
@ -36,7 +38,8 @@ type Action =
| { type: 'SET_CONNECTOR'; payload: State['connector'] }
| { type: 'SET_INTEGRATION_SETTINGS'; payload: State['integrationSettings'] }
| { type: 'SET_IS_GENERATING'; payload: State['isGenerating'] }
| { type: 'SET_HAS_CEL_INPUT'; payload: State['hasCelInput'] }
| { type: 'SET_SHOW_CEL_CREATE_FLYOUT'; payload: State['showCelCreateFlyout'] }
| { type: 'SET_IS_FLYOUT_GENERATING'; payload: State['isFlyoutGenerating'] }
| { type: 'SET_GENERATED_RESULT'; payload: State['result'] }
| { type: 'SET_CEL_INPUT_RESULT'; payload: State['celInputResult'] };
@ -55,8 +58,10 @@ export const reducer = (state: State, action: Action): State => {
return { ...state, integrationSettings: action.payload };
case 'SET_IS_GENERATING':
return { ...state, isGenerating: action.payload };
case 'SET_HAS_CEL_INPUT':
return { ...state, hasCelInput: action.payload };
case 'SET_SHOW_CEL_CREATE_FLYOUT':
return { ...state, showCelCreateFlyout: action.payload };
case 'SET_IS_FLYOUT_GENERATING':
return { ...state, isFlyoutGenerating: action.payload };
case 'SET_GENERATED_RESULT':
return {
...state,
@ -75,8 +80,9 @@ export interface Actions {
setConnector: (payload: State['connector']) => void;
setIntegrationSettings: (payload: State['integrationSettings']) => void;
setIsGenerating: (payload: State['isGenerating']) => void;
setHasCelInput: (payload: State['hasCelInput']) => void;
setResult: (payload: State['result']) => void;
setShowCelCreateFlyout: (payload: State['showCelCreateFlyout']) => void;
setIsFlyoutGenerating: (payload: State['isFlyoutGenerating']) => void;
setCelInputResult: (payload: State['celInputResult']) => void;
completeStep: () => void;
}

View file

@ -1,118 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useState } from 'react';
import { EuiFilePicker, EuiFormRow, EuiText } from '@elastic/eui';
import type { IntegrationSettings } from '../../types';
import * as i18n from './translations';
import { useActions } from '../../state';
interface ApiDefinitionInputProps {
integrationSettings: IntegrationSettings | undefined;
}
export const ApiDefinitionInput = React.memo<ApiDefinitionInputProps>(({ integrationSettings }) => {
const { setIntegrationSettings } = useActions();
const [isParsing, setIsParsing] = useState(false);
const [apiFileError, setApiFileError] = useState<string>();
const onChangeApiDefinition = useCallback(
(files: FileList | null) => {
if (!files) {
return;
}
setApiFileError(undefined);
setIntegrationSettings({
...integrationSettings,
apiDefinition: undefined,
});
const apiDefinitionFile = files[0];
const reader = new FileReader();
reader.onloadstart = function () {
setIsParsing(true);
};
reader.onloadend = function () {
setIsParsing(false);
};
reader.onload = function (e) {
const fileContent = e.target?.result as string | undefined; // We can safely cast to string since we call `readAsText` to load the file.
if (fileContent == null) {
setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ);
return;
}
if (fileContent === '' && e.loaded > 100000) {
// V8-based browsers can't handle large files and return an empty string
// instead of an error; see https://stackoverflow.com/a/61316641
setApiFileError(i18n.API_DEFINITION_ERROR.TOO_LARGE_TO_PARSE);
return;
}
setIntegrationSettings({
...integrationSettings,
apiDefinition: fileContent,
});
};
const handleReaderError = function () {
const message = reader.error?.message;
if (message) {
setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ_WITH_REASON(message));
} else {
setApiFileError(i18n.API_DEFINITION_ERROR.CAN_NOT_READ);
}
};
reader.onerror = handleReaderError;
reader.onabort = handleReaderError;
reader.readAsText(apiDefinitionFile);
},
[integrationSettings, setIntegrationSettings, setIsParsing]
);
return (
<EuiFormRow
label={i18n.API_DEFINITION_LABEL}
helpText={
<EuiText color="danger" size="xs">
{apiFileError}
</EuiText>
}
isInvalid={apiFileError != null}
>
<>
<EuiFilePicker
id="apiDefinitionFilePicker"
initialPromptText={
<>
<EuiText size="s" textAlign="center">
{i18n.API_DEFINITION_DESCRIPTION}
</EuiText>
<EuiText size="xs" color="subdued" textAlign="center">
{i18n.API_DEFINITION_DESCRIPTION_2}
</EuiText>
</>
}
onChange={onChangeApiDefinition}
display="large"
aria-label="Upload API definition file"
isLoading={isParsing}
data-test-subj="apiDefinitionFilePicker"
data-loading={isParsing}
/>
</>
</EuiFormRow>
);
});
ApiDefinitionInput.displayName = 'ApiDefinitionInput';

View file

@ -1,71 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiPanel } from '@elastic/eui';
import { StepContentWrapper } from '../step_content_wrapper';
import { useActions, type State } from '../../state';
import type { OnComplete } from './generation_modal';
import { GenerationModal } from './generation_modal';
import { ApiDefinitionInput } from './api_definition_input';
import * as i18n from './translations';
interface CelInputStepProps {
integrationSettings: State['integrationSettings'];
connector: State['connector'];
isGenerating: State['isGenerating'];
}
export const CelInputStep = React.memo<CelInputStepProps>(
({ integrationSettings, connector, isGenerating }) => {
const { setIsGenerating, setStep, setCelInputResult, completeStep } = useActions();
const onGenerationCompleted = useCallback<OnComplete>(
(result: State['celInputResult']) => {
if (result) {
setCelInputResult(result);
setIsGenerating(false);
setStep(6);
}
},
[setCelInputResult, setIsGenerating, setStep]
);
const onGenerationClosed = useCallback(() => {
setIsGenerating(false); // aborts generation
}, [setIsGenerating]);
return (
<EuiFlexGroup direction="column" gutterSize="l" data-test-subj="celInputStep">
<EuiFlexItem>
<StepContentWrapper title={i18n.CEL_INPUT_TITLE} subtitle={i18n.CEL_INPUT_DESCRIPTION}>
<EuiPanel hasShadow={false} hasBorder>
<EuiForm
component="form"
fullWidth
onSubmit={(e) => {
e.preventDefault();
completeStep();
}}
>
<ApiDefinitionInput integrationSettings={integrationSettings} />
</EuiForm>
</EuiPanel>
</StepContentWrapper>
{isGenerating && (
<GenerationModal
integrationSettings={integrationSettings}
connector={connector}
onComplete={onGenerationCompleted}
onClose={onGenerationClosed}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
CelInputStep.displayName = 'CelInputStep';

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { State } from '../../state';
export const isCelInputStepReadyToComplete = ({ integrationSettings }: State) =>
Boolean(
integrationSettings?.name &&
integrationSettings?.dataStreamTitle &&
integrationSettings?.dataStreamDescription &&
integrationSettings?.dataStreamName &&
integrationSettings?.apiDefinition
);
// TODO add support for not uploading a spec file

View file

@ -1,87 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ANALYZING = i18n.translate('xpack.integrationAssistant.step.dataStream.analyzing', {
defaultMessage: 'Analyzing',
});
export const CEL_INPUT_TITLE = i18n.translate(
'xpack.integrationAssistant.step.celInput.celInputTitle',
{
defaultMessage: 'Generate CEL input configuration',
}
);
export const CEL_INPUT_DESCRIPTION = i18n.translate(
'xpack.integrationAssistant.step.celInput.celInputDescription',
{
defaultMessage: 'Upload an OpenAPI spec file to generate a configuration for the CEL input',
}
);
export const API_DEFINITION_LABEL = i18n.translate(
'xpack.integrationAssistant.step.celInput.apiDefinition.label',
{
defaultMessage: 'OpenAPI spec',
}
);
export const API_DEFINITION_DESCRIPTION = i18n.translate(
'xpack.integrationAssistant.step.celInput.apiDefinition.description',
{
defaultMessage: 'Drag and drop a file or browse files.',
}
);
export const API_DEFINITION_DESCRIPTION_2 = i18n.translate(
'xpack.integrationAssistant.step.celInput.apiDefinition.description2',
{
defaultMessage: 'OpenAPI specification',
}
);
export const API_DEFINITION_ERROR = {
CAN_NOT_READ: i18n.translate(
'xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotRead',
{
defaultMessage: 'Failed to read the logs sample file',
}
),
CAN_NOT_READ_WITH_REASON: (reason: string) =>
i18n.translate(
'xpack.integrationAssistant.step.celInput.openapiSpec.errorCanNotReadWithReason',
{
values: { reason },
defaultMessage: 'An error occurred when reading spec file: {reason}',
}
),
TOO_LARGE_TO_PARSE: i18n.translate(
'xpack.integrationAssistant.step.celInput.openapiSpec.errorTooLargeToParse',
{
defaultMessage: 'This spec file is too large to parse',
}
),
};
export const PROGRESS_CEL_INPUT_GRAPH = i18n.translate(
'xpack.integrationAssistant.step.celInput.progress.relatedGraph',
{
defaultMessage: 'Generating CEL input configuration',
}
);
export const GENERATION_ERROR = i18n.translate(
'xpack.integrationAssistant.step.celInput.generationError',
{
defaultMessage: 'An error occurred during: CEL input generation',
}
);
export const RETRY = i18n.translate('xpack.integrationAssistant.step.celInput.retryButtonLabel', {
defaultMessage: 'Retry',
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import React from 'react';
import * as i18n from './translations';
interface DataStreamNameProps {
dataStreamName: string;
onChangeDataStreamName(update: React.ChangeEvent<HTMLInputElement>): void;
invalidName: boolean;
}
export const DataStreamName = React.memo<DataStreamNameProps>(
({ dataStreamName, onChangeDataStreamName, invalidName }) => {
return (
<EuiFormRow
label={i18n.DATA_STREAM_NAME_LABEL}
helpText={!invalidName ? i18n.NO_SPACES_HELP : undefined}
isInvalid={invalidName}
error={[i18n.NO_SPACES_HELP]}
>
<EuiFieldText
name="dataStreamName"
data-test-subj="dataStreamNameInput"
value={dataStreamName}
onChange={onChangeDataStreamName}
isInvalid={invalidName}
/>
</EuiFormRow>
);
}
);
DataStreamName.displayName = 'DataStreamName';

View file

@ -62,6 +62,7 @@ describe('DataStreamStep', () => {
integrationSettings={undefined}
connector={mockState.connector}
isGenerating={false}
celInputResult={undefined}
/>,
{ wrapper }
);
@ -224,6 +225,45 @@ describe('DataStreamStep', () => {
});
});
});
describe('when dataCollectionMethod is cel', () => {
const dataCollectionMethod = 'cel';
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('dataCollectionMethodInput'), {
target: { value: dataCollectionMethod },
});
});
});
it('should show add OpenApi spec button', () => {
expect(result.queryByTestId('addOpenApiSpecButton')).toBeInTheDocument();
});
});
});
describe('when dataCollectionMethod=cel and has celInputResult', () => {
let result: RenderResult;
beforeEach(() => {
result = render(
<DataStreamStep
integrationSettings={mockState.integrationSettings}
connector={mockState.connector}
isGenerating={false}
celInputResult={mockState.celInputResult}
/>,
{ wrapper }
);
act(() => {
fireEvent.change(result.getByTestId('dataCollectionMethodInput'), {
target: { value: 'cel' },
});
});
});
it('should render successfully configured cel input', () => {
expect(result.queryByTestId('openApiConfigured')).toBeInTheDocument();
});
});
describe('when is generating', () => {
@ -234,6 +274,7 @@ describe('DataStreamStep', () => {
integrationSettings={mockState.integrationSettings}
connector={mockState.connector}
isGenerating={true}
celInputResult={undefined}
/>,
{ wrapper }
);

View file

@ -7,6 +7,9 @@
import type { EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiBetaBadge,
EuiButton,
EuiButtonEmpty,
EuiComboBox,
EuiFieldText,
EuiFlexGroup,
@ -14,6 +17,8 @@ import {
EuiForm,
EuiFormRow,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { NAME_REGEX_PATTERN } from '../../../../../../common/constants';
@ -32,7 +37,7 @@ export const InputTypeOptions: Array<EuiComboBoxOptionOption<InputType>> = [
{ value: 'aws-s3', label: 'AWS S3' },
{ value: 'azure-blob-storage', label: 'Azure Blob Storage' },
{ value: 'azure-eventhub', label: 'Azure Event Hub' },
{ value: 'cel', label: 'Common Expression Language (CEL)' },
{ value: 'cel', label: 'API (CEL Input)' },
{ value: 'cloudfoundry', label: 'Cloud Foundry' },
{ value: 'filestream', label: 'File Stream' },
{ value: 'gcp-pubsub', label: 'GCP Pub/Sub' },
@ -49,15 +54,16 @@ const getNameFromTitle = (title: string) => title.toLowerCase().replaceAll(/[^a-
interface DataStreamStepProps {
integrationSettings: State['integrationSettings'];
celInputResult: State['celInputResult'];
connector: State['connector'];
isGenerating: State['isGenerating'];
}
export const DataStreamStep = React.memo<DataStreamStepProps>(
({ integrationSettings, connector, isGenerating }) => {
({ integrationSettings, celInputResult, connector, isGenerating }) => {
const {
setIntegrationSettings,
setIsGenerating,
setHasCelInput,
setShowCelCreateFlyout,
setStep,
setResult,
completeStep,
@ -69,6 +75,7 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
integrationSettings?.dataStreamName ?? ''
);
const [invalidFields, setInvalidFields] = useState({ name: false, dataStreamName: false });
const [showCelOpenApiSpecButton, setShowCelOpenApiSpecButton] = useState<boolean>(false);
const setIntegrationValues = useCallback(
(settings: Partial<IntegrationSettings>) =>
@ -107,13 +114,13 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
setIntegrationValues({ dataStreamDescription: e.target.value }),
inputTypes: (options: EuiComboBoxOptionOption[]) => {
setIntegrationValues({ inputTypes: options.map((option) => option.value as InputType) });
setHasCelInput(
setShowCelOpenApiSpecButton(
// the cel value here comes from the input type options defined above
options.map((option) => option.value as InputType).includes('cel' as InputType)
);
},
};
}, [setIntegrationValues, setInvalidFields, setHasCelInput, packageNames]);
}, [setIntegrationValues, setInvalidFields, packageNames]);
useEffect(() => {
// Pre-populates the name from the title set in the previous step.
@ -239,6 +246,60 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
fullWidth
/>
</EuiFormRow>
{showCelOpenApiSpecButton && (
<EuiFormRow
label={
<EuiFlexGroup direction="row" gutterSize="s">
{i18n.ADD_OPEN_API_SPEC_LABEL}
<EuiBetaBadge
iconType="beaker"
label={i18n.TECH_PREVIEW}
tooltipContent={i18n.TECH_PREVIEW_TOOLTIP}
size="s"
color="hollow"
data-test-subj="techPreviewBadge"
/>
</EuiFlexGroup>
}
labelAppend={
<EuiText size="xs" color="subdued">
<p>{i18n.ADD_OPEN_API_SPEC_OPTIONAL_LABEL}</p>
</EuiText>
}
>
<EuiFlexGroup direction="column">
<EuiText size="xs">
<p>{i18n.ADD_OPEN_API_SPEC_DESCRIPTION}</p>
</EuiText>
<EuiFlexGroup>
{celInputResult ? (
<EuiFlexGroup>
<EuiButton iconType="check" disabled data-test-subj="openApiConfigured">
<EuiText size="xs">{i18n.OPEN_API_SPEC_BUTTON_CONFIGURED}</EuiText>
</EuiButton>
<EuiButtonEmpty
iconType="pencil"
onClick={() => setShowCelCreateFlyout(true)}
>
<EuiText size="xs">{i18n.EDIT_OPEN_API_SPEC_BUTTON}</EuiText>
</EuiButtonEmpty>
</EuiFlexGroup>
) : (
<EuiFlexItem grow={false}>
<EuiButton
iconType="plusInCircle"
onClick={() => setShowCelCreateFlyout(true)}
data-test-subj="addOpenApiSpecButton"
>
<EuiText size="xs">{i18n.ADD_OPEN_API_SPEC_BUTTON}</EuiText>
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFormRow>
)}
<EuiSpacer size="m" />
<SampleLogsInput integrationSettings={integrationSettings} />
</EuiPanel>
</StepContentWrapper>

View file

@ -89,6 +89,57 @@ export const DATA_COLLECTION_METHOD_LABEL = i18n.translate(
}
);
export const ADD_OPEN_API_SPEC_LABEL = i18n.translate(
'xpack.integrationAssistant.step.dataStream.addOpenApiSpecLabel',
{
defaultMessage: 'OpenAPI Spec',
}
);
export const ADD_OPEN_API_SPEC_OPTIONAL_LABEL = i18n.translate(
'xpack.integrationAssistant.step.dataStream.addOpenApiSpecOptionalLabel',
{
defaultMessage: 'Optional',
}
);
export const ADD_OPEN_API_SPEC_BUTTON = i18n.translate(
'xpack.integrationAssistant.step.dataStream.addOpenApiSpecButton',
{
defaultMessage: 'Add OpenAPI spec file',
}
);
export const OPEN_API_SPEC_BUTTON_CONFIGURED = i18n.translate(
'xpack.integrationAssistant.step.dataStream.openApiSpecButtonConfigured',
{
defaultMessage: 'OpenAPI spec file successfully configured',
}
);
export const EDIT_OPEN_API_SPEC_BUTTON = i18n.translate(
'xpack.integrationAssistant.step.dataStream.editOpenApiSpecButton',
{
defaultMessage: 'Edit',
}
);
export const ADD_OPEN_API_SPEC_DESCRIPTION = i18n.translate(
'xpack.integrationAssistant.step.dataStream.addOpenApiSpecDescription',
{
defaultMessage:
'If a spec file is uploaded, the LLM will generate a CEL input configuration. If you skip this step, the final output will have a default template and will not be generated by the chosen LLM.',
}
);
export const TECH_PREVIEW = i18n.translate(
'xpack.integrationAssistant.step.dataStream.techPreviewBadge',
{
defaultMessage: 'Technical preview',
}
);
export const TECH_PREVIEW_TOOLTIP = i18n.translate(
'xpack.integrationAssistant.step.dataStream.techPreviewTooltip',
{
defaultMessage:
'This functionality is in technical preview and is subject to change. Please use with caution in production environments.',
}
);
export const LOGS_SAMPLE_LABEL = i18n.translate(
'xpack.integrationAssistant.step.dataStream.logsSample.label',
{

View file

@ -1,65 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiFlexItem,
EuiFlexGroup,
EuiCodeBlock,
EuiText,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { type State } from '../../state';
import * as i18n from './translations';
interface CelConfigResultsProps {
celInputResult: State['celInputResult'];
}
export const CelConfigResults = React.memo<CelConfigResultsProps>(({ celInputResult }) => {
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.PROGRAM}</h4>
</EuiText>
<EuiSpacer size="m" />
<EuiCodeBlock language="c" fontSize="m" isCopyable>
{celInputResult?.program}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.STATE}</h4>
</EuiText>
<EuiSpacer size="m" />
<EuiCodeBlock language="json" fontSize="m" isCopyable>
{JSON.stringify(celInputResult?.stateSettings, null, 2)}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<h4>{i18n.REDACT_VARS}</h4>
</EuiText>
<EuiSpacer size="m" />
<EuiCodeBlock language="json" fontSize="m" isCopyable>
{JSON.stringify(celInputResult?.redactVars)}
</EuiCodeBlock>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
});
CelConfigResults.displayName = 'CelConfigForm';

View file

@ -1,43 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiForm, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import React from 'react';
import { useActions, type State } from '../../state';
import { StepContentWrapper } from '../step_content_wrapper';
import * as i18n from './translations';
import { CelConfigResults } from './cel_config_results';
interface ReviewCelStepProps {
celInputResult: State['celInputResult'];
isGenerating: State['isGenerating'];
}
export const ReviewCelStep = React.memo<ReviewCelStepProps>(({ isGenerating, celInputResult }) => {
const { completeStep } = useActions();
return (
<StepContentWrapper title={i18n.TITLE} subtitle={i18n.DESCRIPTION}>
<EuiPanel hasShadow={false} hasBorder data-test-subj="reviewCelStep">
{isGenerating ? (
<EuiLoadingSpinner size="l" />
) : (
<EuiForm
component="form"
fullWidth
onSubmit={(e) => {
e.preventDefault();
completeStep();
}}
>
<CelConfigResults celInputResult={celInputResult} />
</EuiForm>
)}
</EuiPanel>
</StepContentWrapper>
);
});
ReviewCelStep.displayName = 'ReviewCelStep';

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const TITLE = i18n.translate('xpack.integrationAssistant.step.reviewCel.title', {
defaultMessage: 'Review results',
});
export const DESCRIPTION = i18n.translate('xpack.integrationAssistant.step.reviewCel.description', {
defaultMessage:
'Review the generated CEL input configuration settings for your integration. These settings will be auto-populated into the integration configuration where editing will be possible.',
});
export const PROGRAM = i18n.translate('xpack.integrationAssistant.step.reviewCel.program', {
defaultMessage: 'The CEL program to be run for each polling',
});
export const STATE = i18n.translate('xpack.integrationAssistant.step.reviewCel.state', {
defaultMessage: 'Initial CEL evaluation state',
});
export const REDACT_VARS = i18n.translate('xpack.integrationAssistant.step.reviewCel.redact', {
defaultMessage: 'Redacted fields',
});
export const SAVE_BUTTON = i18n.translate('xpack.integrationAssistant.step.reviewCel.save', {
defaultMessage: 'Save',
});

View file

@ -8,7 +8,8 @@
import type { OpenAiProviderType } from '@kbn/stack-connectors-plugin/public/common';
import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
import type { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
import type { InputType, SamplesFormat } from '../../../../common';
import type Oas from 'oas';
import type { CelAuthType, InputType, SamplesFormat } from '../../../../common';
interface GenAiConfig {
apiUrl?: string;
@ -35,5 +36,18 @@ export interface IntegrationSettings {
inputTypes?: InputType[];
logSamples?: string[];
samplesFormat?: SamplesFormat;
apiDefinition?: string;
apiSpec?: Oas;
celUrl?: string;
celPath?: string;
celAuth?: CelAuthType;
}
export interface CelSettings {
suggestedPaths?: string[];
endpoint?: string;
auth?: CelAuthType;
}
export interface ApiPathOptions {
[key: string]: string;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import type Oas from 'oas';
import type {
ComponentsObject,
KeyedSecuritySchemeObject,
MediaTypeObject,
OASDocument,
SecurityType,
} from 'oas/dist/types.cjs';
import { CelAuthTypeEnum } from '../../common/api/model/cel_input_attributes.gen';
import type { CelAuthType } from '../../common';
/**
* Returns the inner-most schema object for the specified path array.
*/
const getSchemaObject = (obj: object, paths: string[]): any => {
const attr = paths[0];
const innerObj = (obj as any)[attr];
if (paths.length > 1) {
const newPaths = paths.slice(1);
return getSchemaObject(innerObj, newPaths);
} else {
return innerObj;
}
};
/**
* Returns any $ref from the specified schema object, or an empty string if there are none.
*/
const getRefValueOrEmpty = (schemaObj: any): string => {
if ('content' in schemaObj) {
const contentObj: MediaTypeObject = schemaObj.content;
for (const obj of Object.values(contentObj)) {
if ('schema' in obj) {
if ('items' in obj.schema) {
return obj.schema.items.$ref;
} else {
return obj.schema.$ref;
}
} else if ('$ref' in obj) {
return obj.$ref;
}
}
} else if ('properties' in schemaObj) {
for (const obj of Object.values(schemaObj.properties)) {
if ('items' in (obj as any)) {
if ('$ref' in (obj as any).items) {
return (obj as any).items.$ref;
}
} else {
if ('$ref' in (obj as any)) {
return (obj as any).$ref;
}
}
}
} else if ('items' in schemaObj) {
if ('$ref' in (schemaObj as any).items) {
return (schemaObj as any).items.$ref;
}
} else if ('$ref' in (schemaObj as any)) {
return (schemaObj as any).$ref;
}
return '';
};
/**
* Returns a list of utilized $refs from the specified layer of the schema.
*/
const getRefs = (refs: Set<string>, oas: OASDocument): Set<string> => {
const layerUsed = new Set<string>();
for (const ref of refs) {
const pathSplits = ref.split('/').filter((split: string) => split !== '#');
if (oas) {
const schemaObj = getSchemaObject(oas, pathSplits);
const refVal = getRefValueOrEmpty(schemaObj);
if (refVal) {
layerUsed.add(refVal);
}
}
}
return layerUsed;
};
/**
* Returns a list of all utilized $refs from the schema.
*/
const buildRefSet = (
allRefs: Set<string>,
refsToCheck: Set<string>,
oas: OASDocument
): Set<string> => {
if (refsToCheck.size > 0) {
const addtlRefs = getRefs(refsToCheck, oas);
const updated = new Set([...allRefs, ...addtlRefs]);
return buildRefSet(updated, addtlRefs, oas);
} else {
return allRefs;
}
};
/**
* Retrieves the OAS spec components down to only those utilized by the specified path.
*/
export function reduceSpecComponents(oas: Oas, path: string): ComponentsObject | undefined {
const operation = oas?.operation(path, 'get');
const responses = operation?.schema.responses;
const usedSchemas = new Set<string>();
if (responses) {
for (const responseObj of Object.values(responses)) {
if ('$ref' in responseObj) {
usedSchemas.add(responseObj.$ref);
}
if ('content' in responseObj) {
const contentObj: MediaTypeObject = responseObj.content;
for (const obj of Object.values(contentObj)) {
if ('schema' in obj) {
if ('items' in obj.schema) {
usedSchemas.add(obj.schema.items.$ref);
} else {
usedSchemas.add(obj.schema.$ref);
}
} else if ('$ref' in obj) {
usedSchemas.add(obj.$ref);
}
}
}
}
}
if (oas?.api) {
const allUsedSchemas = buildRefSet(usedSchemas, usedSchemas, oas?.api);
// iterate the schemas and remove those not used
const reduced: ComponentsObject | undefined = JSON.parse(JSON.stringify(oas?.api.components));
if (reduced) {
for (const [componentType, items] of Object.entries(reduced)) {
for (const component of Object.keys(items)) {
if (!allUsedSchemas.has(`#/components/${componentType}/${component}`)) {
delete reduced[componentType as keyof ComponentsObject]?.[component];
}
}
if (Object.keys(items).length < 1) {
delete reduced[componentType as keyof ComponentsObject];
}
}
}
return reduced;
}
}
/**
* Maps the cel authType to the corresponding auth details from the OAS schema.
*/
export function getAuthDetails(
authType: CelAuthType,
specAuthDetails: Record<SecurityType, KeyedSecuritySchemeObject[]> | undefined
): KeyedSecuritySchemeObject | undefined {
const auth = authType.toLowerCase();
if (auth === CelAuthTypeEnum.basic) {
return specAuthDetails?.Basic ? specAuthDetails?.Basic[0] : undefined;
} else if (auth === CelAuthTypeEnum.oauth2) {
return specAuthDetails?.OAuth2 ? specAuthDetails?.OAuth2[0] : undefined;
} else if (auth === CelAuthTypeEnum.header) {
return specAuthDetails?.Header ? specAuthDetails?.Header[0] : undefined;
} else if (auth === CelAuthTypeEnum.digest) {
return specAuthDetails?.http ? specAuthDetails?.http[0] : undefined;
} else {
// should never get here
throw new Error('unsupported auth method');
}
}

View file

@ -22,6 +22,7 @@ import { getRelatedGraph } from '../server/graphs/related/graph';
import { getKVGraph } from '../server/graphs/kv/graph';
import { getUnstructuredGraph } from '../server/graphs/unstructured';
import { getCelGraph } from '../server/graphs/cel/graph';
import { getApiAnalysisGraph } from '../server/graphs/api_analysis';
// Some mock elements just to get the graph to compile
const model = new FakeLLM({
@ -56,6 +57,7 @@ const GRAPH_LIST = {
ecs_subgraph: getEcsSubGraph,
unstructured_graph: getUnstructuredGraph,
cel_graph: getCelGraph,
analyze_api_graph: getApiAnalysisGraph,
};
export async function drawGraphs() {

View file

@ -0,0 +1,8 @@
/*
* 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 EX_ANSWER_PATHS = ['/path1', '/path2', '/path3'];

View file

@ -0,0 +1,61 @@
/*
* 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 { FakeLLM } from '@langchain/core/utils/testing';
import { getApiAnalysisGraph } from './graph';
import {
apiAnalysisPathSuggestionsMockedResponse,
apiAnalysisExpectedResults,
} from '../../../__jest__/fixtures/api_analysis';
import {
ActionsClientChatOpenAI,
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server/language_models';
import { mockedApiAnalysisRequest } from '../../../__jest__/fixtures';
import { handleGetSuggestedPaths } from './paths';
const model = new FakeLLM({
response: "I'll callback later.",
}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel;
jest.mock('./paths');
describe('CelGraph', () => {
beforeEach(() => {
// Mocked responses for each node that requires an LLM API call/response.
const mockInvokePathSuggestions = jest
.fn()
.mockResolvedValue(apiAnalysisPathSuggestionsMockedResponse);
(handleGetSuggestedPaths as jest.Mock).mockImplementation(async () => ({
suggestedPaths: await mockInvokePathSuggestions(),
lastExecutedChain: 'getSuggestedPaths',
}));
});
it('Ensures that the graph compiles', async () => {
// When getCelGraph runs, langgraph compiles the graph it will error if the graph has any issues.
// Common issues for example detecting a node has no next step, or there is a infinite loop between them.
try {
await getApiAnalysisGraph({ model });
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
});
it('Runs the whole graph, with mocked outputs from the LLM.', async () => {
const celGraph = await getApiAnalysisGraph({ model });
let response;
try {
response = await celGraph.invoke(mockedApiAnalysisRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleGetSuggestedPaths).toHaveBeenCalled();
expect(response.results).toStrictEqual(apiAnalysisExpectedResults);
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 { StateGraphArgs } from '@langchain/langgraph';
import { END, START, StateGraph } from '@langchain/langgraph';
import type { ApiAnalysisState } from '../../types';
import { ApiAnalysisGraphParams, ApiAnalysisBaseNodeParams } from './types';
import { handleGetSuggestedPaths } from './paths';
const graphState: StateGraphArgs<ApiAnalysisState>['channels'] = {
dataStreamName: {
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
pathOptions: {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
results: {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
suggestedPaths: {
value: (x: string[], y?: string[]) => y ?? x,
default: () => [],
},
lastExecutedChain: {
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
};
function modelInput({ state }: ApiAnalysisBaseNodeParams): Partial<ApiAnalysisState> {
return {
lastExecutedChain: 'modelInput',
pathOptions: state.pathOptions,
dataStreamName: state.dataStreamName,
};
}
function modelOutput({ state }: ApiAnalysisBaseNodeParams): Partial<ApiAnalysisState> {
return {
lastExecutedChain: 'modelOutput',
results: {
suggestedPaths: state.suggestedPaths,
},
};
}
export async function getApiAnalysisGraph({ model }: ApiAnalysisGraphParams) {
const workflow = new StateGraph({ channels: graphState })
.addNode('modelInput', (state: ApiAnalysisState) => modelInput({ state }))
.addNode('handleGetSuggestedPaths', (state: ApiAnalysisState) =>
handleGetSuggestedPaths({ state, model })
)
.addNode('modelOutput', (state: ApiAnalysisState) => modelOutput({ state }))
.addEdge(START, 'modelInput')
.addEdge('modelInput', 'handleGetSuggestedPaths')
.addEdge('handleGetSuggestedPaths', 'modelOutput')
.addEdge('modelOutput', END);
const compiledApiAnalysisGraph = workflow.compile();
return compiledApiAnalysisGraph;
}

View file

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

View file

@ -0,0 +1,28 @@
/*
* 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 {
ActionsClientChatOpenAI,
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server/language_models';
import { FakeLLM } from '@langchain/core/utils/testing';
import { apiAnalysisTestState } from '../../../__jest__/fixtures/api_analysis';
import type { ApiAnalysisState } from '../../types';
import { handleGetSuggestedPaths } from './paths';
const model = new FakeLLM({
response: '[ "/path1", "/path2" ]',
}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel;
const state: ApiAnalysisState = apiAnalysisTestState;
describe('Testing api analysis handler', () => {
it('handleGetSuggestedPaths()', async () => {
const response = await handleGetSuggestedPaths({ state, model });
expect(response.suggestedPaths).toStrictEqual(['/path1', '/path2']);
expect(response.lastExecutedChain).toBe('getSuggestedPaths');
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { JsonOutputParser } from '@langchain/core/output_parsers';
import { ApiAnalysisState } from '../../types';
import { ApiAnalysisNodeParams } from './types';
import { SUGGESTED_PATHS_PROMPT } from './prompts';
import { EX_ANSWER_PATHS } from './constants';
export async function handleGetSuggestedPaths({
state,
model,
}: ApiAnalysisNodeParams): Promise<Partial<ApiAnalysisState>> {
const outputParser = new JsonOutputParser();
const suggestedPathsGraph = SUGGESTED_PATHS_PROMPT.pipe(model).pipe(outputParser);
const paths = await suggestedPathsGraph.invoke({
data_stream_title: state.dataStreamName,
path_options: state.pathOptions,
ex_answer: EX_ANSWER_PATHS,
});
return {
suggestedPaths: paths as string[],
lastExecutedChain: 'getSuggestedPaths',
};
}

View file

@ -0,0 +1,43 @@
/*
* 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 { ChatPromptTemplate } from '@langchain/core/prompts';
export const SUGGESTED_PATHS_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
`You are a helpful, expert assistant in REST APIs.
Here is some context for you to reference for your task, read it carefully as you will get questions about it later:
<context>
<path_options>
{path_options}
</path_options>
</context>`,
],
[
'human',
`Review each of the path_options specified as each option represents a REST endpoint path and a short description of that path. Please determine from the provided options
a list of recommendations for which paths to use to retrieve data relevant to {data_stream_title}. Return at least 1, but up to 4 options in order of best fit. Be sure
to only respond with exact path from the options provided.
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- Prioritize bulk api routes over more specialized routes.
- Try and return as many options from the provided list as possible, while maintaining preference order.
- Your response must only include exact paths specified in the path_options.
</guidelines>
Please respond with a string array of the suggested paths.
<example_response>
A: Please find the suggested paths below:
\`\`\`
{ex_answer}
\`\`\`
</example_response>`,
],
['ai', `Please find the suggested paths below:`],
]);

View file

@ -0,0 +1,20 @@
/*
* 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 { ApiAnalysisState, ChatModels } from '../../types';
export interface ApiAnalysisBaseNodeParams {
state: ApiAnalysisState;
}
export interface ApiAnalysisNodeParams extends ApiAnalysisBaseNodeParams {
model: ChatModels;
}
export interface ApiAnalysisGraphParams {
model: ChatModels;
}

View file

@ -0,0 +1,28 @@
/*
* 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 {
ActionsClientChatOpenAI,
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server/language_models';
import { FakeLLM } from '@langchain/core/utils/testing';
import { celTestState } from '../../../__jest__/fixtures/cel';
import type { CelInputState } from '../../types';
import { handleAnalyzeHeaders } from './analyze_headers';
const model = new FakeLLM({
response: 'true',
}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel;
const state: CelInputState = celTestState;
describe('Testing cel handler', () => {
it('handleAnalyzeHeaders()', async () => {
const response = await handleAnalyzeHeaders({ state, model });
expect(response.hasProgramHeaders).toStrictEqual(true);
expect(response.lastExecutedChain).toBe('analyzeProgramHeaders');
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 { StringOutputParser } from '@langchain/core/output_parsers';
import { CelInputState } from '../../types';
import { CEL_ANALYZE_HEADERS_PROMPT } from './prompts';
import { CelInputNodeParams } from './types';
export async function handleAnalyzeHeaders({
state,
model,
}: CelInputNodeParams): Promise<Partial<CelInputState>> {
const outputParser = new StringOutputParser();
const celProgramGraph = CEL_ANALYZE_HEADERS_PROMPT.pipe(model).pipe(outputParser);
const hasProgramHeadersResult = await celProgramGraph.invoke({
cel_program: state.currentProgram,
});
return {
hasProgramHeaders: JSON.parse(hasProgramHeadersResult),
lastExecutedChain: 'analyzeProgramHeaders',
};
}

View file

@ -0,0 +1,28 @@
/*
* 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 {
ActionsClientChatOpenAI,
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server/language_models';
import { FakeLLM } from '@langchain/core/utils/testing';
import { celTestState } from '../../../__jest__/fixtures/cel';
import type { CelInputState } from '../../types';
import { handleUpdateProgramBasic } from './auth_basic';
const model = new FakeLLM({
response: 'my_updated_cel_program',
}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel;
const state: CelInputState = celTestState;
describe('Testing cel handler', () => {
it('handleUpdateProgramBasic()', async () => {
const response = await handleUpdateProgramBasic({ state, model });
expect(response.currentProgram).toStrictEqual('my_updated_cel_program');
expect(response.lastExecutedChain).toBe('updateProgramBasicAuth');
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { StringOutputParser } from '@langchain/core/output_parsers';
import { CelInputState } from '../../types';
import { SAMPLE_CEL_PROGRAMS } from './constants';
import { CEL_AUTH_BASIC_PROMPT } from './prompts';
import { CelInputNodeParams } from './types';
export async function handleUpdateProgramBasic({
state,
model,
}: CelInputNodeParams): Promise<Partial<CelInputState>> {
const outputParser = new StringOutputParser();
const updateCelProgramBasicGraph = CEL_AUTH_BASIC_PROMPT.pipe(model).pipe(outputParser);
const updatedProgram = await updateCelProgramBasicGraph.invoke({
cel_program: state.currentProgram,
open_api_auth_schema: state.openApiAuthSchema,
example_cel_programs: SAMPLE_CEL_PROGRAMS,
});
return {
currentProgram: updatedProgram.trim(),
lastExecutedChain: 'updateProgramBasicAuth',
};
}

View file

@ -0,0 +1,28 @@
/*
* 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 {
ActionsClientChatOpenAI,
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server/language_models';
import { FakeLLM } from '@langchain/core/utils/testing';
import { celTestState } from '../../../__jest__/fixtures/cel';
import type { CelInputState } from '../../types';
import { handleRemoveHeadersDigest } from './auth_digest';
const model = new FakeLLM({
response: 'my_updated_cel_program',
}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel;
const state: CelInputState = celTestState;
describe('Testing cel handler', () => {
it('handleRemoveHeadersDigest()', async () => {
const response = await handleRemoveHeadersDigest({ state, model });
expect(response.currentProgram).toStrictEqual('my_updated_cel_program');
expect(response.lastExecutedChain).toBe('removeHeadersDigest');
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { StringOutputParser } from '@langchain/core/output_parsers';
import { CelInputState } from '../../types';
import { SAMPLE_CEL_PROGRAMS } from './constants';
import { CEL_AUTH_DIGEST_PROMPT } from './prompts';
import { CelInputNodeParams } from './types';
export async function handleRemoveHeadersDigest({
state,
model,
}: CelInputNodeParams): Promise<Partial<CelInputState>> {
const outputParser = new StringOutputParser();
const updateCelProgramDigestGraph = CEL_AUTH_DIGEST_PROMPT.pipe(model).pipe(outputParser);
const updatedProgram = await updateCelProgramDigestGraph.invoke({
cel_program: state.currentProgram,
example_cel_programs: SAMPLE_CEL_PROGRAMS,
});
return {
currentProgram: updatedProgram.trim(),
lastExecutedChain: 'removeHeadersDigest',
};
}

View file

@ -0,0 +1,29 @@
/*
* 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 { StringOutputParser } from '@langchain/core/output_parsers';
import { CelInputState } from '../../types';
import { CEL_AUTH_HEADERS_PROMPT as CEL_AUTH_HEADER_PROMPT } from './prompts';
import { CelInputNodeParams } from './types';
export async function handleUpdateProgramHeaderAuth({
state,
model,
}: CelInputNodeParams): Promise<Partial<CelInputState>> {
const outputParser = new StringOutputParser();
const updateCelProgramHeadersGraph = CEL_AUTH_HEADER_PROMPT.pipe(model).pipe(outputParser);
const updatedProgram = await updateCelProgramHeadersGraph.invoke({
cel_program: state.currentProgram,
open_api_auth_schema: state.openApiAuthSchema,
});
return {
currentProgram: updatedProgram.trim(),
lastExecutedChain: 'updateProgramHeaderAuth',
};
}

View file

@ -0,0 +1,28 @@
/*
* 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 {
ActionsClientChatOpenAI,
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server/language_models';
import { FakeLLM } from '@langchain/core/utils/testing';
import { celTestState } from '../../../__jest__/fixtures/cel';
import type { CelInputState } from '../../types';
import { handleUpdateProgramOauth2 } from './auth_oauth2';
const model = new FakeLLM({
response: 'my_updated_cel_program',
}) as unknown as ActionsClientChatOpenAI | ActionsClientSimpleChatModel;
const state: CelInputState = celTestState;
describe('Testing cel handler', () => {
it('handleUpdateProgramOauth2()', async () => {
const response = await handleUpdateProgramOauth2({ state, model });
expect(response.currentProgram).toStrictEqual('my_updated_cel_program');
expect(response.lastExecutedChain).toBe('updateProgramOAuth2');
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { StringOutputParser } from '@langchain/core/output_parsers';
import { CelInputState } from '../../types';
import { SAMPLE_CEL_PROGRAMS_OAUTH } from './constants';
import { CEL_AUTH_OAUTH2_PROMPT } from './prompts';
import { CelInputNodeParams } from './types';
export async function handleUpdateProgramOauth2({
state,
model,
}: CelInputNodeParams): Promise<Partial<CelInputState>> {
const outputParser = new StringOutputParser();
const updateCelProgramHeadersGraph = CEL_AUTH_OAUTH2_PROMPT.pipe(model).pipe(outputParser);
const updatedProgram = await updateCelProgramHeadersGraph.invoke({
cel_program: state.currentProgram,
open_api_auth_schema: state.openApiAuthSchema,
example_cel_programs: SAMPLE_CEL_PROGRAMS_OAUTH,
});
return {
currentProgram: updatedProgram.trim(),
lastExecutedChain: 'updateProgramOAuth2',
};
}

View file

@ -21,7 +21,8 @@ export async function handleBuildProgram({
const program = await celProgramGraph.invoke({
data_stream_name: state.dataStreamName,
example_cel_programs: SAMPLE_CEL_PROGRAMS,
open_api_spec: state.apiDefinition,
open_api_path_details: state.openApiPathDetails,
open_api_schemas: state.openApiSchemas,
api_query_summary: state.apiQuerySummary,
ex_answer: EX_ANSWER_PROGRAM,
});

View file

@ -60,7 +60,12 @@ export const SAMPLE_CEL_PROGRAMS = [
"error": {
"code": string(resp.StatusCode),
"id": string(resp.Status),
"message": string(resp.Body)
"message": "GET:"+(
size(resp.Body) != 0 ?
string(resp.Body)
:
string(resp.Status) + ' (' + string(resp.StatusCode) + ')'
),
},
},
"want_more": false,
@ -143,7 +148,12 @@ export const SAMPLE_CEL_PROGRAMS = [
"error": {
"code": string(resp.StatusCode),
"id": string(resp.Status),
"message": string(resp.Body)
"message": "GET:"+(
size(resp.Body) != 0 ?
string(resp.Body)
:
string(resp.Status) + ' (' + string(resp.StatusCode) + ')'
),
},
},
"want_more": false,
@ -214,16 +224,17 @@ export const SAMPLE_CEL_PROGRAMS = [
).with(auth_header).do_request().as(resp, resp.StatusCode != 200 ?
{
"id": item.id,
"events": [{
"events": {
"error": {
"code": string(resp.StatusCode),
"id": string(resp.Status),
"message": size(resp.Body) != 0 ?
string(resp.Body)
:
string(resp.Status) + ' (' + string(resp.StatusCode) + ')',
}
}],
"message": "GET:"+(
size(resp.Body) != 0 ?
string(resp.Body)
:
string(resp.Status) + ' (' + string(resp.StatusCode) + ')'
),
},
"want_more": false,
}
:
@ -296,3 +307,184 @@ export const SAMPLE_CEL_PROGRAMS = [
})
)`,
];
export const SAMPLE_CEL_PROGRAMS_OAUTH = [
`state.with(
post(state.url.trim_right("/") + "/oauth/token", "application/json", {
"client_id": state.client_id,
"client_secret": state.client_secret,
"audience": state.url.trim_right("/") + "/api/v2/",
"grant_type": "client_credentials",
}.encode_json()).as(auth_resp, auth_resp.StatusCode != 200 ?
{
"events": {
"error": {
"code": string(auth_resp.StatusCode),
"id": string(auth_resp.Status),
"message": "POST:"+(
size(auth_resp.Body) != 0 ?
string(auth_resp.Body)
:
string(auth_resp.Status) + ' (' + string(auth_resp.StatusCode) + ')'
),
},
},
"want_more": false,
}
:
{
"Body": bytes(auth_resp.Body).decode_json(),
}
).as(token, has(token.events) ? token :
get_request(
state.?next.orValue(
has(state.?cursor.next) ?
// Use the cursor next rel link if it exists.
state.cursor.next.parse_url().as(next, next.with({
// The next rel link includes the take parameter which the
// user may have changed, so replace it with the config's
// value.
"RawQuery": next.RawQuery.parse_query().with({
?"take": has(state.take) ?
optional.of([string(state.take)])
:
optional.none(),
}).format_query()
}).format_url())
:
// Otherwise construct a next rel-ish link to look back.
state.url.trim_right("/") + "/api/v2/logs?" + {
?"take": has(state.take) ?
optional.of([string(state.take)])
:
optional.none(),
?"from": has(state.look_back) ?
// Format a relative timestamp into a log ID.
optional.of(["900" + (now-duration(state.look_back)).format("20060102150405") + "000000000000000000000000000000000000000"])
:
optional.none(),
}.format_query()
)
).with({
"Header": {
"Authorization": [token.?Body.token_type.orValue("Bearer") + " " + token.?Body.access_token.orValue("MISSING")],
"Accept": ["application/json"],
}
}).do_request().as(resp, resp.StatusCode != 200 ?
{
"events": {
"error": {
"code": string(resp.StatusCode),
"id": string(resp.Status),
"message": "GET:"+(
size(resp.Body) != 0 ?
string(resp.Body)
:
string(resp.Status) + ' (' + string(resp.StatusCode) + ')'
),
},
},
"want_more": false,
}
:
{
"Body": bytes(resp.Body).decode_json(),
?"next": resp.Header.?Link[0].orValue("").as(next, next.split(";").as(attrs, attrs.exists(attr, attr.contains('rel="next"')) ?
attrs.map(attr, attr.matches("^<https?://"), attr.trim_prefix('<').trim_suffix('>'))[?0]
:
optional.none()
)),
}.as(result, result.with({
"events": result.Body.map(e, {"json": {"log_id": e.log_id, "data": e}}),
"cursor": {
?"next": result.?next,
},
"want_more": has(result.next) && size(result.Body) != 0,
})).drop("Body")
)
)
)`,
`request("POST", state.url.trim_right("/") + "/auth/external").with(
{
"Header": {
"Accept": ["application/json"],
"Content-Type": ["application/json"],
},
"Body": {
"clientId": state.auth_client_id,
"accessKey": state.auth_access_key,
}.encode_json(),
}
).do_request().as(resp,
(resp.StatusCode == 200) ?
bytes(resp.Body).decode_json().as(body,
body.data.token
)
:
bytes(resp.Body).decode_json().as(body,
body.message
)
).as(auth_token,
// submit logs query to search security event logs
request("POST", state.url.trim_right("/") + "/app/laas-logs-api/api/logs_query").with(
{
"Header": {
"Accept": ["application/json"],
"Content-Type": ["application/json"],
"Authorization": ["Bearer " + auth_token],
},
"Body": {
"filter": state.filter,
"limit": state.limit,
"pageLimit": state.page_limit,
"cloudService": "Harmony Endpoint",
"timeframe": {
"startTime": (state.?cursor.next_startTime.orValue(null) == null) ?
timestamp(now() - duration(state.initial_interval)).format(time_layout.RFC3339)
:
timestamp(state.cursor.next_startTime).format(time_layout.RFC3339),
"endTime": timestamp(now().format(time_layout.RFC3339)),
},
}.encode_json(),
}
).do_request().as(resp,
(resp.StatusCode == 200) ?
bytes(resp.Body).decode_json().as(body,
state.with(
{
"events": [{ "message": { "event": { "reason": "polling" }}.encode_json() }],
"want_more": true,
"cursor": {
"auth_token": auth_token,
"task_id": body.data.taskId,
"task_ready": false,
"page_token": null,
"next_startTime": (has(state.cursor) && has(state.cursor.next_startTime)) ? state.cursor.next_startTime : null,
"last_page": false,
},
}
)
)
:
state.with(
{
"events": {
"error": {
"message": "Error " + bytes(resp.Body).decode_json().as(body, body.message),
},
},
"want_more": false,
"cursor": state.cursor.with(
{
"auth_token": null,
"task_id": null,
"task_ready": false,
"page_token": null,
"last_page": false,
}
),
}
)
)
)`,
];

View file

@ -14,10 +14,16 @@ import {
celExpectedResults,
celStateSettings,
celRedact,
celConfigFields,
} from '../../../__jest__/fixtures/cel';
import { mockedRequestWithApiDefinition } from '../../../__jest__/fixtures';
import { mockedRequestWithCelDetails } from '../../../__jest__/fixtures';
import { handleSummarizeQuery } from './summarize_query';
import { handleBuildProgram } from './build_program';
import { handleUpdateProgramHeaderAuth } from './auth_header';
import { handleAnalyzeHeaders } from './analyze_headers';
import { handleUpdateProgramBasic } from './auth_basic';
import { handleRemoveHeadersDigest } from './auth_digest';
import { handleUpdateProgramOauth2 } from './auth_oauth2';
import { handleGetStateVariables } from './retrieve_state_vars';
import { handleGetStateDetails } from './retrieve_state_details';
@ -32,6 +38,11 @@ const model = new FakeLLM({
jest.mock('./summarize_query');
jest.mock('./build_program');
jest.mock('./auth_header');
jest.mock('./analyze_headers');
jest.mock('./auth_basic');
jest.mock('./auth_digest');
jest.mock('./auth_oauth2');
jest.mock('./retrieve_state_vars');
jest.mock('./retrieve_state_details');
@ -42,6 +53,7 @@ describe('CelGraph', () => {
const mockInvokeCelProgram = jest.fn().mockResolvedValue(celProgramMock);
const mockInvokeCelStateVars = jest.fn().mockResolvedValue(celStateVarsMockedResponse);
const mockInvokeCelStateSettings = jest.fn().mockResolvedValue(celStateSettings);
const mockInvokeCelConfigFields = jest.fn().mockResolvedValue(celConfigFields);
const mockInvokeCelRedactVars = jest.fn().mockResolvedValue(celRedact);
// Returns the initial query summary for the api, to trigger the next step.
@ -65,6 +77,7 @@ describe('CelGraph', () => {
// Returns the state details for the CEL program.
(handleGetStateDetails as jest.Mock).mockImplementation(async () => ({
stateSettings: await mockInvokeCelStateSettings(),
configFields: await mockInvokeCelConfigFields(),
redactVars: await mockInvokeCelRedactVars(),
lastExecutedChain: 'getStateDetails',
}));
@ -80,20 +93,169 @@ describe('CelGraph', () => {
}
});
it('Runs the whole graph, with mocked outputs from the LLM.', async () => {
describe('Runs the whole graph, with mocked outputs from the LLM.', () => {
it('header auth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'header' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleUpdateProgramHeaderAuth).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
expect(response.results).toStrictEqual(celExpectedResults);
});
describe('program with headers', () => {
beforeEach(() => {
const mockInvokeAnalyzeHeaders = jest.fn().mockResolvedValue(true);
(handleAnalyzeHeaders as jest.Mock).mockImplementation(async () => ({
hasProgramHeaders: await mockInvokeAnalyzeHeaders(),
lastExecutedChain: 'analyzeProgramHeaders',
}));
});
it('basic auth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'basic' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleAnalyzeHeaders).toHaveBeenCalled();
expect(handleUpdateProgramBasic).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
expect(response.results).toStrictEqual(celExpectedResults);
});
it('digest auth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'digest' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleAnalyzeHeaders).toHaveBeenCalled();
expect(handleRemoveHeadersDigest).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
expect(response.results).toStrictEqual(celExpectedResults);
});
it('oauth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'oauth2' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleAnalyzeHeaders).toHaveBeenCalled();
expect(handleUpdateProgramOauth2).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
expect(response.results).toStrictEqual(celExpectedResults);
});
});
});
});
describe('program without headers', () => {
beforeEach(() => {
const mockInvokeAnalyzeHeaders = jest.fn().mockResolvedValue(false);
(handleAnalyzeHeaders as jest.Mock).mockImplementation(async () => ({
hasProgramHeaders: await mockInvokeAnalyzeHeaders(),
lastExecutedChain: 'analyzeProgramHeaders',
}));
});
it('basic auth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
response = await celGraph.invoke(mockedRequestWithApiDefinition);
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'basic' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleAnalyzeHeaders).toHaveBeenCalled();
expect(handleUpdateProgramBasic).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
expect(response.results).toStrictEqual(celExpectedResults);
const expected = { ...celExpectedResults, needsAuthConfigBlock: true };
expect(response.results).toStrictEqual(expected);
});
it('digest auth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'digest' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleAnalyzeHeaders).toHaveBeenCalled();
expect(handleRemoveHeadersDigest).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
const expected = { ...celExpectedResults, needsAuthConfigBlock: true };
expect(response.results).toStrictEqual(expected);
});
it('oauth', async () => {
const celGraph = await getCelGraph({ model });
let response;
try {
const mockRequest = { ...mockedRequestWithCelDetails, authType: 'oauth2' };
response = await celGraph.invoke(mockRequest);
} catch (error) {
throw Error(`getCelGraph threw an error: ${error}`);
}
expect(handleSummarizeQuery).toHaveBeenCalled();
expect(handleBuildProgram).toHaveBeenCalled();
expect(handleAnalyzeHeaders).toHaveBeenCalled();
expect(handleUpdateProgramOauth2).toHaveBeenCalled();
expect(handleGetStateVariables).toHaveBeenCalled();
expect(handleGetStateDetails).toHaveBeenCalled();
const expected = { ...celExpectedResults, needsAuthConfigBlock: true };
expect(response.results).toStrictEqual(expected);
});
});

View file

@ -7,8 +7,15 @@
import type { StateGraphArgs } from '@langchain/langgraph';
import { END, START, StateGraph } from '@langchain/langgraph';
import { CelAuthTypeEnum } from '../../../common/api/model/cel_input_attributes.gen';
import { CelAuthType } from '../../../common';
import type { CelInputState } from '../../types';
import { handleBuildProgram } from './build_program';
import { handleAnalyzeHeaders } from './analyze_headers';
import { handleUpdateProgramHeaderAuth } from './auth_header';
import { handleUpdateProgramBasic } from './auth_basic';
import { handleUpdateProgramOauth2 } from './auth_oauth2';
import { handleRemoveHeadersDigest } from './auth_digest';
import { handleGetStateDetails } from './retrieve_state_details';
import { handleGetStateVariables } from './retrieve_state_vars';
import { handleSummarizeQuery } from './summarize_query';
@ -31,22 +38,18 @@ const graphState: StateGraphArgs<CelInputState>['channels'] = {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
apiDefinition: {
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
apiQuerySummary: {
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
exampleCelPrograms: {
value: (x: string[], y?: string[]) => y ?? x,
default: () => [],
},
currentProgram: {
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
hasProgramHeaders: {
value: (x: boolean | undefined, y?: boolean | undefined) => y ?? x,
default: () => undefined,
},
stateVarNames: {
value: (x: string[], y?: string[]) => y ?? x,
default: () => [],
@ -55,33 +58,88 @@ const graphState: StateGraphArgs<CelInputState>['channels'] = {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
configFields: {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
redactVars: {
value: (x: string[], y?: string[]) => y ?? x,
default: () => [],
},
authType: {
value: (x: CelAuthType, y?: CelAuthType) => y ?? x,
default: () => 'header',
},
path: {
value: (x: string, y?: string) => y ?? x,
default: () => '',
},
openApiPathDetails: {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
openApiSchemas: {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
openApiAuthSchema: {
value: (x: object, y?: object) => y ?? x,
default: () => ({}),
},
};
function modelInput({ state }: CelInputBaseNodeParams): Partial<CelInputState> {
return {
const input = {
finalized: false,
lastExecutedChain: 'modelInput',
apiDefinition: state.apiDefinition,
path: state.path,
authType: state.authType,
openApiPathDetails: state.openApiPathDetails,
openApiSchemas: state.openApiSchemas,
openApiAuthSchema: state.openApiAuthSchema,
dataStreamName: state.dataStreamName,
};
return input;
}
function modelOutput({ state }: CelInputBaseNodeParams): Partial<CelInputState> {
const needsAuthConfigBlock = !state.hasProgramHeaders && state.authType !== 'header';
return {
finalized: true,
lastExecutedChain: 'modelOutput',
results: {
program: state.currentProgram,
stateSettings: state.stateSettings,
configFields: state.configFields,
redactVars: state.redactVars,
needsAuthConfigBlock,
},
};
}
function headerAuthRouter({ state }: CelInputBaseNodeParams): string {
if (state.authType === CelAuthTypeEnum.header) {
return 'headerAuthUpdate';
}
return 'analyzeProgramForExistingHeaders';
}
function authRouter({ state }: CelInputBaseNodeParams): string {
if (state.authType === CelAuthTypeEnum.oauth2 && state.hasProgramHeaders) {
return 'oauth2Update';
}
if (state.authType === CelAuthTypeEnum.basic && state.hasProgramHeaders) {
return 'basicUpdate';
}
if (state.authType === CelAuthTypeEnum.digest && state.hasProgramHeaders) {
return 'digestUpdate';
}
return 'noExistingHeaders';
}
export async function getCelGraph({ model }: CelInputGraphParams) {
const workflow = new StateGraph({ channels: graphState })
.addNode('modelInput', (state: CelInputState) => modelInput({ state }))
@ -89,6 +147,21 @@ export async function getCelGraph({ model }: CelInputGraphParams) {
handleSummarizeQuery({ state, model })
)
.addNode('handleBuildProgram', (state: CelInputState) => handleBuildProgram({ state, model }))
.addNode('handleAnalyzeProgramHeaders', (state: CelInputState) =>
handleAnalyzeHeaders({ state, model })
)
.addNode('handleUpdateProgramHeaderAuth', (state: CelInputState) =>
handleUpdateProgramHeaderAuth({ state, model })
)
.addNode('handleUpdateProgramBasic', (state: CelInputState) =>
handleUpdateProgramBasic({ state, model })
)
.addNode('handleUpdateProgramOauth2', (state: CelInputState) =>
handleUpdateProgramOauth2({ state, model })
)
.addNode('handleRemoveHeadersDigest', (state: CelInputState) =>
handleRemoveHeadersDigest({ state, model })
)
.addNode('handleGetStateVariables', (state: CelInputState) =>
handleGetStateVariables({ state, model })
)
@ -100,9 +173,31 @@ export async function getCelGraph({ model }: CelInputGraphParams) {
.addEdge('modelOutput', END)
.addEdge('modelInput', 'handleSummarizeQuery')
.addEdge('handleSummarizeQuery', 'handleBuildProgram')
.addEdge('handleBuildProgram', 'handleGetStateVariables')
.addEdge('handleUpdateProgramHeaderAuth', 'handleGetStateVariables')
.addEdge('handleUpdateProgramOauth2', 'handleGetStateVariables')
.addEdge('handleUpdateProgramBasic', 'handleGetStateVariables')
.addEdge('handleRemoveHeadersDigest', 'handleGetStateVariables')
.addEdge('handleGetStateVariables', 'handleGetStateDetails')
.addEdge('handleGetStateDetails', 'modelOutput');
.addEdge('handleGetStateDetails', 'modelOutput')
.addConditionalEdges(
'handleBuildProgram',
(state: CelInputState) => headerAuthRouter({ state }),
{
headerAuthUpdate: 'handleUpdateProgramHeaderAuth',
analyzeProgramForExistingHeaders: 'handleAnalyzeProgramHeaders',
}
)
.addConditionalEdges(
'handleAnalyzeProgramHeaders',
(state: CelInputState) => authRouter({ state }),
{
oauth2Update: 'handleUpdateProgramOauth2',
basicUpdate: 'handleUpdateProgramBasic',
digestUpdate: 'handleRemoveHeadersDigest',
noExistingHeaders: 'handleGetStateVariables',
}
);
const compiledCelGraph = workflow.compile();
return compiledCelGraph;

View file

@ -12,19 +12,14 @@ export const CEL_QUERY_SUMMARY_PROMPT = ChatPromptTemplate.fromMessages([
`You are a helpful, expert assistant in REST APIs and OpenAPI specifications.
Here is some context for you to reference for your task, read it carefully as you will get questions about it later:
<context>
<open_api_spec>
{open_api_spec}
</open_api_spec>
<path_details>
{path_details}
</path_details>
</context>`,
],
[
'human',
`For the {data_stream_name} endpoint and provided OpenAPI specification, please describe which query parameters you would use so that all events are covered in a chronological manner.
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- Prioritize bulk api routes over more specialized routes.
</guidelines>
`For the {path} endpoint and provided OpenAPI specification, please describe which query parameters you would use so that all {data_stream_name} events are covered in a chronological manner.
Please respond with a concise text answer, and a sample URL path.`,
],
@ -37,9 +32,12 @@ export const CEL_BASE_PROGRAM_PROMPT = ChatPromptTemplate.fromMessages([
`You are a helpful, expert assistant in building Elastic filebeat input configurations utilizing the Common Expression Language (CEL) input type.
Here is some context for you to reference your task, review it carefully as you will get questions about it later:
<context>
<open_api_spec>
{open_api_spec}
</open_api_spec>
<open_api_path_details>
{open_api_path_details}
</open_api_path_details>
<open_api_schemas>
{open_api_schemas}
</open_api_schemas>
<example_cel_programs>
{example_cel_programs}
</example_cel_programs>
@ -57,8 +55,10 @@ Utilize the following paging summary details and sample URL for implementing pag
</context>
Each of the following criteria must be addressed in final configuration output:
- The entire program must be wrapped with \`state.with()\`.
- The REST verb must be specified.
- The request URL must include the 'state.url'.
- Any reference to 'state.url' must append \`.trim_right("/")\`.
- The request URL must include the API path.
- The request URL must include all query parameters from the paging summary using a 'format_query' function. Remember to utilize the state variables inside brackets when building the function and be sure to cast any numeric variables to string using 'string(variable)'.
- All request URL parameters must utilize state variables.
@ -66,9 +66,11 @@ Each of the following criteria must be addressed in final configuration output:
- Always use the casing specified by the API spec when building the API path and query parameters.
- There must not be configuration for authentication or authorization.
- There must be configuration of any required headers.
- Any usage of state variables must be optional like \`"token": state.?cursor.token.optMap(v, [v]),\`.
- There must be configuration for parsing the events returned from the API mapped to the 'message' field and encoded in JSON.
- There must be configuration in the API response handling for 'want_more' based on the paging token.
- There must be configuration for error handling. This includes setting the 'want_more' flag to false.
- Be sure to only return a single object as the error, never an array of objects.
- All state variables must use snake casing.
- The page tokens must be updated the corresponding state variable(s).
@ -77,6 +79,7 @@ You ALWAYS follow these guidelines when writing your response:
- You must never include any code for writing data to the API.
- You must respond only with the code block containing the program formatted like human-readable C code. See example response below.
- You must use 2 spaces for tab size.
- The final program must not be enclosed in parentheses.
- Do not enclose the final output in backticks, only return the codeblock and nothing else.
</guidelines>
@ -87,6 +90,23 @@ A: Please find the CEL program below:
['ai', `Please find the CEL program below:`],
]);
export const CEL_ANALYZE_HEADERS_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
`You are a helpful, expert assistant in writing and analyzing CEL programs for Elastic filebeat. Here is some context for you to reference for your task, read it carefully as you will get questions about it later:
<context>
<cel_program>
{cel_program}
</cel_program>
</context>`,
],
[
'human',
`Looking at the CEL program provided in the context, please return a boolean response for whether or not the program contains any headers on the HTTP request to get events. Return true if there are headers, and false if there are none. Do not respond with anything except the boolean answer.`,
],
['ai', `Please find the boolean answer below:`],
]);
export const CEL_STATE_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
@ -126,21 +146,28 @@ export const CEL_CONFIG_DETAILS_PROMPT = ChatPromptTemplate.fromMessages([
Here is some context for you to reference for your task, read it carefully as you will get questions about it later:
<context>
<open_api_spec>
{open_api_spec}
</open_api_spec>
<open_api_path_details>
{open_api_path_details}
</open_api_path_details>
</context>`,
],
[
'human',
`For the identified state variables {state_variables}, iterate through each variable (name) and identify a default value (default) and a boolean representing if it should be redacted(redact). Return all of this information in a JSON object like the sample below.
`For the identified state variables {state_variables}, iterate through each variable (name) and identify a boolean representing if it should be user configurable (configurable), a helpful description (description), type (type), default value (default), and a boolean representing if it should be redacted (redact). Return all of this information in a JSON object like the sample below.
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- Page sizing default should always be non-zero.
- Redact anything that could possibly contain PII, tokens or keys, or expose any sensitive information in the logs.
- You must use the variable names in parentheses when building the return object. Each item in the response must contain the fields: name, redact and default.
- Page sizing default should always be non-zero.
- Most things should be configurable, unless otherwise stated in these guidelines.
- OAuth2, basic, and digest auth details are always configurable.
- Always set a default to use the most broad settings for parameters that filter down event types in the responses.
- Most tokens should not be configurable, unless they are API tokens.
- A variable cannot need redaction if it is not user configurable.
- Paging information, cursor information, usernames and client ids should never be redacted.
- Redact anything that could possibly contain PII, API tokens or keys, or expose any sensitive information in the logs.
- The types should be consistent with the Elastic integration configuration types. For example, 'text' for strings, 'integer' for whole numbers, and 'password' for API keys.
- You must use the variable names in parentheses when building the return object. Each item in the response must contain the fields: name, configurable, description, type, redact and default.
- Do not respond with anything except the JSON object enclosed with 3 backticks (\`), see example response below.
</guidelines>
Example response format:
@ -154,3 +181,139 @@ Example response format:
],
['ai', `Please find the JSON object below:`],
]);
export const CEL_AUTH_HEADERS_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
`You are a helpful, expert assistant in building Elastic filebeat input configurations utilizing the Common Expression Language (CEL) input type.
Here is some context for you to reference your task, review it carefully as you will get questions about it later:
<context>
<open_api_auth_schema>
{open_api_auth_schema}
</open_api_auth_schema>
</context>`,
],
[
'human',
`Please update the following CEL program for the OpenAPI Header authentication information specified in the context.
<context>
<cel_program>
{cel_program}
</cel_program>
</context>
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- Do not update any other details of the program besides authentication on the GET request headers.
- You must use the state variable name \`api_key\` for representing the authentication key value.
- You must respond only with the code block containing the program formatted like human-readable C code. See example response below.
- You must use 2 spaces for tab size.
- The final program must not be enclosed in parentheses.
- Do not enclose the final output in backticks, only return the codeblock and nothing else.
</guidelines>`,
],
['ai', `Please find the updated program below:`],
]);
export const CEL_AUTH_OAUTH2_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
`You are a helpful, expert assistant in building Elastic filebeat input configurations utilizing the Common Expression Language (CEL) input type.
Here is some context for you to reference your task, review it carefully as you will get questions about it later:
<context>
<open_api_auth_schema>
{open_api_auth_schema}
</open_api_auth_schema>
<example_cel_programs>
{example_cel_programs}
</example_cel_programs>
</context>`,
],
[
'human',
`Please update the following CEL program for the OpenAPI OAuth2 authentication information specified in the context.
<context>
<cel_program>
{cel_program}
</cel_program>
</context>
Each of the following criteria must be addressed in final configuration output:
- There must be configuration for submitting a request for the oauth token.
- The received token must be utilized in subsequent GET requests for the events.
- There must be configuration for error handling for the oauth token request.
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- You must use the state variable name \`oauth_id\` for representing the OAUth2 client id.
- You must use the state variable name \`oauth_secret\` for representing the OAUth2 client secret.
- You must respond only with the code block containing the program formatted like human-readable C code. See example response below.
- You must use 2 spaces for tab size.
- The final program must not be enclosed in parentheses.
- Do not enclose the final output in backticks, only return the codeblock and nothing else.
</guidelines>`,
],
['ai', `Please find the updated program below:`],
]);
export const CEL_AUTH_BASIC_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
`You are a helpful, expert assistant in building Elastic filebeat input configurations utilizing the Common Expression Language (CEL) input type.
Here is some context for you to reference your task, review it carefully as you will get questions about it later:
<context>
<open_api_auth_schema>
{open_api_auth_schema}
</open_api_auth_schema>
</context>`,
],
[
'human',
`Please update the following CEL program for the OpenAPI Basic authentication information specified in the context.
<context>
<cel_program>
{cel_program}
</cel_program>
</context>
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- You must use the state variable name \`username\` for representing the auth username.
- You must use the state variable name \`password\` for representing the auth password.
- Do not update any other details of the program besides authentication on the GET request headers.
- You must respond only with the code block containing the program formatted like human-readable C code. See example response below.
- You must use 2 spaces for tab size.
- The final program must not be enclosed in parentheses.
- Do not enclose the final output in backticks, only return the codeblock and nothing else.
</guidelines>`,
],
['ai', `Please find the updated program below:`],
]);
export const CEL_AUTH_DIGEST_PROMPT = ChatPromptTemplate.fromMessages([
[
'system',
`You are a helpful, expert assistant in writing and analyzing CEL programs for Elastic filebeat. Here is some context for you to reference for your task, read it carefully as you will get questions about it later:
<context>
<cel_program>
{cel_program}
</cel_program>
</context>`,
],
[
'human',
`Please update the program above to ensure no HTTP headers are being set and respond with the updated program.
You ALWAYS follow these guidelines when writing your response:
<guidelines>
- You must respond only with the code block containing the program formatted like human-readable C code. See example response below.
- You must use 2 spaces for tab size.
- The final program must not be enclosed in parentheses.
- Do not enclose the final output in backticks, only return the codeblock and nothing else.
</guidelines>`,
],
['ai', `Please find the updated program below:`],
]);

View file

@ -29,6 +29,7 @@ describe('Testing cel handler', () => {
const response = await handleGetStateDetails({ state, model });
expect(response.stateSettings).toStrictEqual(celExpectedResults.stateSettings);
expect(response.redactVars).toStrictEqual(celExpectedResults.redactVars);
expect(response.configFields).toStrictEqual(celExpectedResults.configFields);
expect(response.lastExecutedChain).toBe('getStateDetails');
});
});

View file

@ -10,7 +10,11 @@ import { CelInputState } from '../../types';
import { EX_ANSWER_CONFIG } from './constants';
import { CEL_CONFIG_DETAILS_PROMPT } from './prompts';
import { CelInputNodeParams, CelInputStateDetails } from './types';
import { getRedactVariables, getStateVarsAndDefaultValues } from './util';
import {
getRedactVariables,
getStateVarsAndDefaultValues,
getStateVarsConfigDetails,
} from './util';
export async function handleGetStateDetails({
state,
@ -21,15 +25,17 @@ export async function handleGetStateDetails({
const stateDetails = (await celConfigGraph.invoke({
state_variables: state.stateVarNames,
open_api_spec: state.apiDefinition,
open_api_path_details: state.openApiPathDetails,
ex_answer: EX_ANSWER_CONFIG,
})) as CelInputStateDetails[];
const stateSettings = getStateVarsAndDefaultValues(stateDetails);
const configFields = getStateVarsConfigDetails(stateDetails);
const redactVars = getRedactVariables(stateDetails);
return {
stateSettings,
configFields,
redactVars,
lastExecutedChain: 'getStateDetails',
};

View file

@ -19,7 +19,8 @@ export async function handleSummarizeQuery({
const apiQuerySummary = await celSummarizeGraph.invoke({
data_stream_name: state.dataStreamName,
open_api_spec: state.apiDefinition,
path: state.path,
path_details: state.openApiPathDetails,
});
return {

View file

@ -20,7 +20,10 @@ export interface CelInputGraphParams {
}
export interface CelInputStateDetails {
name: string;
configurable: boolean;
default: string | number | boolean;
description: string;
name: string;
redact: boolean;
type: string;
}

View file

@ -5,11 +5,16 @@
* 2.0.
*/
import { getRedactVariables, getStateVarsAndDefaultValues } from './util';
import {
getRedactVariables,
getStateVarsAndDefaultValues,
getStateVarsConfigDetails,
} from './util';
import {
celStateDetailsMockedResponse,
celStateSettings,
celRedact,
celConfigFields,
} from '../../../__jest__/fixtures/cel';
describe('getCelInputDetails', () => {
@ -26,4 +31,9 @@ describe('getCelInputDetails', () => {
const result = getStateVarsAndDefaultValues(celStateDetailsMockedResponse);
expect(result).toStrictEqual(celStateSettings);
});
it('getStateVarsConfigDetails', () => {
const result = getStateVarsConfigDetails(celStateDetailsMockedResponse);
expect(result).toStrictEqual(celConfigFields);
});
});

View file

@ -30,3 +30,20 @@ export function getStateVarsAndDefaultValues(stateDetails: CelInputStateDetails[
}
return defaultStateVarSettings;
}
/**
* Gets an object containing state variables configuration information.
*/
export function getStateVarsConfigDetails(stateDetails: CelInputStateDetails[]): object {
const defaultStateVarConfigSettings: Record<string, unknown> = {};
for (const stateVar of stateDetails) {
if (stateVar.configurable) {
defaultStateVarConfigSettings[stateVar.name] = {
description: stateVar.description,
type: stateVar.type,
default: stateVar.default,
};
}
}
return defaultStateVarConfigSettings;
}

View file

@ -7,7 +7,8 @@
import { ensureDirSync, createSync } from '../util';
import { createAgentInput } from './agent';
import { InputType } from '../../common';
import { CelInput, InputType } from '../../common';
import { render } from 'nunjucks';
jest.mock('../util', () => ({
...jest.requireActual('../util'),
@ -15,6 +16,8 @@ jest.mock('../util', () => ({
ensureDirSync: jest.fn(),
}));
jest.mock('nunjucks');
describe('createAgentInput', () => {
const dataStreamPath = 'path';
@ -23,9 +26,9 @@ describe('createAgentInput', () => {
});
it('Should create expected files', async () => {
const inputTypes: InputType[] = ['aws-s3', 'filestream'];
const inputTypes: InputType[] = ['aws-s3', 'filestream', 'cel'];
createAgentInput(dataStreamPath, inputTypes);
createAgentInput(dataStreamPath, inputTypes, undefined);
expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`);
@ -39,10 +42,39 @@ describe('createAgentInput', () => {
);
});
it('Should create expected files for cel without generated cel results', async () => {
const inputTypes: InputType[] = ['cel'];
createAgentInput(dataStreamPath, inputTypes, undefined);
expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`);
expect(createSync).toHaveBeenCalledWith(
`${dataStreamPath}/agent/stream/cel.yml.hbs`,
expect.any(String)
);
});
it('Should not create agent files if there are no input types', async () => {
createAgentInput(dataStreamPath, []);
createAgentInput(dataStreamPath, [], undefined);
expect(ensureDirSync).toHaveBeenCalledWith(`${dataStreamPath}/agent/stream`);
expect(createSync).not.toHaveBeenCalled();
});
it('Should create generated cel agent file if provided', async () => {
const inputTypes: InputType[] = ['cel'];
const celInput = {
authType: 'basic',
configFields: {},
needsAuthConfigBlock: false,
program: 'program',
redactVars: [],
stateSettings: {},
url: 'url',
} as CelInput;
createAgentInput(dataStreamPath, inputTypes, celInput);
expect(render).toHaveBeenCalledWith(`cel_generated.yml.hbs.njk`, expect.anything());
});
});

View file

@ -5,11 +5,16 @@
* 2.0.
*/
import nunjucks from 'nunjucks';
import { join as joinPath } from 'path';
import type { InputType } from '../../common';
import type { CelInput, InputType } from '../../common';
import { createSync, ensureDirSync, readSync } from '../util';
export function createAgentInput(specificDataStreamDir: string, inputTypes: InputType[]): void {
export function createAgentInput(
specificDataStreamDir: string,
inputTypes: InputType[],
celInput: CelInput | undefined
): void {
const agentDir = joinPath(specificDataStreamDir, 'agent', 'stream');
const agentTemplatesDir = joinPath(__dirname, '../templates/agent');
ensureDirSync(agentDir);
@ -19,15 +24,26 @@ export function createAgentInput(specificDataStreamDir: string, inputTypes: Inpu
const commonFile = readSync(commonFilePath);
for (const inputType of inputTypes) {
const inputTypeFilePath = joinPath(
agentTemplatesDir,
`${inputType.replaceAll('-', '_')}.yml.hbs`
);
const inputTypeFile = readSync(inputTypeFilePath);
const combinedContents = `${inputTypeFile}\n${commonFile}`;
const destinationFilePath = joinPath(agentDir, `${inputType}.yml.hbs`);
createSync(destinationFilePath, combinedContents);
if (inputType === 'cel' && celInput != null) {
const mappedValues = {
auth: celInput.authType,
needsAuthBlock: celInput.needsAuthConfigBlock,
configFields: Object.keys(celInput.configFields),
} as object;
const generatedCelTemplate = nunjucks.render('cel_generated.yml.hbs.njk', mappedValues);
createSync(destinationFilePath, generatedCelTemplate);
} else {
const inputTypeFilePath = joinPath(
agentTemplatesDir,
`${inputType.replaceAll('-', '_')}.yml.hbs`
);
const inputTypeFile = readSync(inputTypeFilePath);
const combinedContents = `${inputTypeFile}\n${commonFile}`;
createSync(destinationFilePath, combinedContents);
}
}
}

View file

@ -151,8 +151,16 @@ describe('buildPackage', () => {
});
it('Should call createAgentInput for each datastream', async () => {
expect(createAgentInput).toHaveBeenCalledWith(firstDatastreamPath, firstDataStreamInputTypes);
expect(createAgentInput).toHaveBeenCalledWith(secondDatastreamPath, secondDataStreamInputTypes);
expect(createAgentInput).toHaveBeenCalledWith(
firstDatastreamPath,
firstDataStreamInputTypes,
undefined
);
expect(createAgentInput).toHaveBeenCalledWith(
secondDatastreamPath,
secondDataStreamInputTypes,
undefined
);
});
it('Should call createPipeline for each datastream', async () => {

View file

@ -56,7 +56,7 @@ export async function buildPackage(integration: Integration): Promise<Buffer> {
const specificDataStreamDir = joinPath(dataStreamsDir, dataStreamName);
const dataStreamFields = createDataStream(integration.name, specificDataStreamDir, dataStream);
createAgentInput(specificDataStreamDir, dataStream.inputTypes);
createAgentInput(specificDataStreamDir, dataStream.inputTypes, dataStream.celInput);
createPipeline(specificDataStreamDir, dataStream.pipeline);
const fields = createFieldMapping(
integration.name,

View file

@ -5,6 +5,15 @@
* 2.0.
*/
export const CEL_EXISTING_AUTH_CONFIG_FIELDS = [
'oauth_id',
'oauth_secret',
'username',
'password',
'digest_username',
'digest_password',
];
export const DEFAULT_CEL_PROGRAM = `# // Fetch the agent's public IP every minute and note when the last request was made.
# // It does not use the Resource URL configuration value.
# bytes(get("https://api.ipify.org/?format=json").Body).as(body, {
@ -16,3 +25,5 @@ export const DEFAULT_CEL_PROGRAM = `# // Fetch the agent's public IP every minut
# })],
# "cursor": {"last_requested_at": now}
# })`;
export const DEFAULT_URL = 'https://server.example.com:8089/api';

View file

@ -69,9 +69,13 @@ describe('createDataStream', () => {
pipeline: firstDataStreamPipeline,
samplesFormat: { name: 'ndjson', multiline: false },
celInput: {
url: 'https://sample.com',
program: 'line1\nline2',
authType: 'basic',
stateSettings: { setting1: 100, setting2: '' },
redactVars: ['setting2'],
configFields: { setting1: {}, setting2: {} },
needsAuthConfigBlock: false,
},
};
@ -117,19 +121,8 @@ describe('createDataStream', () => {
it('Should populate expected CEL fields', async () => {
createDataStream(packageName, dataStreamPath, celDataStream);
const expectedMappedValues = {
data_stream_title: celDataStream.title,
data_stream_description: celDataStream.description,
package_name: packageName,
data_stream_name: firstDatastreamName,
multiline_ndjson: celDataStream.samplesFormat.multiline,
program: celDataStream.celInput?.program.split('\n'),
state: celDataStream.celInput?.stateSettings,
redact: celDataStream.celInput?.redactVars,
};
// // Manifest files
expect(createSync).toHaveBeenCalledWith(`${dataStreamPath}/manifest.yml`, undefined);
expect(render).toHaveBeenCalledWith(`cel_manifest.yml.njk`, expectedMappedValues);
expect(render).toHaveBeenCalledWith(`cel_manifest.yml.njk`, expect.anything());
});
});

View file

@ -8,8 +8,8 @@
import nunjucks from 'nunjucks';
import { join as joinPath } from 'path';
import { load } from 'js-yaml';
import type { DataStream } from '../../common';
import { DEFAULT_CEL_PROGRAM } from './constants';
import type { CelInput, DataStream } from '../../common';
import { CEL_EXISTING_AUTH_CONFIG_FIELDS, DEFAULT_CEL_PROGRAM, DEFAULT_URL } from './constants';
import { copySync, createSync, ensureDirSync, listDirSync, readSync } from '../util';
import { Field } from '../util/samples';
@ -40,22 +40,7 @@ export function createDataStream(
} as object;
if (inputType === 'cel') {
if (dataStream.celInput != null) {
// Map the generated CEL config items into the template
const cel = dataStream.celInput;
mappedValues = {
...mappedValues,
// Ready the program for printing with correct indentation
program: cel.program.split('\n'),
state: cel.stateSettings,
redact: cel.redactVars,
};
} else {
mappedValues = {
...mappedValues,
program: DEFAULT_CEL_PROGRAM.split('\n'),
};
}
mappedValues = prepareCelValues(mappedValues, dataStream.celInput);
}
const dataStreamManifest = nunjucks.render(
@ -127,3 +112,37 @@ function createPipelineTests(
);
createSync(testFileName, rawSamples.join('\n'));
}
function prepareCelValues(mappedValues: object, celInput: CelInput | undefined) {
if (celInput != null) {
// Ready the program for printing with correct indentation
const programLines = celInput.program.split('\n');
// We don't want to double include the config fields in the state or any of the templated auth fields
const initialState = Object.entries(celInput.stateSettings).filter(
([key]) =>
!Object.keys(celInput.configFields).includes(key) &&
!CEL_EXISTING_AUTH_CONFIG_FIELDS.includes(key)
);
const configSettingsNeeded = Object.entries(celInput.configFields).filter(
([key]) => !CEL_EXISTING_AUTH_CONFIG_FIELDS.includes(key)
);
return {
...mappedValues,
program: programLines,
state: initialState,
configFields: configSettingsNeeded,
redact: celInput.redactVars,
auth: celInput.authType,
url: celInput.url,
showAll: false,
};
} else {
return {
...mappedValues,
program: DEFAULT_CEL_PROGRAM.split('\n'),
url: DEFAULT_URL,
showAll: true,
};
}
}

View file

@ -21,8 +21,6 @@ import type {
IntegrationAssistantPluginStartDependencies,
IntegrationAssistantPluginSetupDependencies,
} from './types';
import { parseExperimentalConfigValue } from '../common/experimental_features';
import { IntegrationAssistantConfigType } from './config';
export type IntegrationAssistantRouteHandlerContext = CustomRequestHandlerContext<{
integrationAssistant: {
@ -45,13 +43,11 @@ export class IntegrationAssistantPlugin
>
{
private readonly logger: Logger;
private readonly config: IntegrationAssistantConfigType;
private isAvailable: boolean;
private hasLicense: boolean;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
this.config = initializerContext.config.get();
this.isAvailable = true;
this.hasLicense = false;
}
@ -68,11 +64,10 @@ export class IntegrationAssistantPlugin
logger: this.logger,
}));
const router = core.http.createRouter<IntegrationAssistantRouteHandlerContext>();
const experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental ?? []);
this.logger.debug('integrationAssistant api: Setup');
registerRoutes(router, experimentalFeatures);
registerRoutes(router);
return {
setIsAvailable: (isAvailable: boolean) => {

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { serverMock } from '../__mocks__/mock_server';
import { requestMock } from '../__mocks__/request';
import { requestContextMock } from '../__mocks__/request_context';
import { ANALYZE_API_PATH } from '../../common';
import { registerApiAnalysisRoutes } from './analyze_api_route';
const mockResult = jest.fn().mockResolvedValue({ results: { suggestedPaths: ['', ''] } });
jest.mock('../graphs/api_analysis', () => {
return {
getApiAnalysisGraph: jest.fn().mockResolvedValue({
withConfig: () => ({
invoke: () => mockResult(),
}),
}),
};
});
describe('registerApiAnalysisRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let { context } = requestContextMock.createTools();
const req = requestMock.create({
method: 'post',
path: ANALYZE_API_PATH,
body: {
connectorId: 'testConnector',
dataStreamTitle: 'testStream',
pathOptions: { path1: 'testDescription', path2: 'testDescription' },
},
});
beforeEach(() => {
jest.clearAllMocks();
server = serverMock.create();
({ context } = requestContextMock.createTools());
registerApiAnalysisRoutes(server.router);
});
it('Runs route and gets ApiAnalysisResponse', async () => {
const response = await server.inject(req, requestContextMock.convertContext(context));
expect(response.body).toEqual({ results: { suggestedPaths: ['', ''] } });
expect(response.status).toEqual(200);
});
it('Runs route with badRequest', async () => {
mockResult.mockResolvedValueOnce({});
const response = await server.inject(req, requestContextMock.convertContext(context));
expect(response.status).toEqual(400);
});
describe('when the integration assistant is not available', () => {
beforeEach(() => {
context.integrationAssistant.isAvailable.mockReturnValue(false);
});
it('returns a 404', async () => {
const response = await server.inject(req, requestContextMock.convertContext(context));
expect(response.status).toEqual(404);
});
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 { IKibanaResponse, IRouter } from '@kbn/core/server';
import { getRequestAbortedSignal } from '@kbn/data-plugin/server';
import { APMTracer } from '@kbn/langchain/server/tracers/apm';
import { getLangSmithTracer } from '@kbn/langchain/server/tracers/langsmith';
import { ANALYZE_API_PATH, AnalyzeApiRequestBody, AnalyzeApiResponse } from '../../common';
import {
ACTIONS_AND_CONNECTORS_ALL_ROLE,
FLEET_ALL_ROLE,
INTEGRATIONS_ALL_ROLE,
ROUTE_HANDLER_TIMEOUT,
} from '../constants';
import { getApiAnalysisGraph } from '../graphs/api_analysis';
import type { IntegrationAssistantRouteHandlerContext } from '../plugin';
import { getLLMClass, getLLMType } from '../util/llm';
import { buildRouteValidationWithZod } from '../util/route_validation';
import { withAvailability } from './with_availability';
import { isErrorThatHandlesItsOwnResponse } from '../lib/errors';
export function registerApiAnalysisRoutes(
router: IRouter<IntegrationAssistantRouteHandlerContext>
) {
router.versioned
.post({
path: ANALYZE_API_PATH,
access: 'internal',
options: {
timeout: {
idleSocket: ROUTE_HANDLER_TIMEOUT,
},
},
})
.addVersion(
{
version: '1',
security: {
authz: {
requiredPrivileges: [
FLEET_ALL_ROLE,
INTEGRATIONS_ALL_ROLE,
ACTIONS_AND_CONNECTORS_ALL_ROLE,
],
},
},
validate: {
request: {
body: buildRouteValidationWithZod(AnalyzeApiRequestBody),
},
},
},
withAvailability(async (context, req, res): Promise<IKibanaResponse<AnalyzeApiResponse>> => {
const { dataStreamTitle, pathOptions, langSmithOptions } = req.body;
const { getStartServices, logger } = await context.integrationAssistant;
const [, { actions: actionsPlugin }] = await getStartServices();
try {
const actionsClient = await actionsPlugin.getActionsClientWithRequest(req);
const connector = await actionsClient.get({ id: req.body.connectorId });
const abortSignal = getRequestAbortedSignal(req.events.aborted$);
const actionTypeId = connector.actionTypeId;
const llmType = getLLMType(actionTypeId);
const llmClass = getLLMClass(llmType);
const model = new llmClass({
actionsClient,
connectorId: connector.id,
logger,
llmType,
model: connector.config?.defaultModel,
temperature: 0.05,
maxTokens: 4096,
signal: abortSignal,
streaming: false,
});
const parameters = {
dataStreamName: dataStreamTitle,
pathOptions,
};
const options = {
callbacks: [
new APMTracer({ projectName: langSmithOptions?.projectName ?? 'default' }, logger),
...getLangSmithTracer({ ...langSmithOptions, logger }),
],
};
const graph = await getApiAnalysisGraph({ model });
const results = await graph
.withConfig({ runName: 'API analysis' })
.invoke(parameters, options);
return res.ok({ body: AnalyzeApiResponse.parse(results) });
} catch (e) {
if (isErrorThatHandlesItsOwnResponse(e)) {
return e.sendResponse(res);
}
return res.badRequest({ body: e });
}
})
);
}

View file

@ -15,6 +15,8 @@ const mockResult = jest.fn().mockResolvedValue({
results: {
program: '',
stateSettings: {},
configFields: {},
needsAuthConfigBlock: false,
redactVars: [],
},
});
@ -38,8 +40,16 @@ describe('registerCelInputRoute', () => {
path: CEL_INPUT_GRAPH_PATH,
body: {
connectorId: 'testConnector',
dataStreamName: 'testStream',
apiDefinition: 'testApiDefinitionFileContents',
dataStreamTitle: 'testStream',
celDetails: {
path: 'testPath',
auth: 'basic',
openApiDetails: {
operation: '{}',
schemas: '{}',
auth: '{}',
},
},
},
});
@ -53,7 +63,13 @@ describe('registerCelInputRoute', () => {
it('Runs route and gets CelInputResponse', async () => {
const response = await server.inject(req, requestContextMock.convertContext(context));
expect(response.body).toEqual({
results: { program: '', stateSettings: {}, redactVars: [] },
results: {
program: '',
stateSettings: {},
configFields: {},
needsAuthConfigBlock: false,
redactVars: [],
},
});
expect(response.status).toEqual(200);
});

Some files were not shown because too many files have changed in this diff Show more