[Inventory] Inventory plugin (#191798)

## Description

This PR adds an inventory plugin, which renders an inventory UI.
Currently only data streams are rendered. This is part of the LogsAI
initiative - basically we need a UI for tasks like structuring data,
extracting entities, listing the results etc. This is mostly POC-level
stuff. Eventually some of this code might be handed over to ECO but
let's cross that bridge when we get to it.

## Notes for reviewers:

@elastic/appex-ai-infra @elastic/security-generative-ai: added a
`truncateList` utility function that takes the first n elements of an
array and appends a `{l-n} more` string value if there are more values
than n. Really simple but I expect will also be very often used because
we cannot send a huge amount of items to the LLM.

@elastic/kibana-core @elastic/kibana-operations: just boiler plate stuff
for adding a new plugin (and thank you for enabling us to run
`quick_checks` locally!

@elastic/obs-knowledge-team: added support for streaming using an
Observable.

@elastic/obs-ux-management-team: added links to the Inventory UI in the
Observability plugin

@elastic/obs-entities: I've added an entity manager client to be able to
fetch entity definitions on the server. Maybe there's a better way? LMK.

@elastic/obs-ux-logs-team: added a deeplink to the Inventory UI. I've
also moved CODEOWNERS for this package to
@elastic/obs-ux-management-team as they own the Observability plugin
where this is mostly used.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2024-09-12 15:07:09 +02:00 committed by GitHub
parent 4d5c25305b
commit 98aa1ab769
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 3075 additions and 882 deletions

6
.github/CODEOWNERS vendored
View file

@ -362,7 +362,7 @@ packages/deeplinks/devtools @elastic/kibana-management
packages/deeplinks/fleet @elastic/fleet
packages/deeplinks/management @elastic/kibana-management
packages/deeplinks/ml @elastic/ml-ui
packages/deeplinks/observability @elastic/obs-ux-logs-team
packages/deeplinks/observability @elastic/obs-ux-management-team
packages/deeplinks/search @elastic/search-kibana
packages/deeplinks/security @elastic/security-solution
packages/deeplinks/shared @elastic/appex-sharedux
@ -522,6 +522,7 @@ x-pack/plugins/integration_assistant @elastic/security-scalability
src/plugins/interactive_setup @elastic/kibana-security
test/interactive_setup_api_integration/plugins/test_endpoints @elastic/kibana-security
packages/kbn-interpreter @elastic/kibana-visualizations
x-pack/plugins/observability_solution/inventory @elastic/obs-ux-infra_services-team
x-pack/plugins/observability_solution/investigate_app @elastic/obs-ux-management-team
x-pack/plugins/observability_solution/investigate @elastic/obs-ux-management-team
packages/kbn-investigation-shared @elastic/obs-ux-management-team
@ -876,6 +877,9 @@ packages/kbn-sort-predicates @elastic/kibana-visualizations
x-pack/plugins/spaces @elastic/kibana-security
x-pack/test/spaces_api_integration/common/plugins/spaces_test_plugin @elastic/kibana-security
packages/kbn-spec-to-console @elastic/kibana-management
packages/kbn-sse-utils @elastic/obs-knowledge-team
packages/kbn-sse-utils-client @elastic/obs-knowledge-team
packages/kbn-sse-utils-server @elastic/obs-knowledge-team
x-pack/plugins/stack_alerts @elastic/response-ops
x-pack/plugins/stack_connectors @elastic/response-ops
x-pack/test/usage_collection/plugins/stack_management_usage_test @elastic/kibana-management

View file

@ -116,6 +116,7 @@
"searchTypes": "packages/kbn-search-types",
"securitySolutionPackages": "x-pack/packages/security-solution",
"serverlessPackages": "packages/serverless",
"sse": [ "packages/kbn-sse-utils" ],
"coloring": "packages/kbn-coloring/src",
"languageDocumentationPopover": "packages/kbn-language-documentation-popover/src",
"esql": "src/plugins/esql",

View file

@ -648,6 +648,10 @@ the infrastructure monitoring use-case within Kibana.
|Team owner: Security Integrations Scalability
|{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/inventory/README.md[inventory]
|Home of the Inventory plugin, which renders the... inventory.
|{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/investigate/README.md[investigate]
|undefined

View file

@ -566,6 +566,7 @@
"@kbn/interactive-setup-plugin": "link:src/plugins/interactive_setup",
"@kbn/interactive-setup-test-endpoints-plugin": "link:test/interactive_setup_api_integration/plugins/test_endpoints",
"@kbn/interpreter": "link:packages/kbn-interpreter",
"@kbn/inventory-plugin": "link:x-pack/plugins/observability_solution/inventory",
"@kbn/investigate-app-plugin": "link:x-pack/plugins/observability_solution/investigate_app",
"@kbn/investigate-plugin": "link:x-pack/plugins/observability_solution/investigate",
"@kbn/investigation-shared": "link:packages/kbn-investigation-shared",
@ -888,6 +889,9 @@
"@kbn/sort-predicates": "link:packages/kbn-sort-predicates",
"@kbn/spaces-plugin": "link:x-pack/plugins/spaces",
"@kbn/spaces-test-plugin": "link:x-pack/test/spaces_api_integration/common/plugins/spaces_test_plugin",
"@kbn/sse-utils": "link:packages/kbn-sse-utils",
"@kbn/sse-utils-client": "link:packages/kbn-sse-utils-client",
"@kbn/sse-utils-server": "link:packages/kbn-sse-utils-server",
"@kbn/stack-alerts-plugin": "link:x-pack/plugins/stack_alerts",
"@kbn/stack-connectors-plugin": "link:x-pack/plugins/stack_connectors",
"@kbn/stack-management-usage-test-plugin": "link:x-pack/test/usage_collection/plugins/stack_management_usage_test",

View file

@ -30,3 +30,5 @@ export const INVESTIGATE_APP_ID = 'investigate';
export const OBLT_UX_APP_ID = 'ux';
export const OBLT_PROFILING_APP_ID = 'profiling';
export const INVENTORY_APP_ID = 'inventory';

View file

@ -19,6 +19,7 @@ import {
AI_ASSISTANT_APP_ID,
OBLT_UX_APP_ID,
OBLT_PROFILING_APP_ID,
INVENTORY_APP_ID,
} from './constants';
type LogsApp = typeof LOGS_APP_ID;
@ -32,6 +33,7 @@ type SloApp = typeof SLO_APP_ID;
type AiAssistantApp = typeof AI_ASSISTANT_APP_ID;
type ObltUxApp = typeof OBLT_UX_APP_ID;
type ObltProfilingApp = typeof OBLT_PROFILING_APP_ID;
type InventoryApp = typeof INVENTORY_APP_ID;
export type AppId =
| LogsApp
@ -44,10 +46,13 @@ export type AppId =
| SloApp
| AiAssistantApp
| ObltUxApp
| ObltProfilingApp;
| ObltProfilingApp
| InventoryApp;
export type LogsLinkId = 'log-categories' | 'settings' | 'anomalies' | 'stream';
export type InventoryLinkId = 'datastreams';
export type ObservabilityOverviewLinkId =
| 'alerts'
| 'cases'
@ -90,4 +95,5 @@ export type DeepLinkId =
| `${MetricsApp}:${MetricsLinkId}`
| `${ApmApp}:${ApmLinkId}`
| `${SyntheticsApp}:${SyntheticsLinkId}`
| `${ObltProfilingApp}:${ProfilingLinkId}`;
| `${ObltProfilingApp}:${ProfilingLinkId}`
| `${InventoryApp}:${InventoryLinkId}`;

View file

@ -1,5 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/deeplinks-observability",
"owner": "@elastic/obs-ux-logs-team"
"owner": "@elastic/obs-ux-management-team"
}

View file

@ -89,7 +89,7 @@ export type SearchHit<
? {
fields: Partial<Record<ValueTypeOfField<TFields>, unknown[]>>;
}
: {}) &
: { fields?: Record<string, unknown[]> }) &
(TDocValueFields extends DocValueFields
? {
fields: Partial<Record<ValueTypeOfField<TDocValueFields>, unknown[]>>;

View file

@ -86,6 +86,7 @@ pageLoadAssetSize:
inspector: 148711
integrationAssistant: 19524
interactiveSetup: 80000
inventory: 27430
investigate: 17970
investigateApp: 91898
kibanaOverview: 56279

View file

@ -9,6 +9,7 @@
export { createRepositoryClient } from './src/create_repository_client';
export { isHttpFetchError } from './src/is_http_fetch_error';
export { isRequestAbortedError } from './src/is_request_aborted_error';
export type {
DefaultClientOptions,

View file

@ -0,0 +1,76 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createParser } from 'eventsource-parser';
import { Observable, throwError } from 'rxjs';
export interface StreamedHttpResponse {
response?: { body: ReadableStream<Uint8Array> | null | undefined };
}
class NoReadableStreamError extends Error {
constructor() {
super(`No readable stream found in response`);
}
}
export function isNoReadableStreamError(error: any): error is NoReadableStreamError {
return error instanceof NoReadableStreamError;
}
export function createObservableFromHttpResponse(
response: StreamedHttpResponse
): Observable<string> {
const rawResponse = response.response;
const body = rawResponse?.body;
if (!body) {
return throwError(() => {
throw new NoReadableStreamError();
});
}
return new Observable<string>((subscriber) => {
const parser = createParser((event) => {
if (event.type === 'event') {
subscriber.next(event.data);
}
});
const readStream = async () => {
const reader = body.getReader();
const decoder = new TextDecoder();
// Function to process each chunk
const processChunk = ({
done,
value,
}: ReadableStreamReadResult<Uint8Array>): Promise<void> => {
if (done) {
return Promise.resolve();
}
parser.feed(decoder.decode(value, { stream: true }));
return reader.read().then(processChunk);
};
// Start reading the stream
return reader.read().then(processChunk);
};
readStream()
.then(() => {
subscriber.complete();
})
.catch((error) => {
subscriber.error(error);
});
});
}

View file

@ -11,28 +11,52 @@ import type { CoreSetup, CoreStart } from '@kbn/core-lifecycle-browser';
import {
RouteRepositoryClient,
ServerRouteRepository,
DefaultClientOptions,
formatRequest,
} from '@kbn/server-route-repository-utils';
import { httpResponseIntoObservable } from '@kbn/sse-utils-client';
import { from } from 'rxjs';
import { HttpFetchOptions, HttpFetchQuery, HttpResponse } from '@kbn/core-http-browser';
import { omit } from 'lodash';
export function createRepositoryClient<
TRepository extends ServerRouteRepository,
TClientOptions extends Record<string, any> = DefaultClientOptions
>(core: CoreStart | CoreSetup) {
TClientOptions extends HttpFetchOptions = {}
>(core: CoreStart | CoreSetup): RouteRepositoryClient<TRepository, TClientOptions> {
const fetch = (
endpoint: string,
params: { path?: Record<string, string>; body?: unknown; query?: HttpFetchQuery } | undefined,
options: TClientOptions
) => {
const { method, pathname, version } = formatRequest(endpoint, params?.path);
return core.http[method](pathname, {
...options,
body: params && params.body ? JSON.stringify(params.body) : undefined,
query: params?.query,
version,
});
};
return {
fetch: (endpoint, optionsWithParams) => {
const { params, ...options } = (optionsWithParams ?? { params: {} }) as unknown as {
params?: Partial<Record<string, any>>;
};
fetch: (endpoint, ...args) => {
const allOptions = args[0] ?? {};
const params = 'params' in allOptions ? (allOptions.params as Record<string, any>) : {};
const otherOptions = omit(allOptions, 'params') as TClientOptions;
const { method, pathname, version } = formatRequest(endpoint, params?.path);
return core.http[method](pathname, {
...options,
body: params && params.body ? JSON.stringify(params.body) : undefined,
query: params?.query,
version,
});
return fetch(endpoint, params, otherOptions) as any;
},
} as { fetch: RouteRepositoryClient<TRepository, TClientOptions> };
stream: (endpoint, ...args) => {
const allOptions = args[0] ?? {};
const params = 'params' in allOptions ? (allOptions.params as Record<string, any>) : {};
const otherOptions = omit(allOptions, 'params') as TClientOptions;
return from(
fetch(endpoint, params, {
...otherOptions,
asResponse: true,
rawResponse: true,
}) as Promise<HttpResponse>
).pipe(httpResponseIntoObservable()) as any;
},
};
}

View file

@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { get } from 'lodash';
export function isRequestAbortedError(error: unknown): error is Error {
return get(error, 'name') === 'AbortError';
}

View file

@ -17,5 +17,6 @@
"@kbn/server-route-repository-utils",
"@kbn/core-lifecycle-browser",
"@kbn/core-http-browser",
"@kbn/sse-utils-client",
]
}

View file

@ -17,43 +17,14 @@ import type {
RouteConfigOptions,
RouteMethod,
} from '@kbn/core/server';
import type { ServerSentEvent } from '@kbn/sse-utils';
import { z } from '@kbn/zod';
import * as t from 'io-ts';
import { RequiredKeys } from 'utility-types';
import { Observable } from 'rxjs';
import { Readable } from 'stream';
import { RequiredKeys, ValuesType } from 'utility-types';
type PathMaybeOptional<T extends { path: Record<string, any> }> = RequiredKeys<
T['path']
> extends never
? { path?: T['path'] }
: { path: T['path'] };
type QueryMaybeOptional<T extends { query: Record<string, any> }> = RequiredKeys<
T['query']
> extends never
? { query?: T['query'] }
: { query: T['query'] };
type BodyMaybeOptional<T extends { body: Record<string, any> }> = RequiredKeys<
T['body']
> extends never
? { body?: T['body'] }
: { body: T['body'] };
type ParamsMaybeOptional<
TPath extends Record<string, any>,
TQuery extends Record<string, any>,
TBody extends Record<string, any>
> = PathMaybeOptional<{ path: TPath }> &
QueryMaybeOptional<{ query: TQuery }> &
BodyMaybeOptional<{ body: TBody }>;
type ZodMaybeOptional<T extends { path: any; query: any; body: any }> = ParamsMaybeOptional<
T['path'],
T['query'],
T['body']
>;
type MaybeOptional<T extends { params: Record<string, any> }> = RequiredKeys<
type MaybeOptional<T extends { params?: Record<string, any> }> = RequiredKeys<
T['params']
> extends never
? { params?: T['params'] }
@ -64,19 +35,19 @@ type WithoutIncompatibleMethods<T extends t.Any> = Omit<T, 'encode' | 'asEncoder
asEncoder: () => t.Encoder<any, any>;
};
export type ZodParamsObject = z.ZodObject<{
export interface RouteParams {
path?: any;
query?: any;
body?: any;
}
export type ZodParamsObject = z.ZodObject<{
path?: z.ZodSchema;
query?: z.ZodSchema;
body?: z.ZodSchema;
}>;
export type IoTsParamsObject = WithoutIncompatibleMethods<
t.Type<{
path?: any;
query?: any;
body?: any;
}>
>;
export type IoTsParamsObject = WithoutIncompatibleMethods<t.Type<RouteParams>>;
export type RouteParamsRT = IoTsParamsObject | ZodParamsObject;
@ -97,22 +68,98 @@ type ValidateEndpoint<TEndpoint extends string> = string extends TEndpoint
: false
: false;
type IsAny<T> = 1 | 0 extends (T extends never ? 1 : 0) ? true : false;
// this ensures only plain objects can be returned, if it's not one
// of the other allowed types. here's how it works:
// - if it's a function, it's invalid
// - if it's a primitive, it's valid
// - if it's an array, it's valid
// - if it's a record, walk it once and apply above principles
// we don't recursively walk because of circular references in object types
// we also don't check arrays, as the goal is to not be able to return
// things like classes and functions at the top level. specifically,
// this code is intended to allow for Observable<ServerSentEvent> but
// to disallow Observable<NotAServerSentEvent>.
type ValidateSerializableValue<T, TWalkRecursively extends boolean = true> = IsAny<T> extends true
? 1
: T extends Function
? 0
: T extends Record<string, any>
? TWalkRecursively extends true
? ValuesType<{
[key in keyof T]: ValidateSerializableValue<T[key], false>;
}>
: 1
: T extends string | number | boolean | null | undefined
? 1
: T extends any[]
? 1
: 0;
type GuardAgainstInvalidRecord<T> = 0 extends ValidateSerializableValue<T> ? never : T;
type ServerRouteHandlerReturnTypeWithoutRecord =
| Observable<ServerSentEvent>
| Readable
| IKibanaResponse
| string
| number
| boolean
| null
| void;
type ServerRouteHandlerReturnType = ServerRouteHandlerReturnTypeWithoutRecord | Record<string, any>;
type ServerRouteHandler<
TRouteHandlerResources extends ServerRouteHandlerResources,
TRouteParamsRT extends RouteParamsRT | undefined,
TReturnType extends ServerRouteHandlerReturnType
> = (
options: TRouteHandlerResources &
(TRouteParamsRT extends RouteParamsRT ? DecodedRequestParamsOfType<TRouteParamsRT> : {})
) => Promise<
TReturnType extends ServerRouteHandlerReturnTypeWithoutRecord
? TReturnType
: GuardAgainstInvalidRecord<TReturnType>
>;
export type CreateServerRouteFactory<
TRouteHandlerResources extends ServerRouteHandlerResources,
TRouteCreateOptions extends ServerRouteCreateOptions
> = <
TEndpoint extends string,
TReturnType extends ServerRouteHandlerReturnType,
TRouteParamsRT extends RouteParamsRT | undefined = undefined
>(
options: {
endpoint: ValidateEndpoint<TEndpoint> extends true ? TEndpoint : never;
handler: ServerRouteHandler<TRouteHandlerResources, TRouteParamsRT, TReturnType>;
params?: TRouteParamsRT;
} & TRouteCreateOptions
) => Record<
TEndpoint,
ServerRoute<
TEndpoint,
TRouteParamsRT,
TRouteHandlerResources,
Awaited<TReturnType>,
TRouteCreateOptions
>
>;
export type ServerRoute<
TEndpoint extends string,
TRouteParamsRT extends RouteParamsRT | undefined,
TRouteHandlerResources extends ServerRouteHandlerResources,
TReturnType,
TReturnType extends ServerRouteHandlerReturnType,
TRouteCreateOptions extends ServerRouteCreateOptions
> = ValidateEndpoint<TEndpoint> extends true
? {
endpoint: TEndpoint;
params?: TRouteParamsRT;
handler: ({}: TRouteHandlerResources &
(TRouteParamsRT extends RouteParamsRT
? DecodedRequestParamsOfType<TRouteParamsRT>
: {})) => Promise<TReturnType>;
} & TRouteCreateOptions
: never;
> = {
endpoint: TEndpoint;
handler: ServerRouteHandler<TRouteHandlerResources, TRouteParamsRT, TReturnType>;
} & TRouteCreateOptions &
(TRouteParamsRT extends RouteParamsRT ? { params: TRouteParamsRT } : {});
export type ServerRouteRepository = Record<
string,
@ -124,22 +171,22 @@ type ClientRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
? MaybeOptional<{
params: t.OutputOf<TRouteParamsRT>;
}>
: TRouteParamsRT extends z.Schema
: TRouteParamsRT extends z.ZodSchema
? MaybeOptional<{
params: ZodMaybeOptional<z.input<TRouteParamsRT>>;
params: z.input<TRouteParamsRT>;
}>
: {};
: never;
type DecodedRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
TRouteParamsRT extends t.Mixed
? MaybeOptional<{
params: t.TypeOf<TRouteParamsRT>;
}>
: TRouteParamsRT extends z.Schema
: TRouteParamsRT extends z.ZodSchema
? MaybeOptional<{
params: ZodMaybeOptional<z.output<TRouteParamsRT>>;
params: z.output<TRouteParamsRT>;
}>
: {};
: never;
export type EndpointOf<TServerRouteRepository extends ServerRouteRepository> =
keyof TServerRouteRepository;
@ -186,24 +233,36 @@ export type ClientRequestParamsOf<
>
? TRouteParamsRT extends RouteParamsRT
? ClientRequestParamsOfType<TRouteParamsRT>
: {}
: TRouteParamsRT extends undefined
? {}
: never
: never;
type MaybeOptionalArgs<T extends Record<string, any>> = RequiredKeys<T> extends never
? [T] | []
: [T];
export type RouteRepositoryClient<
export interface RouteRepositoryClient<
TServerRouteRepository extends ServerRouteRepository,
TAdditionalClientOptions extends Record<string, any> = DefaultClientOptions
> = <TEndpoint extends Extract<keyof TServerRouteRepository, string>>(
endpoint: TEndpoint,
...args: MaybeOptionalArgs<
ClientRequestParamsOf<TServerRouteRepository, TEndpoint> & TAdditionalClientOptions
>
) => Promise<ReturnOf<TServerRouteRepository, TEndpoint>>;
export type DefaultClientOptions = HttpFetchOptions;
TAdditionalClientOptions extends Record<string, any>
> {
fetch<TEndpoint extends Extract<keyof TServerRouteRepository, string>>(
endpoint: TEndpoint,
...args: MaybeOptionalArgs<
ClientRequestParamsOf<TServerRouteRepository, TEndpoint> & TAdditionalClientOptions
>
): Promise<ReturnOf<TServerRouteRepository, TEndpoint>>;
stream<TEndpoint extends Extract<keyof TServerRouteRepository, string>>(
endpoint: TEndpoint,
...args: MaybeOptionalArgs<
ClientRequestParamsOf<TServerRouteRepository, TEndpoint> & TAdditionalClientOptions
>
): ReturnOf<TServerRouteRepository, TEndpoint> extends Observable<infer TReturnType>
? TReturnType extends ServerSentEvent
? Observable<TReturnType>
: never
: never;
}
interface CoreRouteHandlerResources {
request: KibanaRequest;
@ -211,6 +270,8 @@ interface CoreRouteHandlerResources {
context: RequestHandlerContext;
}
export type DefaultClientOptions = HttpFetchOptions;
export interface DefaultRouteHandlerResources extends CoreRouteHandlerResources {
logger: Logger;
}

View file

@ -16,9 +16,10 @@
"target/**/*"
],
"kbn_references": [
"@kbn/core-http-browser",
"@kbn/core-http-server",
"@kbn/core",
"@kbn/zod",
"@kbn/core-http-browser",
"@kbn/sse-utils",
]
}

View file

@ -5,6 +5,7 @@ Utility functions for creating a typed server route repository, and a typed clie
## Overview
There are three main functions that make up this package:
1. `createServerRouteFactory`
2. `registerRoutes`
3. `createRepositoryClient`
@ -22,6 +23,7 @@ By exporting the type of the repository from the server to the browser (make sur
In the server side, we'll start by creating the route factory, to make things easier it is recommended to keep this in its own file and export it.
> server/create_my_plugin_server_route.ts
```javascript
import { createServerRouteFactory } from '@kbn/server-route-repository';
import {
@ -40,17 +42,18 @@ The two generic arguments are optional, this example shows a "default" setup whi
Next, let's create a minimal route.
> server/my_route.ts
```javascript
import { createMyPluginServerRoute } from './create_my_plugin_server_route';
export const myRoute = createMyPluginServerRoute({
endpoint: 'GET /internal/my_plugin/route',
handler: async (resources) => {
const { request, context, response, logger } = resources;
return response.ok({
body: 'Hello, my route!',
});
},
endpoint: 'GET /internal/my_plugin/route',
handler: async (resources) => {
const { request, context, response, logger } = resources;
return response.ok({
body: 'Hello, my route!',
});
},
});
```
@ -87,11 +90,12 @@ We also export the type of the repository, we'll need this for the client which
The client can be created either in `setup` or `start`.
> browser/plugin.ts
```javascript
import { createRepositoryClient, isHttpFetchError, DefaultClientOptions } from '@kbn/server-route-repository-client';
import type { MyPluginRouteRepository } from '../server/plugin';
export type MyPluginRepositoryClient =
export type MyPluginRepositoryClient =
ReturnType<typeof createRepositoryClient<MyPluginRouteRepository, DefaultClientOptions>>;
class MyPlugin implements Plugin {
@ -116,10 +120,10 @@ class MyPlugin implements Plugin {
This example prints 'Hello, my route!' and the type of the response is **inferred** to this.
We pass in the type of the repository that we (_type_) imported from the server. The second generic parameter for `createRepositoryClient` is optional.
We also export the type of the client itself so we can use it to type the client as we pass it around.
We also export the type of the client itself so we can use it to type the client as we pass it around.
When using the client's `fetch` function, the first argument is the route to call and this is auto completed to only the available routes.
The second argument is optional in this case but allows you to send in any extra options.
The second argument is optional in this case but allows you to send in any extra options.
The client translates the endpoint and the options (including request parameters) to the right Core HTTP request.
@ -159,19 +163,20 @@ The `params` object is added to the route resources.
`path`, `query` and `body` are validated before your handler is called and the types are **inferred** inside of the handler.
When calling this endpoint, it will look like this:
```javascript
client('POST /internal/my_plugin/route/{my_path_param}', {
params: {
path: {
my_path_param: 'some_path_value',
},
query: {
my_query_param: 'some_query_value',
},
body: {
my_body_param: 'some_body_value',
},
params: {
path: {
my_path_param: 'some_path_value',
},
query: {
my_query_param: 'some_query_value',
},
body: {
my_body_param: 'some_body_value',
},
},
}).then(console.log);
```
@ -213,9 +218,9 @@ const myRoute = createMyPluginServerRoute({
const result = coinFlip();
if (result === 'heads') {
throw teapot();
throw teapot();
} else {
return 'Hello, my route!';
return 'Hello, my route!';
}
},
});
@ -235,7 +240,7 @@ export interface MyPluginRouteDependencies {
myDependency: MyDependency;
}
export const createMyPluginServerRoute =
export const createMyPluginServerRoute =
createServerRouteFactory<DefaultRouteHandlerResources & MyPluginRouteDependencies>();
```
@ -244,14 +249,16 @@ If you don't want your route to have access to the default resources, you could
Then we use the same type when calling `registerRoutes`
```javascript
registerRoutes<MyPluginRouteDependencies>({
registerRoutes <
MyPluginRouteDependencies >
{
core,
logger,
repository,
dependencies: {
myDependency: new MyDependency(),
myDependency: new MyDependency(),
},
});
};
```
This way, when creating a route, you will have `myDependency` available in the route resources.
@ -260,13 +267,13 @@ This way, when creating a route, you will have `myDependency` available in the r
import { createMyPluginServerRoute } from './create_my_plugin_server_route';
export const myRoute = createMyPluginServerRoute({
endpoint: 'GET /internal/my_plugin/route',
handler: async (resources) => {
const { request, context, response, logger, myDependency } = resources;
return response.ok({
body: myDependency.sayHello(),
});
},
endpoint: 'GET /internal/my_plugin/route',
handler: async (resources) => {
const { request, context, response, logger, myDependency } = resources;
return response.ok({
body: myDependency.sayHello(),
});
},
});
```
@ -295,21 +302,22 @@ export const createMyPluginServerRoute = createServerRouteFactory<
If you don't want your route to have access to the options provided by Core HTTP, you could pass in only `MyPluginRouteCreateOptions`.
You can then specify this option when creating the route.
```javascript
import { createMyPluginServerRoute } from './create_my_plugin_server_route';
export const myRoute = createMyPluginServerRoute({
options: {
access: 'internal',
},
isDangerous: true,
endpoint: 'GET /internal/my_plugin/route',
handler: async (resources) => {
const { request, context, response, logger } = resources;
return response.ok({
body: 'Hello, my route!',
});
},
options: {
access: 'internal',
},
isDangerous: true,
endpoint: 'GET /internal/my_plugin/route',
handler: async (resources) => {
const { request, context, response, logger } = resources;
return response.ok({
body: 'Hello, my route!',
});
},
});
```
@ -346,3 +354,37 @@ class MyPlugin implements Plugin {
```
If you don't want your route to have access to the options provided by Core HTTP, you could pass in only `MyPluginClientOptions`.
## Streaming
@kbn/server-route-repository supports streaming events as well. It uses server-sent events (SSE) for this. To use it, simply return an Observable in the route handler:
```javascript
import { createMyPluginServerRoute } from './create_my_plugin_server_route';
import { ServerSentEvent } from '@kbn/sse-utils';
export const myRoute = createMyPluginServerRoute({
endpoint: 'GET /internal/my_plugin/streaming_route',
handler: async (resources) => {
const { request, context, response, logger } = resources;
return of({
type: 'my_event' as const,
data: {
myData: {}
}
})
},
});
```
This will create a Node.js response stream where events are emitted as soon as the Observable emits them. Errors are automatically serialized, deserialized and thrown. See @kbn/sse-utils for more details.
To parse the event stream in the browser, use the `stream` method on the repository client. It returns a typed Observable:
```javascript
myPluginRepositoryClient.stream('GET /internal/my_plugin/streaming_route').subscribe({
next: (value /*:{ type: 'my_event', data: { myData: {} }}*/) => {
console.log(value);
},
});
```

View file

@ -7,33 +7,17 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
RouteParamsRT,
ServerRoute,
import type {
DefaultRouteCreateOptions,
DefaultRouteHandlerResources,
ServerRouteCreateOptions,
ServerRouteHandlerResources,
DefaultRouteHandlerResources,
DefaultRouteCreateOptions,
} from '@kbn/server-route-repository-utils';
import type { CreateServerRouteFactory } from '@kbn/server-route-repository-utils/src/typings';
export function createServerRouteFactory<
TRouteHandlerResources extends ServerRouteHandlerResources = DefaultRouteHandlerResources,
TRouteCreateOptions extends ServerRouteCreateOptions = DefaultRouteCreateOptions
>(): <
TEndpoint extends string,
TReturnType,
TRouteParamsRT extends RouteParamsRT | undefined = undefined
>(
route: ServerRoute<
TEndpoint,
TRouteParamsRT,
TRouteHandlerResources,
TReturnType,
TRouteCreateOptions
>
) => Record<
TEndpoint,
ServerRoute<TEndpoint, TRouteParamsRT, TRouteHandlerResources, TReturnType, TRouteCreateOptions>
> {
>(): CreateServerRouteFactory<TRouteHandlerResources, TRouteCreateOptions> {
return (route) => ({ [route.endpoint]: route } as any);
}

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ZodObject, ZodAny } from '@kbn/zod';
import { z, ZodObject } from '@kbn/zod';
import { ZodParamsObject } from '@kbn/server-route-repository-utils';
import { noParamsValidationObject } from './validation_objects';
@ -19,7 +19,7 @@ export function makeZodValidationObject(params: ZodParamsObject) {
};
}
function asStrict(schema: ZodAny) {
function asStrict(schema: z.Schema) {
if (schema instanceof ZodObject) {
return schema.strict();
} else {

View file

@ -20,8 +20,11 @@ import {
ZodParamsObject,
parseEndpoint,
} from '@kbn/server-route-repository-utils';
import { observableIntoEventSourceStream } from '@kbn/sse-utils-server';
import { isZod } from '@kbn/zod';
import { merge } from 'lodash';
import { Observable, isObservable } from 'rxjs';
import { ServerSentEvent } from '@kbn/sse-utils';
import { passThroughValidationObject, noParamsValidationObject } from './validation_objects';
import { validateAndDecodeParams } from './validate_and_decode_params';
import { makeZodValidationObject } from './make_zod_validation_object';
@ -89,6 +92,10 @@ export function registerRoutes<TDependencies extends Record<string, any>>({
if (isKibanaResponse(result)) {
return result;
} else if (isObservable(result)) {
return response.ok({
body: observableIntoEventSourceStream(result as Observable<ServerSentEvent>),
});
} else {
const body = result || {};
return response.ok({ body });

View file

@ -11,6 +11,7 @@ import * as t from 'io-ts';
import { z } from '@kbn/zod';
import { kibanaResponseFactory } from '@kbn/core/server';
import { EndpointOf, ReturnOf, RouteRepositoryClient } from '@kbn/server-route-repository-utils';
import { Observable, of } from 'rxjs';
import { createServerRouteFactory } from './create_server_route_factory';
import { decodeRequestParams } from './decode_request_params';
@ -98,13 +99,12 @@ createServerRouteFactory<{}, { options: { tags: string[] } }>()({
});
// Public APIs should be versioned
// @ts-expect-error
createServerRouteFactory<{}, { options: { tags: string[] } }>()({
// @ts-expect-error
endpoint: 'GET /api/endpoint_with_params',
options: {
tags: [],
},
// @ts-expect-error
handler: async (resources) => {},
});
@ -116,6 +116,15 @@ createServerRouteFactory<{}, { options: { tags: string[] } }>()({
handler: async (resources) => {},
});
// cannot return observables that are not in the SSE structure
const route = createServerRouteFactory<{}, {}>()({
endpoint: 'POST /internal/endpoint_returning_observable_without_sse_structure',
// @ts-expect-error
handler: async () => {
return of({ streamed_response: true });
},
});
const createServerRoute = createServerRouteFactory<{}, {}>();
const repository = {
@ -201,6 +210,12 @@ const repository = {
});
},
}),
...createServerRoute({
endpoint: 'POST /internal/endpoint_returning_observable',
handler: async () => {
return of({ type: 'foo' as const, streamed_response: true });
},
}),
};
type TestRepository = typeof repository;
@ -248,21 +263,21 @@ const client: TestClient = {} as any;
// It should respect any additional create options.
// @ts-expect-error Property 'timeout' is missing
client('GET /internal/endpoint_without_params', {});
client.fetch('GET /internal/endpoint_without_params', {});
client('GET /internal/endpoint_without_params', {
client.fetch('GET /internal/endpoint_without_params', {
timeout: 1,
});
// It does not allow params for routes without a params codec
client('GET /internal/endpoint_without_params', {
client.fetch('GET /internal/endpoint_without_params', {
// @ts-expect-error Object literal may only specify known properties, and 'params' does not exist in type
params: {},
timeout: 1,
});
// It requires params for routes with a params codec
client('GET /internal/endpoint_with_params', {
client.fetch('GET /internal/endpoint_with_params', {
params: {
// @ts-expect-error property 'serviceName' is missing in type '{}'
path: {},
@ -270,7 +285,7 @@ client('GET /internal/endpoint_with_params', {
timeout: 1,
});
client('GET /internal/endpoint_with_params_zod', {
client.fetch('GET /internal/endpoint_with_params_zod', {
params: {
// @ts-expect-error property 'serviceName' is missing in type '{}'
path: {},
@ -279,16 +294,16 @@ client('GET /internal/endpoint_with_params_zod', {
});
// Params are optional if the codec has no required keys
client('GET /internal/endpoint_with_optional_params', {
client.fetch('GET /internal/endpoint_with_optional_params', {
timeout: 1,
});
client('GET /internal/endpoint_with_optional_params_zod', {
client.fetch('GET /internal/endpoint_with_optional_params_zod', {
timeout: 1,
});
// If optional, an error will still occur if the params do not match
client('GET /internal/endpoint_with_optional_params', {
client.fetch('GET /internal/endpoint_with_optional_params', {
timeout: 1,
params: {
// @ts-expect-error Object literal may only specify known properties, and 'path' does not exist in type
@ -296,7 +311,7 @@ client('GET /internal/endpoint_with_optional_params', {
},
});
client('GET /internal/endpoint_with_optional_params_zod', {
client.fetch('GET /internal/endpoint_with_optional_params_zod', {
timeout: 1,
params: {
// @ts-expect-error Object literal may only specify known properties, and 'path' does not exist in type
@ -305,57 +320,65 @@ client('GET /internal/endpoint_with_optional_params_zod', {
});
// The return type is correctly inferred
client('GET /internal/endpoint_with_params', {
params: {
path: {
serviceName: '',
client
.fetch('GET /internal/endpoint_with_params', {
params: {
path: {
serviceName: '',
},
},
},
timeout: 1,
}).then((res) => {
assertType<{
noParamsForMe: boolean;
// @ts-expect-error Property 'noParamsForMe' is missing in type
}>(res);
timeout: 1,
})
.then((res) => {
assertType<{
noParamsForMe: boolean;
// @ts-expect-error Property 'noParamsForMe' is missing in type
}>(res);
assertType<{
yesParamsForMe: boolean;
}>(res);
});
assertType<{
yesParamsForMe: boolean;
}>(res);
});
client('GET /internal/endpoint_with_params_zod', {
params: {
path: {
serviceName: '',
client
.fetch('GET /internal/endpoint_with_params_zod', {
params: {
path: {
serviceName: '',
},
},
},
timeout: 1,
}).then((res) => {
assertType<{
noParamsForMe: boolean;
// @ts-expect-error Property 'noParamsForMe' is missing in type
}>(res);
timeout: 1,
})
.then((res) => {
assertType<{
noParamsForMe: boolean;
// @ts-expect-error Property 'noParamsForMe' is missing in type
}>(res);
assertType<{
yesParamsForMe: boolean;
}>(res);
});
assertType<{
yesParamsForMe: boolean;
}>(res);
});
client('GET /internal/endpoint_returning_result', {
timeout: 1,
}).then((res) => {
assertType<{
result: boolean;
}>(res);
});
client
.fetch('GET /internal/endpoint_returning_result', {
timeout: 1,
})
.then((res) => {
assertType<{
result: boolean;
}>(res);
});
client('GET /internal/endpoint_returning_kibana_response', {
timeout: 1,
}).then((res) => {
assertType<{
result: boolean;
}>(res);
});
client
.fetch('GET /internal/endpoint_returning_kibana_response', {
timeout: 1,
})
.then((res) => {
assertType<{
result: boolean;
}>(res);
});
// decodeRequestParams should return the type of the codec that is passed
assertType<{ path: { serviceName: string } }>(
@ -384,3 +407,9 @@ assertType<{ path: { serviceName: boolean } }>(
t.type({ path: t.type({ serviceName: t.string }) })
)
);
assertType<Observable<{ type: 'foo'; streamed_response: boolean }>>(
client.stream('POST /internal/endpoint_returning_observable', {
timeout: 10,
})
);

View file

@ -21,6 +21,8 @@
"@kbn/logging-mocks",
"@kbn/server-route-repository-utils",
"@kbn/zod",
"@kbn/sse-utils-server",
"@kbn/sse-utils",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,3 @@
# @kbn/sse-utils-client
See @kbn/sse-utils.

View file

@ -0,0 +1,10 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { httpResponseIntoObservable } from './src/http_response_into_observable';

View file

@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-sse-utils-client'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/sse-utils-client",
"owner": "@elastic/obs-knowledge-team"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/sse-utils-client",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,83 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createParser } from 'eventsource-parser';
import { Observable, throwError } from 'rxjs';
import { createSSEInternalError, ServerSentEvent, ServerSentEventError } from '@kbn/sse-utils';
import { ServerSentErrorEvent } from '@kbn/sse-utils/src/errors';
export interface StreamedHttpResponse {
response?: { body: ReadableStream<Uint8Array> | null | undefined };
}
export function createObservableFromHttpResponse<T extends ServerSentEvent = never>(
response: StreamedHttpResponse
): Observable<T> {
const rawResponse = response.response;
const body = rawResponse?.body;
if (!body) {
return throwError(() => {
throw createSSEInternalError(`No readable stream found in response`);
});
}
return new Observable<T>((subscriber) => {
const parser = createParser((event) => {
if (event.type === 'event')
try {
const data = JSON.parse(event.data);
if (event.event === 'error') {
const errorData = data as Omit<ServerSentErrorEvent, 'type'>;
subscriber.error(
new ServerSentEventError(
errorData.error.code,
errorData.error.message,
errorData.error.meta
)
);
} else {
subscriber.next({ type: event.event || 'event', ...data } as T);
}
} catch (error) {
subscriber.error(createSSEInternalError(`Failed to parse JSON`));
}
});
const readStream = async () => {
const reader = body.getReader();
const decoder = new TextDecoder();
// Function to process each chunk
const processChunk = ({
done,
value,
}: ReadableStreamReadResult<Uint8Array>): Promise<void> => {
if (done) {
return Promise.resolve();
}
parser.feed(decoder.decode(value, { stream: true }));
return reader.read().then(processChunk);
};
// Start reading the stream
return reader.read().then(processChunk);
};
readStream()
.then(() => {
subscriber.complete();
})
.catch((error) => {
subscriber.error(error);
});
});
}

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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { lastValueFrom, of, toArray } from 'rxjs';
import { httpResponseIntoObservable } from './http_response_into_observable';
import type { StreamedHttpResponse } from './create_observable_from_http_response';
import { ServerSentEventErrorCode } from '@kbn/sse-utils/src/errors';
function toSse(...events: Array<{ type: string } & Record<string, unknown>>) {
return events.map((event) => {
const { type, ...rest } = event;
return new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(rest)}\n\n`);
});
}
describe('httpResponseIntoObservable', () => {
it('parses SSE output', async () => {
const events = [
{
type: 'chatCompletionChunk',
content: 'Hello',
},
{
type: 'chatCompletionChunk',
content: 'Hello again',
},
];
const messages = await lastValueFrom(
of<StreamedHttpResponse>({
response: {
// @ts-expect-error
body: ReadableStream.from(toSse(...events)),
},
}).pipe(httpResponseIntoObservable(), toArray())
);
expect(messages).toEqual(events);
});
it('throws serialized errors', async () => {
const events = [
{
type: 'error',
error: {
code: ServerSentEventErrorCode.internalError,
message: 'Internal error',
},
},
];
await expect(async () => {
await lastValueFrom(
of<StreamedHttpResponse>({
response: {
// @ts-expect-error
body: ReadableStream.from(toSse(...events)),
},
}).pipe(httpResponseIntoObservable(), toArray())
);
}).rejects.toThrowError(`Internal error`);
});
});

View file

@ -0,0 +1,21 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { OperatorFunction, switchMap } from 'rxjs';
import type { ServerSentEvent } from '@kbn/sse-utils/src/events';
import {
createObservableFromHttpResponse,
StreamedHttpResponse,
} from './create_observable_from_http_response';
export function httpResponseIntoObservable<
T extends ServerSentEvent = ServerSentEvent
>(): OperatorFunction<StreamedHttpResponse, T> {
return switchMap((response) => createObservableFromHttpResponse<T>(response));
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/sse-utils",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/sse-utils-server
See @kbn/sse-utils.

View file

@ -0,0 +1,10 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { observableIntoEventSourceStream } from './src/observable_into_event_source_stream';

View file

@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-sse-utils-server'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/sse-utils-server",
"owner": "@elastic/obs-knowledge-team"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/sse-utils-server",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,38 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { map, Observable } from 'rxjs';
import { PassThrough } from 'stream';
import { ServerSentEvent } from '@kbn/sse-utils';
export function observableIntoEventSourceStream(source$: Observable<ServerSentEvent>): PassThrough {
const withSerializedEvents$ = source$.pipe(
map((event) => {
const { type, ...rest } = event;
return `event: ${type}\ndata: ${JSON.stringify(rest)}\n\n`;
})
);
const stream = new PassThrough();
withSerializedEvents$.subscribe({
next: (line) => {
stream.write(line);
},
complete: () => {
stream.end();
},
error: (error) => {
stream.write(`event: error\ndata: ${JSON.stringify(error)}\n\n`);
stream.end();
},
});
return stream;
}

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/sse-utils",
]
}

View file

@ -0,0 +1,57 @@
# @kbn/sse-utils
This package exports utility functions that can be used to format and parse server-sent events(SSE). SSE is useful when streaming data back to the browser as part of a long-running process, such as LLM-based inference. It can convert an Observable that emits values of type `ServerSentEvent` into a response stream on the server, emitting lines in an SSE-compatible format, and it can convert an SSE response stream back into deserialized event.
## Server
On the server, you can use `observableIntoEventSourceStream` to convert an Observable that emits `ServerSentEvent` values into a Node.js response stream:
```ts
import { observableIntoEventSourceStream } from '@kbn/sse-utils-server';
function myRequestHandler(
context: RequestHandlerContext,
request: KibanaRequest,
response: KibanaResponseFactory
) {
return response.ok({
body: observableIntoEventSourceStream(
of({
type: 'my_event_type',
data: {
anyData: {},
},
})
),
});
}
```
All emitted values have to be of `ServerSentEvent` type:
```ts
type ServerSentEvent = {
type: string;
data: Record<string, any>;
};
```
Any error that occurs in the Observable is written to the stream as an event, and the stream is closed.
## Client
On the client, you can use `http `@elastic/core-http-browser` to convert the stream of events back into an Observable:
```ts
import { httpResponseIntoObservable } from '@kbn/sse-utils-client';
function streamEvents(http: Http) {
from(
http.post('/internal/my_event_stream', {
asResponse: true,
rawResponse: true,
})
).pipe(httpResponseIntoObservable());
}
```
Any serialized error events from the stream are de-serialized, and thrown as an error in the Observable.

View file

@ -0,0 +1,19 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export {
createSSERequestError,
createSSEInternalError,
isSSEError,
isSSEInternalError,
isSSERequestError,
ServerSentEventError,
} from './src/errors';
export type { ServerSentEvent, ServerSentEventBase } from './src/events';

View file

@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-sse-utils'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/sse-utils",
"owner": "@elastic/obs-knowledge-team"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/sse-utils",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
import { ServerSentEventBase, ServerSentEventType } from './events';
export enum ServerSentEventErrorCode {
internalError = 'internalError',
requestError = 'requestError',
}
export class ServerSentEventError<
TCode extends string,
TMeta extends Record<string, any> | undefined
> extends Error {
constructor(public code: TCode, message: string, public meta: TMeta) {
super(message);
}
toJSON(): ServerSentErrorEvent {
return {
type: ServerSentEventType.error,
error: {
code: this.code,
message: this.message,
meta: this.meta,
},
};
}
}
export type ServerSentErrorEvent = ServerSentEventBase<
ServerSentEventType.error,
{
error: {
code: string;
message: string;
meta?: Record<string, any>;
};
}
>;
export type ServerSentEventInternalError = ServerSentEventError<
ServerSentEventErrorCode.internalError,
{}
>;
export type ServerSentEventRequestError = ServerSentEventError<
ServerSentEventErrorCode.requestError,
{ status: number }
>;
export function createSSEInternalError(
message: string = i18n.translate('sse.internalError', {
defaultMessage: 'An internal error occurred',
})
): ServerSentEventInternalError {
return new ServerSentEventError(ServerSentEventErrorCode.internalError, message, {});
}
export function createSSERequestError(
message: string,
status: number
): ServerSentEventRequestError {
return new ServerSentEventError(ServerSentEventErrorCode.requestError, message, {
status,
});
}
export function isSSEError(
error: unknown
): error is ServerSentEventError<string, Record<string, any> | undefined> {
return error instanceof ServerSentEventError;
}
export function isSSEInternalError(error: unknown): error is ServerSentEventInternalError {
return isSSEError(error) && error.code === ServerSentEventErrorCode.internalError;
}
export function isSSERequestError(error: unknown): error is ServerSentEventRequestError {
return isSSEError(error) && error.code === ServerSentEventErrorCode.requestError;
}

View file

@ -0,0 +1,24 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type ServerSentEventBase<
TEventType extends string,
TData extends Record<string, any>
> = keyof TData extends 'type'
? never
: TData & {
type: TEventType;
};
export enum ServerSentEventType {
error = 'error',
data = 'data',
}
export type ServerSentEvent = ServerSentEventBase<string, Record<string, unknown>>;

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/i18n",
]
}

View file

@ -45,6 +45,7 @@ export const storybookAliases = {
grouping: 'packages/kbn-grouping/.storybook',
home: 'src/plugins/home/.storybook',
infra: 'x-pack/plugins/observability_solution/infra/.storybook',
inventory: 'x-pack/plugins/observability_solution/inventory/.storybook',
investigate: 'x-pack/plugins/observability_solution/investigate_app/.storybook',
kibana_react: 'src/plugins/kibana_react/.storybook',
lists: 'x-pack/plugins/lists/.storybook',

View file

@ -1038,6 +1038,8 @@
"@kbn/interactive-setup-test-endpoints-plugin/*": ["test/interactive_setup_api_integration/plugins/test_endpoints/*"],
"@kbn/interpreter": ["packages/kbn-interpreter"],
"@kbn/interpreter/*": ["packages/kbn-interpreter/*"],
"@kbn/inventory-plugin": ["x-pack/plugins/observability_solution/inventory"],
"@kbn/inventory-plugin/*": ["x-pack/plugins/observability_solution/inventory/*"],
"@kbn/investigate-app-plugin": ["x-pack/plugins/observability_solution/investigate_app"],
"@kbn/investigate-app-plugin/*": ["x-pack/plugins/observability_solution/investigate_app/*"],
"@kbn/investigate-plugin": ["x-pack/plugins/observability_solution/investigate"],
@ -1746,6 +1748,12 @@
"@kbn/spaces-test-plugin/*": ["x-pack/test/spaces_api_integration/common/plugins/spaces_test_plugin/*"],
"@kbn/spec-to-console": ["packages/kbn-spec-to-console"],
"@kbn/spec-to-console/*": ["packages/kbn-spec-to-console/*"],
"@kbn/sse-utils": ["packages/kbn-sse-utils"],
"@kbn/sse-utils/*": ["packages/kbn-sse-utils/*"],
"@kbn/sse-utils-client": ["packages/kbn-sse-utils-client"],
"@kbn/sse-utils-client/*": ["packages/kbn-sse-utils-client/*"],
"@kbn/sse-utils-server": ["packages/kbn-sse-utils-server"],
"@kbn/sse-utils-server/*": ["packages/kbn-sse-utils-server/*"],
"@kbn/stack-alerts-plugin": ["x-pack/plugins/stack_alerts"],
"@kbn/stack-alerts-plugin/*": ["x-pack/plugins/stack_alerts/*"],
"@kbn/stack-connectors-plugin": ["x-pack/plugins/stack_connectors"],

View file

@ -59,6 +59,7 @@
"xpack.ingestPipelines": "plugins/ingest_pipelines",
"xpack.integrationAssistant": "plugins/integration_assistant",
"xpack.inference": "plugins/inference",
"xpack.inventory": "plugins/observability_solution/inventory",
"xpack.investigate": "plugins/observability_solution/investigate",
"xpack.investigateApp": "plugins/observability_solution/investigate_app",
"xpack.kubernetesSecurity": "plugins/kubernetes_security",

View file

@ -0,0 +1,23 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
export function excludeFrozenQuery(): estypes.QueryDslQueryContainer[] {
return [
{
bool: {
must_not: [
{
term: {
_tier: 'data_frozen',
},
},
],
},
},
];
}

View file

@ -47,12 +47,12 @@ export function createOutputApi(chatCompleteApi: ChatCompleteAPI): OutputAPI {
return {
id,
type: OutputEventType.OutputComplete,
output:
event.toolCalls.length && 'arguments' in event.toolCalls[0].function
? event.toolCalls[0].function.arguments
: undefined,
content: event.content,
type: OutputEventType.OutputComplete,
};
})
);

View file

@ -6,8 +6,8 @@
*/
import { Observable } from 'rxjs';
import { ServerSentEventBase } from '@kbn/sse-utils';
import { FromToolSchema, ToolSchema } from '../chat_complete/tool_schema';
import { InferenceTaskEventBase } from '../inference_task';
import { Message } from '../chat_complete';
export enum OutputEventType {
@ -17,20 +17,25 @@ export enum OutputEventType {
type Output = Record<string, any> | undefined | unknown;
export type OutputUpdateEvent<TId extends string = string> =
InferenceTaskEventBase<OutputEventType.OutputUpdate> & {
export type OutputUpdateEvent<TId extends string = string> = ServerSentEventBase<
OutputEventType.OutputUpdate,
{
id: TId;
content: string;
};
}
>;
export type OutputCompleteEvent<
TId extends string = string,
TOutput extends Output = Output
> = InferenceTaskEventBase<OutputEventType.OutputComplete> & {
id: TId;
output: TOutput;
content?: string;
};
> = ServerSentEventBase<
OutputEventType.OutputComplete,
{
id: TId;
output: TOutput;
content: string;
}
>;
export type OutputEvent<TId extends string = string, TOutput extends Output = Output> =
| OutputUpdateEvent<TId>
@ -67,9 +72,9 @@ export function createOutputCompleteEvent<TId extends string, TOutput extends Ou
content?: string
): OutputCompleteEvent<TId, TOutput> {
return {
id,
type: OutputEventType.OutputComplete,
id,
output,
content,
content: content ?? '',
};
}

View file

@ -0,0 +1,16 @@
/*
* 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 { take } from 'lodash';
export function truncateList<T>(values: T[], limit: number): Array<T | string> {
if (values.length <= limit) {
return values;
}
return [...take(values, limit), `${values.length - limit} more values`];
}

View file

@ -5,23 +5,26 @@
* 2.0.
*/
import { map, OperatorFunction, pipe, switchMap, tap } from 'rxjs';
import { InferenceTaskEvent, InferenceTaskEventType } from '../../common/inference_task';
import {
createObservableFromHttpResponse,
StreamedHttpResponse,
} from './create_observable_from_http_response';
import { catchError, map, OperatorFunction, pipe, switchMap, tap, throwError } from 'rxjs';
import {
createInferenceInternalError,
InferenceTaskError,
InferenceTaskErrorEvent,
} from '../../common/errors';
import { InferenceTaskEvent, InferenceTaskEventType } from '../../common/inference_task';
import {
createObservableFromHttpResponse,
StreamedHttpResponse,
} from './create_observable_from_http_response';
export function httpResponseIntoObservable<
T extends InferenceTaskEvent = never
>(): OperatorFunction<StreamedHttpResponse, T> {
return pipe(
switchMap((response) => createObservableFromHttpResponse(response)),
catchError((error) => {
return throwError(() => createInferenceInternalError(error.message));
}),
map((line): T => {
try {
return JSON.parse(line);

View file

@ -18,6 +18,7 @@ export { withoutTokenCountEvents } from '../common/chat_complete/without_token_c
export { withoutChunkEvents } from '../common/chat_complete/without_chunk_events';
export { withoutOutputUpdateEvents } from '../common/output/without_output_update_events';
export type { InferenceClient } from './types';
export { naturalLanguageToEsql } from './tasks/nl_to_esql';
export type { InferenceServerSetup, InferenceServerStart };

View file

@ -8,8 +8,7 @@
import type { Logger } from '@kbn/logging';
import { isEmpty, mapValues, pick } from 'lodash';
import { Observable, from, map, merge, of, switchMap } from 'rxjs';
import { v4 } from 'uuid';
import { ToolSchema, isChatCompletionMessageEvent } from '../../../common';
import { ToolSchema, generateFakeToolCallId, isChatCompletionMessageEvent } from '../../../common';
import {
ChatCompletionChunkEvent,
ChatCompletionMessageEvent,
@ -97,7 +96,7 @@ export function naturalLanguageToEsql<TToolOptions extends ToolOptions>({
functions,
},
},
toolCallId: v4().substring(0, 6),
toolCallId: generateFakeToolCallId(),
};
return merge(
@ -113,6 +112,7 @@ export function naturalLanguageToEsql<TToolOptions extends ToolOptions>({
keywords,
requestedDocumentation,
},
content: '',
}),
client
.chatComplete({

View file

@ -18,22 +18,23 @@
".storybook/**/*.js"
],
"kbn_references": [
"@kbn/core",
"@kbn/i18n",
"@kbn/sse-utils",
"@kbn/esql-ast",
"@kbn/esql-validation-autocomplete",
"@kbn/core",
"@kbn/logging",
"@kbn/babel-register",
"@kbn/dev-cli-runner",
"@kbn/expect",
"@kbn/tooling-log",
"@kbn/repo-info",
"@kbn/logging-mocks",
"@kbn/core-http-server",
"@kbn/actions-plugin",
"@kbn/config-schema",
"@kbn/esql-validation-autocomplete",
"@kbn/esql-ast",
"@kbn/dev-cli-runner",
"@kbn/babel-register",
"@kbn/expect",
"@kbn/tooling-log",
"@kbn/es-types",
"@kbn/field-types",
"@kbn/expressions-plugin",
"@kbn/logging-mocks",
"@kbn/repo-info"
]
}

View file

@ -26,7 +26,7 @@ export function createSharedUseFetcher<TEndpoint extends APIEndpoint>(
): SharedUseFetcher<TEndpoint> {
const Context = createContext<APIClientRequestParamsOf<APIEndpoint> | undefined>(undefined);
const returnValue: SharedUseFetcher<TEndpoint> = {
const returnValue: SharedUseFetcher<APIEndpoint> = {
useFetcherResult: () => {
const context = useContext(Context);
@ -34,16 +34,16 @@ export function createSharedUseFetcher<TEndpoint extends APIEndpoint>(
throw new Error('Context was not found');
}
const params = context.params;
const params = 'params' in context ? context.params : undefined;
const result = useFetcher(
(callApmApi) => {
return callApmApi(...([endpoint, { params }] as Parameters<typeof callApmApi>));
return callApmApi(endpoint, ...((params ? [{ params }] : []) as any));
},
[params]
);
return result as ReturnType<SharedUseFetcher<TEndpoint>['useFetcherResult']>;
return result as ReturnType<SharedUseFetcher<APIEndpoint>['useFetcherResult']>;
},
Provider: (props) => {
const { children } = props;

View file

@ -22,12 +22,12 @@ export type APMClientOptions = Omit<FetchOptions, 'query' | 'body' | 'pathname'
signal: AbortSignal | null;
};
export type APMClient = RouteRepositoryClient<APMServerRouteRepository, APMClientOptions>;
export type APMClient = RouteRepositoryClient<APMServerRouteRepository, APMClientOptions>['fetch'];
export type AutoAbortedAPMClient = RouteRepositoryClient<
APMServerRouteRepository,
Omit<APMClientOptions, 'signal'>
>;
>['fetch'];
export type APIReturnType<TEndpoint extends APIEndpoint> = ReturnOf<
APMServerRouteRepository,
@ -43,7 +43,10 @@ export type APIClientRequestParamsOf<TEndpoint extends APIEndpoint> = ClientRequ
export type AbstractAPMRepository = ServerRouteRepository;
export type AbstractAPMClient = RouteRepositoryClient<AbstractAPMRepository, APMClientOptions>;
export type AbstractAPMClient = RouteRepositoryClient<
AbstractAPMRepository,
APMClientOptions
>['fetch'];
export let callApmApi: APMClient = () => {
throw new Error('callApmApi has to be initialized before used. Call createCallApmApi first.');

View file

@ -68,7 +68,9 @@ const getRegisterRouteDependencies = () => {
};
const initApi = (
routes: Array<ServerRoute<any, t.Any, APMRouteHandlerResources, any, APMRouteCreateOptions>>
routes: Array<
ServerRoute<any, t.Any | undefined, APMRouteHandlerResources, any, APMRouteCreateOptions>
>
) => {
const { mocks, dependencies } = getRegisterRouteDependencies();

View file

@ -17,7 +17,7 @@ export const debugTelemetryRoute = createApmServerRoute({
options: {
tags: ['access:apm', 'access:apm_write'],
},
handler: async (resources): Promise<APMTelemetry | unknown> => {
handler: async (resources): Promise<APMTelemetry> => {
const { plugins, context } = resources;
const coreContext = await context.core;
const taskManagerStart = await plugins.taskManager?.start();
@ -30,6 +30,6 @@ export const debugTelemetryRoute = createApmServerRoute({
APM_TELEMETRY_SAVED_OBJECT_ID
);
return apmTelemetryObject.attributes;
return apmTelemetryObject.attributes as APMTelemetry;
},
});

View file

@ -26,12 +26,12 @@ export type DatasetQualityClientOptions = Omit<
export type DatasetQualityClient = RouteRepositoryClient<
DatasetQualityServerRouteRepository,
DatasetQualityClientOptions
>;
>['fetch'];
export type AutoAbortedClient = RouteRepositoryClient<
DatasetQualityServerRouteRepository,
Omit<DatasetQualityClientOptions, 'signal'>
>;
>['fetch'];
export type APIReturnType<TEndpoint extends APIEndpoint> = ReturnOf<
DatasetQualityServerRouteRepository,

View file

@ -20,7 +20,7 @@ import {
import type { EntityManagerRouteRepository } from '../../server';
import { EntityManagerUnauthorizedError } from './errors';
type EntityManagerRepositoryClient = RouteRepositoryClient<EntityManagerRouteRepository>;
type EntityManagerRepositoryClient = RouteRepositoryClient<EntityManagerRouteRepository, {}>;
type QueryParamOf<T extends { params?: any }> = Exclude<T['params'], undefined>['query'];
@ -36,7 +36,7 @@ type CreateEntityDefinitionQuery = QueryParamOf<
>;
export class EntityClient {
public readonly repositoryClient: EntityManagerRepositoryClient;
public readonly repositoryClient: EntityManagerRepositoryClient['fetch'];
constructor(core: CoreStart | CoreSetup) {
this.repositoryClient = createRepositoryClient<EntityManagerRouteRepository>(core).fetch;

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 type { IScopedClusterClient, SavedObjectsClientContract } from '@kbn/core/server';
import { findEntityDefinitions } from '../entities/find_entity_definition';
import type { EntityDefinitionWithState } from '../entities/types';
export class EntityManagerClient {
constructor(
private readonly esClient: IScopedClusterClient,
private readonly soClient: SavedObjectsClientContract
) {}
findEntityDefinitions({ page, perPage }: { page?: number; perPage?: number } = {}): Promise<
EntityDefinitionWithState[]
> {
return findEntityDefinitions({
esClient: this.esClient.asCurrentUser,
soClient: this.soClient,
page,
perPage,
});
}
}

View file

@ -30,8 +30,11 @@ import {
EntityManagerServerSetup,
} from './types';
export type EntityManagerServerPluginSetup = ReturnType<EntityManagerServerPlugin['setup']>;
export type EntityManagerServerPluginStart = ReturnType<EntityManagerServerPlugin['start']>;
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EntityManagerServerPluginSetup {}
export interface EntityManagerServerPluginStart {
getScopedClient: (options: { request: KibanaRequest }) => Promise<EntityClient>;
}
export const config: PluginConfigDescriptor<EntityManagerConfig> = {
schema: configSchema,
@ -56,7 +59,10 @@ export class EntityManagerServerPlugin
this.logger = context.logger.get();
}
public setup(core: CoreSetup, plugins: EntityManagerPluginSetupDependencies) {
public setup(
core: CoreSetup,
plugins: EntityManagerPluginSetupDependencies
): EntityManagerServerPluginSetup {
core.savedObjects.registerType(entityDefinition);
core.savedObjects.registerType(EntityDiscoveryApiKeyType);
plugins.encryptedSavedObjects.registerType({
@ -76,9 +82,7 @@ export class EntityManagerServerPlugin
server: this.server,
getScopedClient: async ({ request }: { request: KibanaRequest }) => {
const [coreStart] = await core.getStartServices();
const esClient = coreStart.elasticsearch.client.asScoped(request).asCurrentUser;
const soClient = coreStart.savedObjects.getScopedClient(request);
return new EntityClient({ esClient, soClient, logger: this.logger });
return this.getScopedClient({ request, coreStart });
},
},
core,
@ -88,7 +92,22 @@ export class EntityManagerServerPlugin
return {};
}
public start(core: CoreStart, plugins: EntityManagerPluginStartDependencies) {
private async getScopedClient({
request,
coreStart,
}: {
request: KibanaRequest;
coreStart: CoreStart;
}) {
const esClient = coreStart.elasticsearch.client.asScoped(request).asCurrentUser;
const soClient = coreStart.savedObjects.getScopedClient(request);
return new EntityClient({ esClient, soClient, logger: this.logger });
}
public start(
core: CoreStart,
plugins: EntityManagerPluginStartDependencies
): EntityManagerServerPluginStart {
if (this.server) {
this.server.core = core;
this.server.isServerless = core.elasticsearch.getCapabilities().serverless;
@ -114,7 +133,11 @@ export class EntityManagerServerPlugin
})
.catch((err) => this.logger.error(err));
return {};
return {
getScopedClient: async ({ request }: { request: KibanaRequest }) => {
return this.getScopedClient({ request, coreStart: core });
},
};
}
public stop() {}

View file

@ -4,7 +4,7 @@
"outDir": "target/types"
},
"include": [
"../../../../typings/**/*",
"../../../typings/**/*",
"common/**/*",
"server/**/*",
"public/**/*",
@ -12,25 +12,25 @@
],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/core-plugins-server",
"@kbn/core",
"@kbn/config-schema",
"@kbn/core-http-server",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/datemath",
"@kbn/entities-schema",
"@kbn/core",
"@kbn/core-plugins-server",
"@kbn/server-route-repository-client",
"@kbn/logging",
"@kbn/core-http-server",
"@kbn/security-plugin",
"@kbn/es-query",
"@kbn/core-elasticsearch-server",
"@kbn/core-saved-objects-api-server",
"@kbn/core-elasticsearch-client-server-mocks",
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/entities-schema",
"@kbn/es-query",
"@kbn/security-plugin",
"@kbn/encrypted-saved-objects-plugin",
"@kbn/logging-mocks",
"@kbn/licensing-plugin",
"@kbn/server-route-repository-client",
"@kbn/datemath",
"@kbn/server-route-repository",
"@kbn/zod",
"@kbn/zod-helpers",
"@kbn/encrypted-saved-objects-plugin",
"@kbn/licensing-plugin",
]
}

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 { coreMock } from '@kbn/core/public/mocks';
import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public';
import type { InferencePublicStart } from '@kbn/inference-plugin/public';
import type { InventoryKibanaContext } from '../public/hooks/use_kibana';
export function getMockInventoryContext(): InventoryKibanaContext {
const core = coreMock.createStart();
return {
core,
dependencies: {
start: {
observabilityShared: {} as unknown as ObservabilitySharedPluginStart,
inference: {} as unknown as InferencePublicStart,
},
},
services: {
inventoryAPIClient: {
fetch: jest.fn(),
stream: jest.fn(),
},
},
};
}

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 { setGlobalConfig } from '@storybook/testing-react';
import * as globalStorybookConfig from './preview';
setGlobalConfig(globalStorybookConfig);

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.
*/
module.exports = require('@kbn/storybook').defaultConfig;

View file

@ -0,0 +1,13 @@
/*
* 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 { EuiThemeProviderDecorator } from '@kbn/kibana-react-plugin/common';
import * as jest from 'jest-mock';
window.jest = jest;
export const decorators = [EuiThemeProviderDecorator];

View file

@ -0,0 +1,18 @@
/*
* 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, { ComponentType, useMemo } from 'react';
import { InventoryContextProvider } from '../public/components/inventory_context_provider';
import { getMockInventoryContext } from './get_mock_inventory_context';
export function KibanaReactStorybookDecorator(Story: ComponentType) {
const context = useMemo(() => getMockInventoryContext(), []);
return (
<InventoryContextProvider context={context}>
<Story />
</InventoryContextProvider>
);
}

View file

@ -0,0 +1,3 @@
# Inventory
Home of the Inventory plugin, which renders the... _inventory_.

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.
*/
export interface EntityTypeDefinition {
type: string;
label: string;
icon: string;
count: number;
}
export interface EntityDefinition {
type: string;
field: string;
filter?: string;
index: string[];
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: [
'<rootDir>/x-pack/plugins/observability_solution/inventory/public',
'<rootDir>/x-pack/plugins/observability_solution/inventory/common',
'<rootDir>/x-pack/plugins/observability_solution/inventory/server',
],
setupFiles: [
'<rootDir>/x-pack/plugins/observability_solution/inventory/.storybook/jest_setup.js',
],
collectCoverage: true,
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/observability_solution/inventory/{public,common,server}/**/*.{js,ts,tsx}',
],
coverageReporters: ['html'],
};

View file

@ -0,0 +1,22 @@
{
"type": "plugin",
"id": "@kbn/inventory-plugin",
"owner": "@elastic/obs-ux-infra_services-team",
"plugin": {
"id": "inventory",
"server": true,
"browser": true,
"configPath": ["xpack", "inventory"],
"requiredPlugins": [
"observabilityShared",
"entityManager",
"inference",
"dataViews"
],
"requiredBundles": [
"kibanaReact"
],
"optionalPlugins": [],
"extraPublicDirs": []
}
}

View file

@ -0,0 +1,50 @@
/*
* 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 { CoreSetup, CoreStart, HttpFetchOptions } from '@kbn/core/public';
import type {
ClientRequestParamsOf,
ReturnOf,
RouteRepositoryClient,
} from '@kbn/server-route-repository';
import { createRepositoryClient } from '@kbn/server-route-repository-client';
import type { InventoryServerRouteRepository } from '../../server';
type FetchOptions = Omit<HttpFetchOptions, 'body'> & {
body?: any;
};
export type InventoryAPIClientOptions = Omit<
FetchOptions,
'query' | 'body' | 'pathname' | 'signal'
> & {
signal: AbortSignal | null;
};
export type InventoryAPIClient = RouteRepositoryClient<
InventoryServerRouteRepository,
InventoryAPIClientOptions
>;
export type AutoAbortedInventoryAPIClient = RouteRepositoryClient<
InventoryServerRouteRepository,
Omit<InventoryAPIClientOptions, 'signal'>
>;
export type InventoryAPIEndpoint = keyof InventoryServerRouteRepository;
export type APIReturnType<TEndpoint extends InventoryAPIEndpoint> = ReturnOf<
InventoryServerRouteRepository,
TEndpoint
>;
export type InventoryAPIClientRequestParamsOf<TEndpoint extends InventoryAPIEndpoint> =
ClientRequestParamsOf<InventoryServerRouteRepository, TEndpoint>;
export function createCallInventoryAPI(core: CoreStart | CoreSetup): InventoryAPIClient {
return createRepositoryClient(core);
}

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart, CoreTheme } from '@kbn/core/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { History } from 'history';
import React, { useMemo } from 'react';
import type { Observable } from 'rxjs';
import { RouteRenderer, RouterProvider } from '@kbn/typed-react-router-config';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { InventoryStartDependencies } from './types';
import { inventoryRouter } from './routes/config';
import { InventoryKibanaContext } from './hooks/use_kibana';
import { InventoryServices } from './services/types';
import { InventoryContextProvider } from './components/inventory_context_provider';
function Application({
coreStart,
history,
pluginsStart,
theme$,
services,
}: {
coreStart: CoreStart;
history: History;
pluginsStart: InventoryStartDependencies;
theme$: Observable<CoreTheme>;
services: InventoryServices;
}) {
const theme = useMemo(() => {
return { theme$ };
}, [theme$]);
const context: InventoryKibanaContext = useMemo(
() => ({
core: coreStart,
dependencies: {
start: pluginsStart,
},
services,
}),
[coreStart, pluginsStart, services]
);
return (
<KibanaRenderContextProvider
theme={theme}
i18n={coreStart.i18n}
analytics={coreStart.analytics}
>
<InventoryContextProvider context={context}>
<RedirectAppLinks coreStart={coreStart}>
<coreStart.i18n.Context>
<RouterProvider history={history} router={inventoryRouter as any}>
<RouteRenderer />
</RouterProvider>
</coreStart.i18n.Context>
</RedirectAppLinks>
</InventoryContextProvider>
</KibanaRenderContextProvider>
);
}
export { Application };

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 { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { mergePlainObjects } from '@kbn/investigate-plugin/common';
import { EntityTypeListBase as Component } from '.';
import { KibanaReactStorybookDecorator } from '../../../.storybook/storybook_decorator';
interface Args {
props: Omit<React.ComponentProps<typeof Component>, 'onLockAllClick' | 'onUnlockAllClick'>;
}
type StoryMeta = Meta<Args>;
type Story = StoryObj<Args>;
const meta: StoryMeta = {
component: Component,
title: 'app/Molecules/EntityTypeList',
decorators: [KibanaReactStorybookDecorator],
};
export default meta;
const defaultStory: Story = {
args: {
props: {
definitions: [],
loading: true,
},
},
render: function Render(args) {
return (
<div style={{ width: 240 }}>
<Component {...args.props} />
</div>
);
},
};
export const Default: Story = {
...defaultStory,
args: {
props: mergePlainObjects(defaultStory.args!.props!, {
loading: false,
definitions: [
{
icon: 'node',
label: 'Services',
type: 'service',
count: 9,
},
{
icon: 'pipeNoBreaks',
label: 'Datasets',
type: 'dataset',
count: 11,
},
],
}),
},
name: 'default',
};
export const Empty: Story = {
...defaultStory,
args: {
props: mergePlainObjects(defaultStory.args!.props!, {
definitions: [],
loading: false,
}),
},
name: 'empty',
};
export const Loading: Story = {
...defaultStory,
args: {
props: mergePlainObjects(defaultStory.args!.props!, {
loading: true,
}),
},
name: 'loading',
};

View file

@ -0,0 +1,96 @@
/*
* 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 { useAbortableAsync } from '@kbn/observability-utils/hooks/use_abortable_async';
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiLoadingSpinner,
EuiText,
} from '@elastic/eui';
import { useKibana } from '../../hooks/use_kibana';
import { EntityTypeDefinition } from '../../../common/entities';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
export function EntityTypeListItem({
href,
icon,
label,
count,
}: {
href: string;
icon: string;
label: string;
count: number;
}) {
return (
<EuiLink data-test-subj="inventoryEntityTypeListBaseLink" href={href}>
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon size="s" type={icon} />
</EuiFlexItem>
<EuiFlexItem grow>
<EuiText size="s">{label}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{count}</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
);
}
export function EntityTypeListBase({
definitions,
loading,
error,
}: {
loading?: boolean;
definitions?: EntityTypeDefinition[];
error?: Error;
}) {
const router = useInventoryRouter();
if (loading) {
return <EuiLoadingSpinner size="s" />;
}
return (
<EuiFlexGroup direction="column" gutterSize="s">
{definitions?.map((definition) => {
return (
<EntityTypeListItem
key={definition.type}
href={router.link('/{type}', { path: { type: definition.type } })}
icon={definition.icon}
count={definition.count}
label={definition.label}
/>
);
})}
</EuiFlexGroup>
);
}
export function EntityTypeList() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { value, loading, error } = useAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entity_types', {
signal,
});
},
[inventoryAPIClient]
);
return <EntityTypeListBase definitions={value?.definitions} error={error} loading={loading} />;
}

View file

@ -0,0 +1,19 @@
/*
* 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 { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { InventoryKibanaContext } from '../../hooks/use_kibana';
export function InventoryContextProvider({
context,
children,
}: {
context: InventoryKibanaContext;
children: React.ReactNode;
}) {
return <KibanaContextProvider services={context}>{children}</KibanaContextProvider>;
}

View file

@ -0,0 +1,78 @@
/*
* 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 { EuiFlexGroup, EuiPanel, EuiTitle } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import { useTheme } from '@kbn/observability-utils/hooks/use_theme';
import React from 'react';
import { useKibana } from '../../hooks/use_kibana';
import { EntityTypeList } from '../entity_type_list';
export function InventoryPageTemplate({ children }: { children: React.ReactNode }) {
const {
dependencies: {
start: { observabilityShared },
},
} = useKibana();
const { PageTemplate } = observabilityShared.navigation;
const theme = useTheme();
return (
<PageTemplate
pageSectionProps={{
className: css`
max-height: calc(100vh - var(--euiFixedHeadersOffset, 0));
overflow: auto;
padding-inline: 0px;
`,
contentProps: {
className: css`
padding-block: 0px;
display: flex;
height: 100%;
`,
},
}}
>
<EuiFlexGroup direction="row" gutterSize="s" alignItems="stretch">
<EuiPanel
className={css`
width: 288px;
max-width: 288px;
min-width: 288px;
padding: 24px;
height: 100%;
border: none;
border-radius: 0;
border-right: 1px solid ${theme.colors.lightShade};
`}
hasBorder={false}
hasShadow={false}
>
<EuiFlexGroup direction="column" gutterSize="l">
<EuiTitle size="xs">
<h1>
{i18n.translate('xpack.inventory.inventoryPageHeaderLabel', {
defaultMessage: 'Inventory',
})}
</h1>
</EuiTitle>
<EuiFlexGroup direction="column" gutterSize="m">
<EntityTypeList />
</EuiFlexGroup>
</EuiFlexGroup>
</EuiPanel>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="l">
{children}
</EuiPanel>
</EuiFlexGroup>
</PageTemplate>
);
}

View file

@ -0,0 +1,14 @@
/*
* 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 PathsOf, type TypeOf, useParams } from '@kbn/typed-react-router-config';
import type { InventoryRoutes } from '../routes/config';
export function useInventoryParams<TPath extends PathsOf<InventoryRoutes>>(
path: TPath
): TypeOf<InventoryRoutes, TPath> {
return useParams(path)! as TypeOf<InventoryRoutes, TPath>;
}

View file

@ -0,0 +1,15 @@
/*
* 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 { PathsOf, useRoutePath } from '@kbn/typed-react-router-config';
import type { InventoryRoutes } from '../routes/config';
export function useInventoryRoutePath() {
const path = useRoutePath();
return path as PathsOf<InventoryRoutes>;
}

View file

@ -0,0 +1,55 @@
/*
* 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 { PathsOf, TypeAsArgs, TypeOf } from '@kbn/typed-react-router-config';
import { useMemo } from 'react';
import type { InventoryRouter, InventoryRoutes } from '../routes/config';
import { inventoryRouter } from '../routes/config';
import { useKibana } from './use_kibana';
interface StatefulInventoryRouter extends InventoryRouter {
push<T extends PathsOf<InventoryRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<InventoryRoutes, T>>
): void;
replace<T extends PathsOf<InventoryRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<InventoryRoutes, T>>
): void;
}
export function useInventoryRouter(): StatefulInventoryRouter {
const {
core: {
http,
application: { navigateToApp },
},
} = useKibana();
const link = (...args: any[]) => {
// @ts-expect-error
return inventoryRouter.link(...args);
};
return useMemo<StatefulInventoryRouter>(
() => ({
...inventoryRouter,
push: (...args) => {
const next = link(...args);
navigateToApp('inventory', { path: next, replace: false });
},
replace: (path, ...args) => {
const next = link(path, ...args);
navigateToApp('inventory', { path: next, replace: true });
},
link: (path, ...args) => {
return http.basePath.prepend('/app/observability/inventory' + link(path, ...args));
},
}),
[navigateToApp, http.basePath]
);
}

View file

@ -0,0 +1,23 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import type { InventoryStartDependencies } from '../types';
import type { InventoryServices } from '../services/types';
export interface InventoryKibanaContext {
core: CoreStart;
dependencies: { start: InventoryStartDependencies };
services: InventoryServices;
}
const useTypedKibana = () => {
return useKibana<InventoryKibanaContext>().services;
};
export { useTypedKibana as useKibana };

View file

@ -0,0 +1,26 @@
/*
* 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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/public';
import { InventoryPlugin } from './plugin';
import type {
InventoryPublicSetup,
InventoryPublicStart,
InventorySetupDependencies,
InventoryStartDependencies,
ConfigSchema,
} from './types';
export type { InventoryPublicSetup, InventoryPublicStart };
export const plugin: PluginInitializer<
InventoryPublicSetup,
InventoryPublicStart,
InventorySetupDependencies,
InventoryStartDependencies
> = (pluginInitializerContext: PluginInitializerContext<ConfigSchema>) =>
new InventoryPlugin(pluginInitializerContext);

View file

@ -0,0 +1,114 @@
/*
* 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 ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import {
AppMountParameters,
APP_WRAPPER_CLASS,
CoreSetup,
CoreStart,
DEFAULT_APP_CATEGORIES,
Plugin,
PluginInitializerContext,
} from '@kbn/core/public';
import type { Logger } from '@kbn/logging';
import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants';
import { css } from '@emotion/css';
import type {
ConfigSchema,
InventoryPublicSetup,
InventoryPublicStart,
InventorySetupDependencies,
InventoryStartDependencies,
} from './types';
import { InventoryServices } from './services/types';
import { createCallInventoryAPI } from './api';
export class InventoryPlugin
implements
Plugin<
InventoryPublicSetup,
InventoryPublicStart,
InventorySetupDependencies,
InventoryStartDependencies
>
{
logger: Logger;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<InventoryStartDependencies, InventoryPublicStart>,
pluginsSetup: InventorySetupDependencies
): InventoryPublicSetup {
const inventoryAPIClient = createCallInventoryAPI(coreSetup);
coreSetup.application.register({
id: INVENTORY_APP_ID,
title: i18n.translate('xpack.inventory.appTitle', {
defaultMessage: 'Inventory',
}),
euiIconType: 'logoObservability',
appRoute: '/app/observability/inventory',
category: DEFAULT_APP_CATEGORIES.observability,
visibleIn: ['sideNav'],
order: 8001,
deepLinks: [
{
id: 'inventory',
title: i18n.translate('xpack.inventory.inventoryDeepLinkTitle', {
defaultMessage: 'Inventory',
}),
path: '/',
},
],
mount: async (appMountParameters: AppMountParameters<unknown>) => {
// Load application bundle and Get start services
const [{ Application }, [coreStart, pluginsStart]] = await Promise.all([
import('./application'),
coreSetup.getStartServices(),
]);
const services: InventoryServices = {
inventoryAPIClient,
};
ReactDOM.render(
<Application
coreStart={coreStart}
history={appMountParameters.history}
pluginsStart={pluginsStart}
theme$={appMountParameters.theme$}
services={services}
/>,
appMountParameters.element
);
const appWrapperClassName = css`
overflow: auto;
`;
const appWrapperElement = document.getElementsByClassName(APP_WRAPPER_CLASS)[1];
appWrapperElement.classList.add(appWrapperClassName);
return () => {
ReactDOM.unmountComponentAtNode(appMountParameters.element);
appWrapperElement.classList.remove(appWrapperClassName);
};
},
});
return {};
}
start(coreStart: CoreStart, pluginsStart: InventoryStartDependencies): InventoryPublicStart {
return {};
}
}

View file

@ -0,0 +1,41 @@
/*
* 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 * as t from 'io-ts';
import { createRouter, Outlet } from '@kbn/typed-react-router-config';
import React from 'react';
import { InventoryPageTemplate } from '../components/inventory_page_template';
/**
* The array of route definitions to be used when the application
* creates the routes.
*/
const inventoryRoutes = {
'/': {
element: (
<InventoryPageTemplate>
<Outlet />
</InventoryPageTemplate>
),
children: {
'/{type}': {
element: <></>,
params: t.type({
path: t.type({ type: t.string }),
}),
},
'/': {
element: <></>,
},
},
},
};
export type InventoryRoutes = typeof inventoryRoutes;
export const inventoryRouter = createRouter(inventoryRoutes);
export type InventoryRouter = typeof inventoryRouter;

View file

@ -0,0 +1,12 @@
/*
* 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 { InventoryAPIClient } from '../api';
export interface InventoryServices {
inventoryAPIClient: InventoryAPIClient;
}

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 type {
ObservabilitySharedPluginStart,
ObservabilitySharedPluginSetup,
} from '@kbn/observability-shared-plugin/public';
import type { InferencePublicStart, InferencePublicSetup } from '@kbn/inference-plugin/public';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ConfigSchema {}
export interface InventorySetupDependencies {
observabilityShared: ObservabilitySharedPluginSetup;
inference: InferencePublicSetup;
}
export interface InventoryStartDependencies {
observabilityShared: ObservabilitySharedPluginStart;
inference: InferencePublicStart;
}
export interface InventoryPublicSetup {}
export interface InventoryPublicStart {}

View file

@ -0,0 +1,14 @@
/*
* 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 { schema, type TypeOf } from '@kbn/config-schema';
export const config = schema.object({
enabled: schema.boolean({ defaultValue: false }),
});
export type InventoryConfig = TypeOf<typeof config>;

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 type {
PluginConfigDescriptor,
PluginInitializer,
PluginInitializerContext,
} from '@kbn/core/server';
import type { InventoryConfig } from './config';
import { InventoryPlugin } from './plugin';
import type {
InventoryServerSetup,
InventoryServerStart,
InventorySetupDependencies,
InventoryStartDependencies,
} from './types';
export type { InventoryServerRouteRepository } from './routes/get_global_inventory_route_repository';
export type { InventoryServerSetup, InventoryServerStart };
import { config as configSchema } from './config';
export const config: PluginConfigDescriptor<InventoryConfig> = {
schema: configSchema,
};
export const plugin: PluginInitializer<
InventoryServerSetup,
InventoryServerStart,
InventorySetupDependencies,
InventoryStartDependencies
> = async (pluginInitializerContext: PluginInitializerContext<InventoryConfig>) =>
new InventoryPlugin(pluginInitializerContext);

View file

@ -0,0 +1,64 @@
/*
* 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { mapValues } from 'lodash';
import { registerServerRoutes } from './routes/register_routes';
import { InventoryRouteHandlerResources } from './routes/types';
import type {
ConfigSchema,
InventoryServerSetup,
InventoryServerStart,
InventorySetupDependencies,
InventoryStartDependencies,
} from './types';
export class InventoryPlugin
implements
Plugin<
InventoryServerSetup,
InventoryServerStart,
InventorySetupDependencies,
InventoryStartDependencies
>
{
logger: Logger;
constructor(context: PluginInitializerContext<ConfigSchema>) {
this.logger = context.logger.get();
}
setup(
coreSetup: CoreSetup<InventoryStartDependencies, InventoryServerStart>,
pluginsSetup: InventorySetupDependencies
): InventoryServerSetup {
const startServicesPromise = coreSetup
.getStartServices()
.then(([_coreStart, pluginsStart]) => pluginsStart);
registerServerRoutes({
core: coreSetup,
logger: this.logger,
dependencies: {
plugins: mapValues(pluginsSetup, (value, key) => {
return {
start: () =>
startServicesPromise.then(
(startServices) => startServices[key as keyof typeof startServices]
),
setup: () => value,
};
}) as unknown as InventoryRouteHandlerResources['plugins'],
},
});
return {};
}
start(core: CoreStart, pluginsStart: InventoryStartDependencies): InventoryServerStart {
return {};
}
}

View file

@ -0,0 +1,13 @@
/*
* 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 { createServerRouteFactory } from '@kbn/server-route-repository';
import type { InventoryRouteCreateOptions, InventoryRouteHandlerResources } from './types';
export const createInventoryServerRoute = createServerRouteFactory<
InventoryRouteHandlerResources,
InventoryRouteCreateOptions
>();

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { EntityTypeDefinition } from '../../../common/entities';
import { createInventoryServerRoute } from '../create_inventory_server_route';
export const listEntityTypesRoute = createInventoryServerRoute({
endpoint: 'GET /internal/inventory/entity_types',
options: {
tags: ['access:inventory'],
},
handler: async ({ plugins, request }): Promise<{ definitions: EntityTypeDefinition[] }> => {
return {
definitions: [
{
label: i18n.translate('xpack.inventory.entityTypeLabels.datasets', {
defaultMessage: 'Datasets',
}),
icon: 'pipeNoBreaks',
type: 'dataset',
count: 0,
},
],
};
},
});
export const entitiesRoutes = {
...listEntityTypesRoute,
};

View file

@ -0,0 +1,18 @@
/*
* 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 { entitiesRoutes } from './entities/route';
export function getGlobalInventoryServerRouteRepository() {
return {
...entitiesRoutes,
};
}
export type InventoryServerRouteRepository = ReturnType<
typeof getGlobalInventoryServerRouteRepository
>;

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 type { CoreSetup } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { registerRoutes } from '@kbn/server-route-repository';
import { getGlobalInventoryServerRouteRepository } from './get_global_inventory_route_repository';
import type { InventoryRouteHandlerResources } from './types';
export function registerServerRoutes({
core,
logger,
dependencies,
}: {
core: CoreSetup;
logger: Logger;
dependencies: Omit<InventoryRouteHandlerResources, 'request' | 'context' | 'logger' | 'params'>;
}) {
registerRoutes({
core,
logger,
repository: getGlobalInventoryServerRouteRepository(),
dependencies,
});
}

View file

@ -0,0 +1,39 @@
/*
* 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 { CustomRequestHandlerContext, KibanaRequest } from '@kbn/core/server';
import type { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server/types';
import type { Logger } from '@kbn/logging';
import type { InventorySetupDependencies, InventoryStartDependencies } from '../types';
export type InventoryRequestHandlerContext = CustomRequestHandlerContext<{
licensing: Pick<LicensingApiRequestHandlerContext, 'license' | 'featureUsage'>;
}>;
export interface InventoryRouteHandlerResources {
request: KibanaRequest;
context: InventoryRequestHandlerContext;
logger: Logger;
plugins: {
[key in keyof InventorySetupDependencies]: {
setup: Required<InventorySetupDependencies>[key];
};
} & {
[key in keyof InventoryStartDependencies]: {
start: () => Promise<Required<InventoryStartDependencies>[key]>;
};
};
}
export interface InventoryRouteCreateOptions {
options: {
timeout?: {
idleSocket?: number;
};
tags: Array<'access:inventory'>;
};
}

View file

@ -0,0 +1,36 @@
/*
* 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 {
EntityManagerServerPluginStart,
EntityManagerServerPluginSetup,
} from '@kbn/entityManager-plugin/server';
import type { InferenceServerSetup, InferenceServerStart } from '@kbn/inference-plugin/server';
import type {
DataViewsServerPluginSetup,
DataViewsServerPluginStart,
} from '@kbn/data-views-plugin/server';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ConfigSchema {}
export interface InventorySetupDependencies {
entityManager: EntityManagerServerPluginSetup;
inference: InferenceServerSetup;
dataViews: DataViewsServerPluginSetup;
}
export interface InventoryStartDependencies {
entityManager: EntityManagerServerPluginStart;
inference: InferenceServerStart;
dataViews: DataViewsServerPluginStart;
}
export interface InventoryServerSetup {}
export interface InventoryClient {}
export interface InventoryServerStart {}

View file

@ -0,0 +1,39 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"../../../typings/**/*",
"common/**/*",
"public/**/*",
"typings/**/*",
"public/**/*.json",
"server/**/*",
".storybook/**/*"
],
"exclude": [
"target/**/*",
".storybook/**/*.js"
],
"kbn_references": [
"@kbn/core",
"@kbn/logging",
"@kbn/config-schema",
"@kbn/observability-shared-plugin",
"@kbn/server-route-repository",
"@kbn/shared-ux-link-redirect-app",
"@kbn/typed-react-router-config",
"@kbn/investigate-plugin",
"@kbn/observability-utils",
"@kbn/kibana-react-plugin",
"@kbn/i18n",
"@kbn/deeplinks-observability",
"@kbn/entityManager-plugin",
"@kbn/licensing-plugin",
"@kbn/inference-plugin",
"@kbn/data-views-plugin",
"@kbn/server-route-repository-client",
"@kbn/react-kibana-context-render",
]
}

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