From 63da7701e7e4d61254d31791d7856b3019bd6802 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 25 Nov 2024 14:51:24 +0100 Subject: [PATCH] [Streams] App plugin (#200060) Creates the Streams app plugin, which renders UI for managing streams (see https://github.com/elastic/kibana/pull/198713). Additional changes in this PR: - The menus were updated to conditionally add a link to the Streams app. The Streams plugin itself returns a status$ observable which signals if Streams have been enabled. This value is used to conditionally render the link in the various flavors of menus. - There's a small change in the ES types to allow for ordered params in ES|QL (vs named params) - `@kbn/server-route-repository` was updated to be able to override `access` (instead of only inferring it from the endpoint name). Additionally, we now allow all route options by default. - `@kbn/typed-react-router-config` now also exports a `useBreadcrumbs`. This was copied over from the APM implementation. - the signature of the `esql` method in `ObservabilityElasticsearchClient` was updated to separate processing options from options that are sent over to the _query endpoint. --------- Co-authored-by: Chris Cowan Co-authored-by: Joe Reuter Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .eslintrc.js | 5 +- .github/CODEOWNERS | 1 + docs/developer/plugin-list.asciidoc | 4 + package.json | 1 + packages/deeplinks/observability/constants.ts | 2 + .../deeplinks/observability/deep_links.ts | 13 +- packages/kbn-es-types/src/search.ts | 2 +- packages/kbn-optimizer/limits.yml | 1 + .../src/create_repository_client.ts | 4 +- .../index.ts | 1 - .../src/parse_endpoint.ts | 12 +- .../src/typings.ts | 95 ++- packages/kbn-server-route-repository/index.ts | 1 - .../src/create_server_route_factory.ts | 9 +- .../src/register_routes.test.ts | 65 +- .../src/register_routes.ts | 32 +- .../src/test_types.ts | 24 +- .../kbn-typed-react-router-config/index.ts | 1 + .../kibana.jsonc | 2 +- .../src/breadcrumbs/breadcrumb.tsx | 51 ++ .../src/breadcrumbs/context.tsx | 113 +++ .../create_router_breadcrumb_component.tsx | 17 + .../src/breadcrumbs/index.tsx | 12 + .../src/breadcrumbs/use_breadcrumbs.ts | 101 +++ .../src/breadcrumbs/use_router_breadcrumb.ts | 53 ++ .../src/create_router.ts | 8 +- .../src/types/index.ts | 6 + .../src/use_router.tsx | 2 +- .../tsconfig.json | 7 +- packages/kbn-utility-types/src/dot.ts | 4 +- .../src/tsd_tests/test_d/dot.ts | 52 +- .../collectors/application_usage/schema.ts | 1 + src/plugins/telemetry/schema/oss_plugins.json | 131 +++ tsconfig.base.json | 2 + x-pack/.i18nrc.json | 7 +- .../object/unflatten_object.test.ts | 12 + .../object/unflatten_object.ts | 11 +- .../observability_utils_common/tsconfig.json | 1 + .../client/create_observability_es_client.ts | 135 +++- .../es/esql_result_to_plain_objects.test.ts | 35 +- .../es/esql_result_to_plain_objects.ts | 34 +- .../observability_utils_server/tsconfig.json | 2 + .../server/routes/enablement/check.ts | 12 +- .../server/routes/enablement/disable.ts | 12 +- .../server/routes/enablement/enable.ts | 12 +- .../server/routes/entities/create.ts | 12 +- .../server/routes/entities/delete.ts | 12 +- .../server/routes/entities/get.ts | 12 +- .../server/routes/entities/reset.ts | 12 +- .../server/routes/entities/update.ts | 12 +- .../apm_routes/register_apm_server_routes.ts | 10 +- .../apm/server/routes/typings.ts | 28 +- .../server/routes/register_routes.ts | 6 +- .../dataset_quality/server/routes/types.ts | 4 +- .../routes/entities/get_latest_entity.ts | 21 +- .../routes/entities/get_entity_groups.ts | 16 +- .../routes/entities/get_entity_types.ts | 20 +- .../routes/entities/get_latest_entities.ts | 31 +- .../server/routes/has_data/get_has_data.ts | 12 +- .../inventory/server/routes/types.ts | 8 +- .../investigate_app/server/routes/types.ts | 7 +- .../observability/kibana.jsonc | 5 +- .../observability/public/navigation_tree.ts | 15 +- .../observability/public/plugin.ts | 3 + .../observability/server/plugin.ts | 1 - .../server/routes/register_routes.ts | 99 +-- .../observability/server/routes/types.ts | 8 +- .../observability/tsconfig.json | 1 + .../server/functions/context.ts | 2 +- .../server/plugin.ts | 15 +- .../server/routes/chat/route.ts | 2 +- .../server/routes/register_routes.ts | 4 +- .../server/routes/types.ts | 71 +- .../observability_ai_assistant/tsconfig.json | 1 + .../server/plugin.ts | 19 +- .../server/rule_connector/index.test.ts | 12 +- .../server/rule_connector/index.ts | 2 +- .../observability_onboarding/server/plugin.ts | 24 +- .../server/routes/register_routes.ts | 126 --- .../server/routes/types.ts | 6 +- .../observability_shared/kibana.jsonc | 2 +- .../slo/server/routes/register_routes.ts | 3 +- .../observability_solution/slo/tsconfig.json | 1 - .../serverless_observability/kibana.jsonc | 6 +- .../public/navigation_tree.ts | 745 +++++++++--------- .../serverless_observability/public/plugin.ts | 10 +- .../serverless_observability/public/types.ts | 11 +- .../serverless_observability/tsconfig.json | 1 + x-pack/plugins/streams/common/index.ts | 8 + x-pack/plugins/streams/public/api/index.ts | 52 ++ x-pack/plugins/streams/public/index.ts | 7 +- x-pack/plugins/streams/public/plugin.ts | 38 +- x-pack/plugins/streams/public/types.ts | 15 +- x-pack/plugins/streams/server/index.ts | 2 + .../streams/server/lib/streams/stream_crud.ts | 76 +- x-pack/plugins/streams/server/plugin.ts | 7 +- .../streams/server/routes/esql/route.ts | 67 ++ x-pack/plugins/streams/server/routes/index.ts | 10 +- .../streams/server/routes/streams/delete.ts | 42 +- .../streams/server/routes/streams/disable.ts | 44 ++ .../streams/server/routes/streams/edit.ts | 28 +- .../streams/server/routes/streams/enable.ts | 33 +- .../streams/server/routes/streams/fork.ts | 35 +- .../streams/server/routes/streams/list.ts | 81 +- .../streams/server/routes/streams/read.ts | 41 +- .../streams/server/routes/streams/resync.ts | 33 +- .../streams/server/routes/streams/settings.ts | 34 + x-pack/plugins/streams/tsconfig.json | 3 + .../get_mock_streams_app_context.tsx | 36 + .../streams_app/.storybook/jest_setup.js | 11 + x-pack/plugins/streams_app/.storybook/main.js | 8 + .../plugins/streams_app/.storybook/preview.js | 13 + .../.storybook/storybook_decorator.tsx | 18 + x-pack/plugins/streams_app/README.md | 3 + .../streams_app/common/entity_source_query.ts | 14 + x-pack/plugins/streams_app/common/index.ts | 22 + x-pack/plugins/streams_app/jest.config.js | 23 + x-pack/plugins/streams_app/kibana.jsonc | 26 + .../streams_app/public/application.tsx | 49 ++ .../public/components/app_root/index.tsx | 73 ++ .../components/entity_detail_view/index.tsx | 102 +++ .../index.tsx | 30 + .../entity_overview_tab_list/index.tsx | 32 + .../esql_chart/controlled_esql_chart.tsx | 174 ++++ .../public/components/loading_panel/index.tsx | 35 + .../public/components/not_found/index.tsx | 24 + .../public/components/redirect_to/index.tsx | 27 + .../stream_detail_overview/index.tsx | 168 ++++ .../components/stream_detail_view/index.tsx | 65 ++ .../components/stream_list_view/index.tsx | 64 ++ .../streams_app_context_provider/index.tsx | 27 + .../streams_app_page_body/index.tsx | 26 + .../streams_app_page_header/index.tsx | 35 + .../streams_app_page_header_title.tsx | 16 + .../streams_app_page_template/index.tsx | 44 ++ .../streams_app_router_breadcrumb/index.tsx | 11 + .../streams_app_search_bar/index.tsx | 78 ++ .../public/components/streams_table/index.tsx | 78 ++ .../streams_app/public/hooks/use_kibana.tsx | 36 + .../hooks/use_streams_app_breadcrumbs.ts | 11 + .../public/hooks/use_streams_app_fetch.ts | 73 ++ .../public/hooks/use_streams_app_params.ts | 14 + .../hooks/use_streams_app_route_path.ts | 15 + .../public/hooks/use_streams_app_router.ts | 55 ++ x-pack/plugins/streams_app/public/index.ts | 26 + x-pack/plugins/streams_app/public/plugin.ts | 139 ++++ .../streams_app/public/routes/config.tsx | 68 ++ .../streams_app/public/services/types.ts | 9 + x-pack/plugins/streams_app/public/types.ts | 43 + .../public/util/esql_result_to_timeseries.ts | 99 +++ x-pack/plugins/streams_app/server/config.ts | 14 + x-pack/plugins/streams_app/server/index.ts | 35 + x-pack/plugins/streams_app/server/plugin.ts | 42 + x-pack/plugins/streams_app/server/types.ts | 23 + x-pack/plugins/streams_app/tsconfig.json | 37 + yarn.lock | 4 + 156 files changed, 4098 insertions(+), 1159 deletions(-) create mode 100644 packages/kbn-typed-react-router-config/src/breadcrumbs/breadcrumb.tsx create mode 100644 packages/kbn-typed-react-router-config/src/breadcrumbs/context.tsx create mode 100644 packages/kbn-typed-react-router-config/src/breadcrumbs/create_router_breadcrumb_component.tsx create mode 100644 packages/kbn-typed-react-router-config/src/breadcrumbs/index.tsx create mode 100644 packages/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts create mode 100644 packages/kbn-typed-react-router-config/src/breadcrumbs/use_router_breadcrumb.ts delete mode 100644 x-pack/plugins/observability_solution/observability_onboarding/server/routes/register_routes.ts create mode 100644 x-pack/plugins/streams/common/index.ts create mode 100644 x-pack/plugins/streams/public/api/index.ts create mode 100644 x-pack/plugins/streams/server/routes/esql/route.ts create mode 100644 x-pack/plugins/streams/server/routes/streams/disable.ts create mode 100644 x-pack/plugins/streams/server/routes/streams/settings.ts create mode 100644 x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx create mode 100644 x-pack/plugins/streams_app/.storybook/jest_setup.js create mode 100644 x-pack/plugins/streams_app/.storybook/main.js create mode 100644 x-pack/plugins/streams_app/.storybook/preview.js create mode 100644 x-pack/plugins/streams_app/.storybook/storybook_decorator.tsx create mode 100644 x-pack/plugins/streams_app/README.md create mode 100644 x-pack/plugins/streams_app/common/entity_source_query.ts create mode 100644 x-pack/plugins/streams_app/common/index.ts create mode 100644 x-pack/plugins/streams_app/jest.config.js create mode 100644 x-pack/plugins/streams_app/kibana.jsonc create mode 100644 x-pack/plugins/streams_app/public/application.tsx create mode 100644 x-pack/plugins/streams_app/public/components/app_root/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/entity_detail_view_header_section/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/entity_overview_tab_list/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/esql_chart/controlled_esql_chart.tsx create mode 100644 x-pack/plugins/streams_app/public/components/loading_panel/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/not_found/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/redirect_to/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/stream_detail_overview/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/stream_list_view/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_context_provider/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_page_header/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_page_header/streams_app_page_header_title.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_router_breadcrumb/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx create mode 100644 x-pack/plugins/streams_app/public/components/streams_table/index.tsx create mode 100644 x-pack/plugins/streams_app/public/hooks/use_kibana.tsx create mode 100644 x-pack/plugins/streams_app/public/hooks/use_streams_app_breadcrumbs.ts create mode 100644 x-pack/plugins/streams_app/public/hooks/use_streams_app_fetch.ts create mode 100644 x-pack/plugins/streams_app/public/hooks/use_streams_app_params.ts create mode 100644 x-pack/plugins/streams_app/public/hooks/use_streams_app_route_path.ts create mode 100644 x-pack/plugins/streams_app/public/hooks/use_streams_app_router.ts create mode 100644 x-pack/plugins/streams_app/public/index.ts create mode 100644 x-pack/plugins/streams_app/public/plugin.ts create mode 100644 x-pack/plugins/streams_app/public/routes/config.tsx create mode 100644 x-pack/plugins/streams_app/public/services/types.ts create mode 100644 x-pack/plugins/streams_app/public/types.ts create mode 100644 x-pack/plugins/streams_app/public/util/esql_result_to_timeseries.ts create mode 100644 x-pack/plugins/streams_app/server/config.ts create mode 100644 x-pack/plugins/streams_app/server/index.ts create mode 100644 x-pack/plugins/streams_app/server/plugin.ts create mode 100644 x-pack/plugins/streams_app/server/types.ts create mode 100644 x-pack/plugins/streams_app/tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js index 097080753161..7ff37b0c9fd9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -952,6 +952,7 @@ module.exports = { { files: [ 'x-pack/plugins/observability_solution/**/*.{ts,tsx}', + 'x-pack/plugins/{streams,streams_app}/**/*.{ts,tsx}', 'x-pack/packages/observability/**/*.{ts,tsx}', ], rules: { @@ -959,7 +960,7 @@ module.exports = { 'error', { additionalHooks: - '^(useAbortableAsync|useMemoWithAbortSignal|useFetcher|useProgressiveFetcher|useBreadcrumb|useAsync|useTimeRangeAsync|useAutoAbortedHttpClient)$', + '^(useAbortableAsync|useMemoWithAbortSignal|useFetcher|useProgressiveFetcher|useBreadcrumb|useAsync|useTimeRangeAsync|useAutoAbortedHttpClient|use.*Fetch)$', }, ], }, @@ -968,6 +969,7 @@ module.exports = { files: [ 'x-pack/plugins/aiops/**/*.tsx', 'x-pack/plugins/observability_solution/**/*.tsx', + 'x-pack/plugins/{streams,streams_app}/**/*.{ts,tsx}', 'src/plugins/ai_assistant_management/**/*.tsx', 'x-pack/packages/observability/**/*.{ts,tsx}', ], @@ -984,6 +986,7 @@ module.exports = { { files: [ 'x-pack/plugins/observability_solution/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', + 'x-pack/plugins/{streams,streams_app}/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'src/plugins/ai_assistant_management/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', 'x-pack/packages/observability/logs_overview/**/!(*.stories.tsx|*.test.tsx|*.storybook_decorator.tsx|*.mock.tsx)', ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b56729efdee0..2ef66f43460c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -978,6 +978,7 @@ x-pack/plugins/spaces @elastic/kibana-security x-pack/plugins/stack_alerts @elastic/response-ops x-pack/plugins/stack_connectors @elastic/response-ops x-pack/plugins/streams @simianhacker @flash1293 @dgieselaar +x-pack/plugins/streams_app @simianhacker @flash1293 @dgieselaar x-pack/plugins/task_manager @elastic/response-ops x-pack/plugins/telemetry_collection_xpack @elastic/kibana-core x-pack/plugins/threat_intelligence @elastic/security-threat-hunting-investigations diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index efe65e4a9954..8e8ee80ff81b 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -917,6 +917,10 @@ routes, etc. |This plugin provides an interface to manage streams +|{kib-repo}blob/{branch}/x-pack/plugins/streams_app/README.md[streamsApp] +|Home of the Streams app plugin, which allows users to manage Streams via the UI. + + |{kib-repo}blob/{branch}/x-pack/plugins/observability_solution/synthetics/README.md[synthetics] |The purpose of this plugin is to provide users of Heartbeat more visibility of what's happening in their infrastructure. diff --git a/package.json b/package.json index 5c91eb069491..a36f44f30086 100644 --- a/package.json +++ b/package.json @@ -937,6 +937,7 @@ "@kbn/status-plugin-a-plugin": "link:test/server_integration/plugins/status_plugin_a", "@kbn/status-plugin-b-plugin": "link:test/server_integration/plugins/status_plugin_b", "@kbn/std": "link:packages/kbn-std", + "@kbn/streams-app-plugin": "link:x-pack/plugins/streams_app", "@kbn/streams-plugin": "link:x-pack/plugins/streams", "@kbn/synthetics-plugin": "link:x-pack/plugins/observability_solution/synthetics", "@kbn/synthetics-private-location": "link:x-pack/packages/kbn-synthetics-private-location", diff --git a/packages/deeplinks/observability/constants.ts b/packages/deeplinks/observability/constants.ts index 25642ba69613..3dcc009481d0 100644 --- a/packages/deeplinks/observability/constants.ts +++ b/packages/deeplinks/observability/constants.ts @@ -35,3 +35,5 @@ export const OBLT_UX_APP_ID = 'ux'; export const OBLT_PROFILING_APP_ID = 'profiling'; export const INVENTORY_APP_ID = 'inventory'; + +export const STREAMS_APP_ID = 'streams'; diff --git a/packages/deeplinks/observability/deep_links.ts b/packages/deeplinks/observability/deep_links.ts index 1253b4e889fc..256350feb2e2 100644 --- a/packages/deeplinks/observability/deep_links.ts +++ b/packages/deeplinks/observability/deep_links.ts @@ -21,6 +21,7 @@ import { OBLT_UX_APP_ID, OBLT_PROFILING_APP_ID, INVENTORY_APP_ID, + STREAMS_APP_ID, } from './constants'; type LogsApp = typeof LOGS_APP_ID; @@ -36,6 +37,7 @@ 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; +type StreamsApp = typeof STREAMS_APP_ID; export type AppId = | LogsApp @@ -50,7 +52,8 @@ export type AppId = | AiAssistantApp | ObltUxApp | ObltProfilingApp - | InventoryApp; + | InventoryApp + | StreamsApp; export type LogsLinkId = 'log-categories' | 'settings' | 'anomalies' | 'stream'; @@ -83,13 +86,16 @@ export type SyntheticsLinkId = 'certificates' | 'overview'; export type ProfilingLinkId = 'stacktraces' | 'flamegraphs' | 'functions'; +export type StreamsLinkId = 'overview'; + export type LinkId = | LogsLinkId | ObservabilityOverviewLinkId | MetricsLinkId | ApmLinkId | SyntheticsLinkId - | ProfilingLinkId; + | ProfilingLinkId + | StreamsLinkId; export type DeepLinkId = | AppId @@ -99,4 +105,5 @@ export type DeepLinkId = | `${ApmApp}:${ApmLinkId}` | `${SyntheticsApp}:${SyntheticsLinkId}` | `${ObltProfilingApp}:${ProfilingLinkId}` - | `${InventoryApp}:${InventoryLinkId}`; + | `${InventoryApp}:${InventoryLinkId}` + | `${StreamsApp}:${StreamsLinkId}`; diff --git a/packages/kbn-es-types/src/search.ts b/packages/kbn-es-types/src/search.ts index 87f9dd15517c..d3675e04c266 100644 --- a/packages/kbn-es-types/src/search.ts +++ b/packages/kbn-es-types/src/search.ts @@ -695,5 +695,5 @@ export interface ESQLSearchParams { locale?: string; include_ccs_metadata?: boolean; dropNullColumns?: boolean; - params?: Array>; + params?: estypesWithoutBodyKey.ScalarValue[] | Array>; } diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 34355f36541c..caa5c3b53d19 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -163,6 +163,7 @@ pageLoadAssetSize: stackAlerts: 58316 stackConnectors: 67227 streams: 16742 + streamsApp: 20537 synthetics: 55971 telemetry: 51957 telemetryManagementSection: 38586 diff --git a/packages/kbn-server-route-repository-client/src/create_repository_client.ts b/packages/kbn-server-route-repository-client/src/create_repository_client.ts index 015db2b9948d..325f484103d0 100644 --- a/packages/kbn-server-route-repository-client/src/create_repository_client.ts +++ b/packages/kbn-server-route-repository-client/src/create_repository_client.ts @@ -15,12 +15,12 @@ import { } 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 { HttpFetchQuery, HttpResponse } from '@kbn/core-http-browser'; import { omit } from 'lodash'; export function createRepositoryClient< TRepository extends ServerRouteRepository, - TClientOptions extends HttpFetchOptions = {} + TClientOptions extends Record = {} >(core: CoreStart | CoreSetup): RouteRepositoryClient { const fetch = ( endpoint: string, diff --git a/packages/kbn-server-route-repository-utils/index.ts b/packages/kbn-server-route-repository-utils/index.ts index 14322f9b64c8..a1e3ec45bd6f 100644 --- a/packages/kbn-server-route-repository-utils/index.ts +++ b/packages/kbn-server-route-repository-utils/index.ts @@ -18,7 +18,6 @@ export type { EndpointOf, ReturnOf, RouteRepositoryClient, - RouteState, ClientRequestParamsOf, DecodedRequestParamsOf, ServerRouteRepository, diff --git a/packages/kbn-server-route-repository-utils/src/parse_endpoint.ts b/packages/kbn-server-route-repository-utils/src/parse_endpoint.ts index e16590c9a666..b81b3285ec8f 100644 --- a/packages/kbn-server-route-repository-utils/src/parse_endpoint.ts +++ b/packages/kbn-server-route-repository-utils/src/parse_endpoint.ts @@ -7,22 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; +import type { RouteMethod } from '@kbn/core-http-server'; + +const validMethods: RouteMethod[] = ['delete', 'get', 'patch', 'post', 'put']; export function parseEndpoint(endpoint: string) { const parts = endpoint.split(' '); - const method = parts[0].trim().toLowerCase() as Method; + const method = parts[0].trim().toLowerCase() as Exclude; const pathname = parts[1].trim(); const version = parts[2]?.trim(); - if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) { + if (!validMethods.includes(method)) { throw new Error(`Endpoint ${endpoint} was not prefixed with a valid HTTP method`); } - if (!version && pathname.startsWith('/api')) { - throw new Error(`Missing version for public endpoint ${endpoint}`); - } - return { method, pathname, version }; } diff --git a/packages/kbn-server-route-repository-utils/src/typings.ts b/packages/kbn-server-route-repository-utils/src/typings.ts index fc4fbe8ab6da..5db4a87b8b32 100644 --- a/packages/kbn-server-route-repository-utils/src/typings.ts +++ b/packages/kbn-server-route-repository-utils/src/typings.ts @@ -8,14 +8,13 @@ */ import type { HttpFetchOptions } from '@kbn/core-http-browser'; -import type { IKibanaResponse } from '@kbn/core-http-server'; +import type { IKibanaResponse, RouteAccess, RouteSecurity } from '@kbn/core-http-server'; import type { KibanaRequest, KibanaResponseFactory, Logger, RequestHandlerContext, RouteConfigOptions, - RouteSecurity, RouteMethod, } from '@kbn/core/server'; import type { ServerSentEvent } from '@kbn/sse-utils'; @@ -23,7 +22,7 @@ import { z } from '@kbn/zod'; import * as t from 'io-ts'; import { Observable } from 'rxjs'; import { Readable } from 'stream'; -import { RequiredKeys, ValuesType } from 'utility-types'; +import { Required, RequiredKeys, ValuesType } from 'utility-types'; type MaybeOptional }> = RequiredKeys< T['params'] @@ -51,24 +50,37 @@ export type ZodParamsObject = z.ZodObject<{ export type IoTsParamsObject = WithoutIncompatibleMethods>; export type RouteParamsRT = IoTsParamsObject | ZodParamsObject; +export type ServerRouteHandlerResources = Record; -export interface RouteState { - [endpoint: string]: ServerRoute; +export interface ServerRouteCreateOptions { + [x: string]: any; } -export type ServerRouteHandlerResources = Record; -export type ServerRouteCreateOptions = Record; +type RouteMethodOf = TEndpoint extends `${infer TRouteMethod} ${string}` + ? Lowercase extends RouteMethod + ? Lowercase + : never + : never; -type ValidateEndpoint = string extends TEndpoint +type IsPublicEndpoint< + TEndpoint extends string, + TRouteAccess extends RouteAccess | undefined +> = TRouteAccess extends 'public' ? true - : TEndpoint extends `${string} ${string} ${string}` + : TRouteAccess extends 'internal' + ? false + : TEndpoint extends `${string} /api${string}` ? true - : TEndpoint extends `${string} ${infer TPathname}` - ? TPathname extends `/internal/${string}` - ? true - : false : false; +type IsVersionSpecified = + TEndpoint extends `${string} ${string} ${string}` ? true : false; + +type ValidateEndpoint< + TEndpoint extends string, + TRouteAccess extends RouteAccess | undefined +> = IsPublicEndpoint extends true ? IsVersionSpecified : true; + type IsAny = 1 | 0 extends (T extends never ? 1 : 0) ? true : false; // this ensures only plain objects can be returned, if it's not one @@ -128,17 +140,27 @@ type ServerRouteHandler< export type CreateServerRouteFactory< TRouteHandlerResources extends ServerRouteHandlerResources, - TRouteCreateOptions extends ServerRouteCreateOptions + TRouteCreateOptions extends DefaultRouteCreateOptions | undefined > = < TEndpoint extends string, TReturnType extends ServerRouteHandlerReturnType, - TRouteParamsRT extends RouteParamsRT | undefined = undefined + TRouteParamsRT extends RouteParamsRT | undefined = undefined, + TRouteAccess extends RouteAccess | undefined = undefined >( options: { - endpoint: ValidateEndpoint extends true ? TEndpoint : never; + endpoint: ValidateEndpoint extends true ? TEndpoint : never; handler: ServerRouteHandler; params?: TRouteParamsRT; - } & TRouteCreateOptions + security?: RouteSecurity; + } & Required< + { + options?: (TRouteCreateOptions extends DefaultRouteCreateOptions ? TRouteCreateOptions : {}) & + RouteConfigOptions> & { + access?: TRouteAccess; + }; + }, + RequiredKeys extends never ? never : 'options' + > ) => Record< TEndpoint, ServerRoute< @@ -155,16 +177,17 @@ export type ServerRoute< TRouteParamsRT extends RouteParamsRT | undefined, TRouteHandlerResources extends ServerRouteHandlerResources, TReturnType extends ServerRouteHandlerReturnType, - TRouteCreateOptions extends ServerRouteCreateOptions + TRouteCreateOptions extends DefaultRouteCreateOptions | undefined > = { endpoint: TEndpoint; handler: ServerRouteHandler; -} & TRouteCreateOptions & - (TRouteParamsRT extends RouteParamsRT ? { params: TRouteParamsRT } : {}); + security?: RouteSecurity; +} & (TRouteParamsRT extends RouteParamsRT ? { params: TRouteParamsRT } : {}) & + (TRouteCreateOptions extends DefaultRouteCreateOptions ? { options: TRouteCreateOptions } : {}); export type ServerRouteRepository = Record< string, - ServerRoute> + ServerRoute >; type ClientRequestParamsOfType = @@ -195,13 +218,7 @@ export type EndpointOf = export type ReturnOf< TServerRouteRepository extends ServerRouteRepository, TEndpoint extends keyof TServerRouteRepository -> = TServerRouteRepository[TEndpoint] extends ServerRoute< - any, - any, - any, - infer TReturnType, - ServerRouteCreateOptions -> +> = TServerRouteRepository[TEndpoint] extends ServerRoute ? TReturnType extends IKibanaResponse ? TWrappedResponseType : TReturnType @@ -210,13 +227,7 @@ export type ReturnOf< export type DecodedRequestParamsOf< TServerRouteRepository extends ServerRouteRepository, TEndpoint extends keyof TServerRouteRepository -> = TServerRouteRepository[TEndpoint] extends ServerRoute< - any, - infer TRouteParamsRT, - any, - any, - ServerRouteCreateOptions -> +> = TServerRouteRepository[TEndpoint] extends ServerRoute ? TRouteParamsRT extends RouteParamsRT ? DecodedRequestParamsOfType : {} @@ -230,7 +241,7 @@ export type ClientRequestParamsOf< infer TRouteParamsRT, any, any, - ServerRouteCreateOptions + ServerRouteCreateOptions | undefined > ? TRouteParamsRT extends RouteParamsRT ? ClientRequestParamsOfType @@ -250,13 +261,17 @@ export interface RouteRepositoryClient< fetch>( endpoint: TEndpoint, ...args: MaybeOptionalArgs< - ClientRequestParamsOf & TAdditionalClientOptions + ClientRequestParamsOf & + TAdditionalClientOptions & + HttpFetchOptions > ): Promise>; stream>( endpoint: TEndpoint, ...args: MaybeOptionalArgs< - ClientRequestParamsOf & TAdditionalClientOptions + ClientRequestParamsOf & + TAdditionalClientOptions & + HttpFetchOptions > ): ReturnOf extends Observable ? TReturnType extends ServerSentEvent @@ -277,6 +292,4 @@ export interface DefaultRouteHandlerResources extends CoreRouteHandlerResources logger: Logger; } -export interface DefaultRouteCreateOptions { - options?: RouteConfigOptions & { security?: RouteSecurity }; -} +export type DefaultRouteCreateOptions = RouteConfigOptions>; diff --git a/packages/kbn-server-route-repository/index.ts b/packages/kbn-server-route-repository/index.ts index a922c2ebb1e1..16958e744417 100644 --- a/packages/kbn-server-route-repository/index.ts +++ b/packages/kbn-server-route-repository/index.ts @@ -23,7 +23,6 @@ export type { ServerRouteRepository, ServerRoute, RouteParamsRT, - RouteState, DefaultRouteCreateOptions, DefaultRouteHandlerResources, IoTsParamsObject, diff --git a/packages/kbn-server-route-repository/src/create_server_route_factory.ts b/packages/kbn-server-route-repository/src/create_server_route_factory.ts index be375bd06948..34fc375b0d9a 100644 --- a/packages/kbn-server-route-repository/src/create_server_route_factory.ts +++ b/packages/kbn-server-route-repository/src/create_server_route_factory.ts @@ -8,16 +8,17 @@ */ import type { - DefaultRouteCreateOptions, DefaultRouteHandlerResources, - ServerRouteCreateOptions, ServerRouteHandlerResources, } from '@kbn/server-route-repository-utils'; -import type { CreateServerRouteFactory } from '@kbn/server-route-repository-utils/src/typings'; +import type { + CreateServerRouteFactory, + DefaultRouteCreateOptions, +} from '@kbn/server-route-repository-utils/src/typings'; export function createServerRouteFactory< TRouteHandlerResources extends ServerRouteHandlerResources = DefaultRouteHandlerResources, - TRouteCreateOptions extends ServerRouteCreateOptions = DefaultRouteCreateOptions + TRouteCreateOptions extends DefaultRouteCreateOptions | undefined = undefined >(): CreateServerRouteFactory { return (route) => ({ [route.endpoint]: route } as any); } diff --git a/packages/kbn-server-route-repository/src/register_routes.test.ts b/packages/kbn-server-route-repository/src/register_routes.test.ts index 249f81df3a8a..b13592c57ba5 100644 --- a/packages/kbn-server-route-repository/src/register_routes.test.ts +++ b/packages/kbn-server-route-repository/src/register_routes.test.ts @@ -15,6 +15,7 @@ import { NEVER } from 'rxjs'; import * as makeZodValidationObject from './make_zod_validation_object'; import { registerRoutes } from './register_routes'; import { passThroughValidationObject, noParamsValidationObject } from './validation_objects'; +import { ServerRouteRepository } from '@kbn/server-route-repository-utils'; describe('registerRoutes', () => { const post = jest.fn(); @@ -54,44 +55,82 @@ describe('registerRoutes', () => { 'POST /internal/route': { endpoint: 'POST /internal/route', handler: jest.fn(), - options: { - internal: true, - }, }, 'POST /api/public_route version': { endpoint: 'POST /api/public_route version', handler: jest.fn(), + }, + 'POST /api/internal_but_looks_like_public version': { + endpoint: 'POST /api/internal_but_looks_like_public version', options: { - public: true, + access: 'internal', }, + handler: jest.fn(), }, - }); + 'POST /internal/route_with_security': { + endpoint: `POST /internal/route_with_security`, + handler: jest.fn(), + security: { + authz: { + enabled: false, + reason: 'whatever', + }, + }, + }, + 'POST /api/route_with_security version': { + endpoint: `POST /api/route_with_security version`, + handler: jest.fn(), + security: { + authz: { + enabled: false, + reason: 'whatever', + }, + }, + }, + } satisfies ServerRouteRepository); expect(createRouter).toHaveBeenCalledTimes(1); - expect(post).toHaveBeenCalledTimes(1); - const [internalRoute] = post.mock.calls[0]; expect(internalRoute.path).toEqual('/internal/route'); expect(internalRoute.options).toEqual({ - internal: true, + access: 'internal', }); expect(internalRoute.validate).toEqual(noParamsValidationObject); - expect(postWithVersion).toHaveBeenCalledTimes(1); + const [internalRouteWithSecurity] = post.mock.calls[1]; + + expect(internalRouteWithSecurity.path).toEqual('/internal/route_with_security'); + expect(internalRouteWithSecurity.security).toEqual({ + authz: { + enabled: false, + reason: 'whatever', + }, + }); + const [publicRoute] = postWithVersion.mock.calls[0]; expect(publicRoute.path).toEqual('/api/public_route'); - expect(publicRoute.options).toEqual({ - public: true, - }); expect(publicRoute.access).toEqual('public'); - expect(postAddVersion).toHaveBeenCalledTimes(1); + const [apiInternalRoute] = postWithVersion.mock.calls[1]; + expect(apiInternalRoute.path).toEqual('/api/internal_but_looks_like_public'); + expect(apiInternalRoute.access).toEqual('internal'); + const [versionedRoute] = postAddVersion.mock.calls[0]; expect(versionedRoute.version).toEqual('version'); expect(versionedRoute.validate).toEqual({ request: noParamsValidationObject, }); + + const [publicRouteWithSecurity] = postWithVersion.mock.calls[2]; + + expect(publicRouteWithSecurity.path).toEqual('/api/route_with_security'); + expect(publicRouteWithSecurity.security).toEqual({ + authz: { + enabled: false, + reason: 'whatever', + }, + }); }); it('does not allow any params if no schema is provided', () => { diff --git a/packages/kbn-server-route-repository/src/register_routes.ts b/packages/kbn-server-route-repository/src/register_routes.ts index 6e7b0d839b2f..6201ffcd869e 100644 --- a/packages/kbn-server-route-repository/src/register_routes.ts +++ b/packages/kbn-server-route-repository/src/register_routes.ts @@ -15,19 +15,20 @@ import { isKibanaResponse } from '@kbn/core-http-server'; import type { CoreSetup } from '@kbn/core-lifecycle-server'; import type { Logger } from '@kbn/logging'; import { + DefaultRouteCreateOptions, + RouteParamsRT, ServerRoute, - ServerRouteCreateOptions, ZodParamsObject, parseEndpoint, } from '@kbn/server-route-repository-utils'; +import { ServerSentEvent } from '@kbn/sse-utils'; import { observableIntoEventSourceStream } from '@kbn/sse-utils-server'; import { isZod } from '@kbn/zod'; -import { merge } from 'lodash'; +import { merge, omit } 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'; +import { validateAndDecodeParams } from './validate_and_decode_params'; +import { noParamsValidationObject, passThroughValidationObject } from './validation_objects'; const CLIENT_CLOSED_REQUEST = { statusCode: 499, @@ -43,7 +44,7 @@ export function registerRoutes>({ dependencies, }: { core: CoreSetup; - repository: Record>; + repository: Record>; logger: Logger; dependencies: TDependencies; }) { @@ -52,7 +53,11 @@ export function registerRoutes>({ const router = core.http.createRouter(); routes.forEach((route) => { - const { params, endpoint, options, handler } = route; + const { endpoint, handler, security } = route; + + const params = 'params' in route ? route.params : undefined; + + const options: DefaultRouteCreateOptions = 'options' in route ? route.options : {}; const { method, pathname, version } = parseEndpoint(endpoint); @@ -137,14 +142,18 @@ export function registerRoutes>({ validationObject = passThroughValidationObject; } - const { security, ...restOptions } = options ?? {}; + const access = options?.access ?? (pathname.startsWith('/internal/') ? 'internal' : 'public'); if (!version) { router[method]( { path: pathname, + // @ts-expect-error we are essentially calling multiple methods at the same type so TS gets confused + options: { + ...options, + access, + }, security, - options: restOptions, validate: validationObject, }, wrappedHandler @@ -152,8 +161,9 @@ export function registerRoutes>({ } else { router.versioned[method]({ path: pathname, - access: pathname.startsWith('/internal/') ? 'internal' : 'public', - options: restOptions, + access, + // @ts-expect-error we are essentially calling multiple methods at the same type so TS gets confused + options: omit(options, 'access', 'description', 'summary', 'deprecated', 'discontinued'), security, }).addVersion( { diff --git a/packages/kbn-server-route-repository/src/test_types.ts b/packages/kbn-server-route-repository/src/test_types.ts index acef5a524eb6..6b099c158f07 100644 --- a/packages/kbn-server-route-repository/src/test_types.ts +++ b/packages/kbn-server-route-repository/src/test_types.ts @@ -83,32 +83,44 @@ createServerRouteFactory<{ context: { getSpaceId: () => string } }, {}>()({ }); // Create options are available when registering a route. -createServerRouteFactory<{}, { options: { tags: string[] } }>()({ +createServerRouteFactory<{}, {}>()({ endpoint: 'GET /internal/endpoint_with_params', params: t.type({ path: t.type({ serviceName: t.string, }), }), - options: { - tags: [], - }, handler: async (resources) => { assertType<{ params: { path: { serviceName: string } } }>(resources); }, }); // Public APIs should be versioned -createServerRouteFactory<{}, { options: { tags: string[] } }>()({ +createServerRouteFactory<{}, { tags: string[] }>()({ // @ts-expect-error + endpoint: 'GET /api/endpoint_with_params', + tags: [], + handler: async (resources) => {}, +}); + +// `access` is respected +createServerRouteFactory<{}, { tags: string[] }>()({ endpoint: 'GET /api/endpoint_with_params', options: { tags: [], + access: 'internal', }, handler: async (resources) => {}, }); -createServerRouteFactory<{}, { options: { tags: string[] } }>()({ +// specifying additional options makes them required +// @ts-expect-error +createServerRouteFactory<{}, { tags: string[] }>()({ + endpoint: 'GET /api/endpoint_with_params 2023-10-31', + handler: async (resources) => {}, +}); + +createServerRouteFactory<{}, { tags: string[] }>()({ endpoint: 'GET /api/endpoint_with_params 2023-10-31', options: { tags: [], diff --git a/packages/kbn-typed-react-router-config/index.ts b/packages/kbn-typed-react-router-config/index.ts index 3075cb48a951..6aff73c55e6c 100644 --- a/packages/kbn-typed-react-router-config/index.ts +++ b/packages/kbn-typed-react-router-config/index.ts @@ -17,3 +17,4 @@ export * from './src/use_match_routes'; export * from './src/use_params'; export * from './src/use_router'; export * from './src/use_route_path'; +export * from './src/breadcrumbs'; diff --git a/packages/kbn-typed-react-router-config/kibana.jsonc b/packages/kbn-typed-react-router-config/kibana.jsonc index 37c95108427e..2f2bda611d6b 100644 --- a/packages/kbn-typed-react-router-config/kibana.jsonc +++ b/packages/kbn-typed-react-router-config/kibana.jsonc @@ -1,5 +1,5 @@ { - "type": "shared-common", + "type": "shared-browser", "id": "@kbn/typed-react-router-config", "owner": [ "@elastic/obs-knowledge-team", diff --git a/packages/kbn-typed-react-router-config/src/breadcrumbs/breadcrumb.tsx b/packages/kbn-typed-react-router-config/src/breadcrumbs/breadcrumb.tsx new file mode 100644 index 000000000000..94a32c76c21f --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/breadcrumbs/breadcrumb.tsx @@ -0,0 +1,51 @@ +/* + * 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 React from 'react'; +import { RequiredKeys } from 'utility-types'; +import { useRouterBreadcrumb } from './use_router_breadcrumb'; +import { PathsOf, RouteMap, TypeOf } from '../types'; + +type AsParamsProps> = RequiredKeys extends never + ? {} + : { params: TObject }; + +export type RouterBreadcrumb = < + TRoutePath extends PathsOf +>({}: { + title: string; + children: React.ReactNode; + path: TRoutePath; +} & AsParamsProps>) => React.ReactElement; + +export function RouterBreadcrumb< + TRouteMap extends RouteMap, + TRoutePath extends PathsOf +>({ + title, + path, + params, + children, +}: { + title: string; + path: TRoutePath; + children: React.ReactElement; + params?: Record; +}) { + useRouterBreadcrumb( + () => ({ + title, + path, + params, + }), + [] + ); + + return children; +} diff --git a/packages/kbn-typed-react-router-config/src/breadcrumbs/context.tsx b/packages/kbn-typed-react-router-config/src/breadcrumbs/context.tsx new file mode 100644 index 000000000000..21d6a30567b1 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/breadcrumbs/context.tsx @@ -0,0 +1,113 @@ +/* + * 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 { ChromeBreadcrumb, ScopedHistory } from '@kbn/core/public'; +import { compact, isEqual } from 'lodash'; +import React, { createContext, useMemo, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useBreadcrumbs } from './use_breadcrumbs'; +import { + PathsOf, + Route, + RouteMap, + RouteMatch, + TypeAsArgs, + TypeAsParams, + TypeOf, + useMatchRoutes, + useRouter, +} from '../..'; + +export type Breadcrumb< + TRouteMap extends RouteMap = RouteMap, + TPath extends PathsOf = PathsOf +> = { + title: string; + path: TPath; +} & TypeAsParams>; + +interface BreadcrumbApi { + set>( + route: Route, + breadcrumb: Array> + ): void; + unset(route: Route): void; + getBreadcrumbs(matches: RouteMatch[]): Array>>; +} + +export const BreadcrumbsContext = createContext(undefined); + +export function BreadcrumbsContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [, forceUpdate] = useState({}); + + const breadcrumbs = useMemo(() => { + return new Map>>(); + }, []); + + const history = useHistory() as ScopedHistory; + + const router = useRouter(); + + const matches: RouteMatch[] = useMatchRoutes(); + + const api = useMemo>( + () => ({ + set(route, breadcrumb) { + if (!isEqual(breadcrumbs.get(route), breadcrumb)) { + breadcrumbs.set(route, breadcrumb); + forceUpdate({}); + } + }, + unset(route) { + if (breadcrumbs.has(route)) { + breadcrumbs.delete(route); + forceUpdate({}); + } + }, + getBreadcrumbs(currentMatches: RouteMatch[]) { + return compact( + currentMatches.flatMap((match) => { + const breadcrumb = breadcrumbs.get(match.route); + + return breadcrumb; + }) + ); + }, + }), + [breadcrumbs] + ); + + const formattedBreadcrumbs: ChromeBreadcrumb[] = api + .getBreadcrumbs(matches) + .map((breadcrumb, index, array) => { + return { + text: breadcrumb.title, + ...(index === array.length - 1 + ? {} + : { + href: history.createHref({ + pathname: router.link( + breadcrumb.path, + ...(('params' in breadcrumb ? [breadcrumb.params] : []) as TypeAsArgs< + TypeOf, false> + >) + ), + }), + }), + }; + }); + + useBreadcrumbs(formattedBreadcrumbs); + + return {children}; +} diff --git a/packages/kbn-typed-react-router-config/src/breadcrumbs/create_router_breadcrumb_component.tsx b/packages/kbn-typed-react-router-config/src/breadcrumbs/create_router_breadcrumb_component.tsx new file mode 100644 index 000000000000..04c8cf4e9d24 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/breadcrumbs/create_router_breadcrumb_component.tsx @@ -0,0 +1,17 @@ +/* + * 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 { RouteMap } from '../types'; +import { RouterBreadcrumb } from './breadcrumb'; + +export function createRouterBreadcrumbComponent< + TRouteMap extends RouteMap +>(): RouterBreadcrumb { + return RouterBreadcrumb as RouterBreadcrumb; +} diff --git a/packages/kbn-typed-react-router-config/src/breadcrumbs/index.tsx b/packages/kbn-typed-react-router-config/src/breadcrumbs/index.tsx new file mode 100644 index 000000000000..721ba433cc01 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/breadcrumbs/index.tsx @@ -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", 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 { createRouterBreadcrumbComponent } from './create_router_breadcrumb_component'; +export { createUseBreadcrumbs } from './use_router_breadcrumb'; +export { BreadcrumbsContextProvider } from './context'; diff --git a/packages/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts b/packages/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts new file mode 100644 index 000000000000..3d63c8a0f27d --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/breadcrumbs/use_breadcrumbs.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", 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 { ApplicationStart, ChromeBreadcrumb, ChromeStart } from '@kbn/core/public'; +import { MouseEvent, useEffect, useMemo } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { ChromeBreadcrumbsAppendExtension } from '@kbn/core-chrome-browser'; +import type { ServerlessPluginStart } from '@kbn/serverless/public'; + +function addClickHandlers( + breadcrumbs: ChromeBreadcrumb[], + navigateToHref?: (url: string) => Promise +) { + return breadcrumbs.map((bc) => ({ + ...bc, + ...(bc.href + ? { + onClick: (event: MouseEvent) => { + if (navigateToHref && bc.href) { + event.preventDefault(); + navigateToHref(bc.href); + } + }, + } + : {}), + })); +} + +function getTitleFromBreadCrumbs(breadcrumbs: ChromeBreadcrumb[]) { + return breadcrumbs.map(({ text }) => text?.toString() ?? '').reverse(); +} + +export const useBreadcrumbs = ( + extraCrumbs: ChromeBreadcrumb[], + options?: { + app?: { id: string; label: string }; + breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension; + serverless?: ServerlessPluginStart; + } +) => { + const { app, breadcrumbsAppendExtension, serverless } = options ?? {}; + + const { + services: { + chrome: { docTitle, setBreadcrumbs: chromeSetBreadcrumbs, setBreadcrumbsAppendExtension }, + application: { getUrlForApp, navigateToUrl }, + }, + } = useKibana<{ + application: ApplicationStart; + chrome: ChromeStart; + }>(); + + const setTitle = docTitle.change; + const appPath = getUrlForApp(app?.id ?? 'observability-overview') ?? ''; + + const setBreadcrumbs = useMemo( + () => serverless?.setBreadcrumbs ?? chromeSetBreadcrumbs, + [serverless, chromeSetBreadcrumbs] + ); + + useEffect(() => { + if (breadcrumbsAppendExtension) { + setBreadcrumbsAppendExtension(breadcrumbsAppendExtension); + } + return () => { + if (breadcrumbsAppendExtension) { + setBreadcrumbsAppendExtension(undefined); + } + }; + }, [breadcrumbsAppendExtension, setBreadcrumbsAppendExtension]); + + useEffect(() => { + const breadcrumbs = serverless + ? extraCrumbs + : [ + { + text: + app?.label ?? + i18n.translate('xpack.observabilityShared.breadcrumbs.observabilityLinkText', { + defaultMessage: 'Observability', + }), + href: appPath + '/overview', + }, + ...extraCrumbs, + ]; + + if (setBreadcrumbs) { + setBreadcrumbs(addClickHandlers(breadcrumbs, navigateToUrl)); + } + if (setTitle) { + setTitle(getTitleFromBreadCrumbs(breadcrumbs)); + } + }, [app?.label, appPath, extraCrumbs, navigateToUrl, serverless, setBreadcrumbs, setTitle]); +}; diff --git a/packages/kbn-typed-react-router-config/src/breadcrumbs/use_router_breadcrumb.ts b/packages/kbn-typed-react-router-config/src/breadcrumbs/use_router_breadcrumb.ts new file mode 100644 index 000000000000..47003cbce5a2 --- /dev/null +++ b/packages/kbn-typed-react-router-config/src/breadcrumbs/use_router_breadcrumb.ts @@ -0,0 +1,53 @@ +/* + * 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 { useContext, useEffect, useRef } from 'react'; +import { castArray } from 'lodash'; +import { PathsOf, RouteMap, useCurrentRoute } from '../..'; +import { Breadcrumb, BreadcrumbsContext } from './context'; + +type UseBreadcrumbs = >( + callback: () => Breadcrumb | Array>, + fnDeps: unknown[] +) => void; + +export function useRouterBreadcrumb(callback: () => Breadcrumb | Breadcrumb[], fnDeps: any[]) { + const api = useContext(BreadcrumbsContext); + + if (!api) { + throw new Error('Missing Breadcrumb API in context'); + } + + const { match } = useCurrentRoute(); + + const matchedRoute = useRef(match?.route); + + useEffect(() => { + if (matchedRoute.current && matchedRoute.current !== match?.route) { + api.unset(matchedRoute.current); + } + + matchedRoute.current = match?.route; + + if (matchedRoute.current) { + api.set(matchedRoute.current, castArray(callback())); + } + + return () => { + if (matchedRoute.current) { + api.unset(matchedRoute.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [matchedRoute.current, match?.route, ...fnDeps]); +} + +export function createUseBreadcrumbs(): UseBreadcrumbs { + return useRouterBreadcrumb; +} diff --git a/packages/kbn-typed-react-router-config/src/create_router.ts b/packages/kbn-typed-react-router-config/src/create_router.ts index 467fe096ffec..4321f4c0744a 100644 --- a/packages/kbn-typed-react-router-config/src/create_router.ts +++ b/packages/kbn-typed-react-router-config/src/create_router.ts @@ -11,7 +11,7 @@ import { deepExactRt, mergeRt } from '@kbn/io-ts-utils'; import { isLeft } from 'fp-ts/lib/Either'; import { Location } from 'history'; import { PathReporter } from 'io-ts/lib/PathReporter'; -import { compact, findLastIndex, merge, orderBy } from 'lodash'; +import { compact, findLastIndex, mapValues, merge, orderBy } from 'lodash'; import qs from 'query-string'; import { MatchedRoute, @@ -139,7 +139,9 @@ export function createRouter(routes: TRoutes): Router< if (route?.params) { const decoded = deepExactRt(route.params).decode( merge({}, route.defaults ?? {}, { - path: matchedRoute.match.params, + path: mapValues(matchedRoute.match.params, (value) => { + return decodeURIComponent(value); + }), query: qs.parse(location.search, { decode: true }), }) ); @@ -179,7 +181,7 @@ export function createRouter(routes: TRoutes): Router< .split('/') .map((part) => { const match = part.match(/(?:{([a-zA-Z]+)})/); - return match ? paramsWithBuiltInDefaults.path[match[1]] : part; + return match ? encodeURIComponent(paramsWithBuiltInDefaults.path[match[1]]) : part; }) .join('/'); diff --git a/packages/kbn-typed-react-router-config/src/types/index.ts b/packages/kbn-typed-react-router-config/src/types/index.ts index 3b4c36c42af5..dbab588619db 100644 --- a/packages/kbn-typed-react-router-config/src/types/index.ts +++ b/packages/kbn-typed-react-router-config/src/types/index.ts @@ -106,6 +106,12 @@ export type TypeAsArgs = keyof TObject extends never ? [TObject] | [] : [TObject]; +export type TypeAsParams = keyof TObject extends never + ? {} + : RequiredKeys extends never + ? never + : { params: TObject }; + export type FlattenRoutesOf = Array< ValuesType<{ [key in keyof MapRoutes]: ValuesType[key]>; diff --git a/packages/kbn-typed-react-router-config/src/use_router.tsx b/packages/kbn-typed-react-router-config/src/use_router.tsx index 531bc5aea53b..af92e33b8952 100644 --- a/packages/kbn-typed-react-router-config/src/use_router.tsx +++ b/packages/kbn-typed-react-router-config/src/use_router.tsx @@ -20,7 +20,7 @@ export const RouterContextProvider = ({ children: React.ReactNode; }) => {children}; -export function useRouter(): Router { +export function useRouter(): Router { const router = useContext(RouterContext); if (!router) { diff --git a/packages/kbn-typed-react-router-config/tsconfig.json b/packages/kbn-typed-react-router-config/tsconfig.json index efda70173609..f9a133f48e8d 100644 --- a/packages/kbn-typed-react-router-config/tsconfig.json +++ b/packages/kbn-typed-react-router-config/tsconfig.json @@ -14,7 +14,12 @@ ], "kbn_references": [ "@kbn/io-ts-utils", - "@kbn/shared-ux-router" + "@kbn/shared-ux-router", + "@kbn/core", + "@kbn/i18n", + "@kbn/kibana-react-plugin", + "@kbn/core-chrome-browser", + "@kbn/serverless" ], "exclude": [ "target/**/*", diff --git a/packages/kbn-utility-types/src/dot.ts b/packages/kbn-utility-types/src/dot.ts index 40af0ce14c69..54b8cb5f5ac3 100644 --- a/packages/kbn-utility-types/src/dot.ts +++ b/packages/kbn-utility-types/src/dot.ts @@ -23,7 +23,9 @@ type DedotKey< export type DedotObject> = UnionToIntersection< Exclude< ValuesType<{ - [TKey in keyof TObject]: {} extends Pick + [TKey in keyof TObject as string]: string extends TKey + ? Record + : {} extends Pick ? DeepPartial>> : DedotKey; }>, diff --git a/packages/kbn-utility-types/src/tsd_tests/test_d/dot.ts b/packages/kbn-utility-types/src/tsd_tests/test_d/dot.ts index 855b84a3cbc1..1911db2e141b 100644 --- a/packages/kbn-utility-types/src/tsd_tests/test_d/dot.ts +++ b/packages/kbn-utility-types/src/tsd_tests/test_d/dot.ts @@ -7,9 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { expectAssignable, expectNotType, expectType } from 'tsd'; import { DedotObject, DotObject } from '../../dot'; +function isAssignable(t: T) {} + interface TestA { 'my.dotted.key': string; 'my.dotted.partial.key'?: string; @@ -60,20 +61,39 @@ const dedotted1 = {} as DedotObject; const dotted1 = {} as DotObject; -expectAssignable>({} as Dedotted); -expectAssignable>({} as Dotted); -expectAssignable({} as DedotObject); -expectAssignable({} as DotObject); - -expectType(dedotted1.ym?.dotted?.partial?.key?.toString()); -expectType(dotted1['my.undotted.key'].toString()); -expectNotType(dotted1['my.partial.key']); -expectType(dotted1['my.partial.key']?.toString()); -expectNotType<{ baz: string }>({} as DedotObject); -expectNotType<{ baz: string }>({} as DotObject); -expectNotType<{ my: { dotted: { key: string }; partial: { key: number } } }>( - {} as DedotObject -); +isAssignable>({} as Dedotted); + +isAssignable>({} as Dotted); +isAssignable({} as DedotObject); +isAssignable({} as DotObject); + +isAssignable(dedotted1.ym?.dotted?.partial?.key); + +isAssignable(dotted1['my.undotted.key'].toString()); +// @ts-expect-error +isAssignable(dotted1['my.partial.key']); + +isAssignable(dotted1['my.partial.key']?.toString()); + +// @ts-expect-error +isAssignable<{ baz: string }>({} as DedotObject); +// @ts-expect-error +isAssignable<{ baz: string }>({} as DotObject); + +// @ts-expect-error +isAssignable<{ my: { dotted: { key: string }; partial: { key: number } } }, DedotObject>(); + +type WithStringKey = { + [x: string]: string; +} & { + count: number; +}; + +type WithStringKeyDedotted = DedotObject; + +isAssignable({} as WithStringKey); + +isAssignable({} as WithStringKeyDedotted); interface ObjectWithArray { span: { @@ -88,7 +108,7 @@ interface ObjectWithArray { }; } -expectType>({ +isAssignable>({ 'span.links.span.id': [''], 'span.links.trace.id': [''], }); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts index 0cc56676137e..ad2dce80fb65 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/schema.ts @@ -183,6 +183,7 @@ export const applicationUsageSchema = { */ siem: commonSchema, space_selector: commonSchema, + streams: commonSchema, uptime: commonSchema, synthetics: commonSchema, ux: commonSchema, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 44fcda4f2858..fe0f599dd6ca 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7731,6 +7731,137 @@ } } }, + "streams": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "Always `main`" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen over the last 90 days" + } + }, + "views": { + "type": "array", + "items": { + "properties": { + "appId": { + "type": "keyword", + "_meta": { + "description": "The application being tracked" + } + }, + "viewId": { + "type": "keyword", + "_meta": { + "description": "The application view being tracked" + } + }, + "clicks_total": { + "type": "long", + "_meta": { + "description": "General number of clicks in the application sub view since we started counting them" + } + }, + "clicks_7_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 7 days" + } + }, + "clicks_30_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 30 days" + } + }, + "clicks_90_days": { + "type": "long", + "_meta": { + "description": "General number of clicks in the active application sub view over the last 90 days" + } + }, + "minutes_on_screen_total": { + "type": "float", + "_meta": { + "description": "Minutes the application sub view is active and on-screen since we started counting them." + } + }, + "minutes_on_screen_7_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 7 days" + } + }, + "minutes_on_screen_30_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 30 days" + } + }, + "minutes_on_screen_90_days": { + "type": "float", + "_meta": { + "description": "Minutes the application is active and on-screen active application sub view over the last 90 days" + } + } + } + } + } + } + }, "uptime": { "properties": { "appId": { diff --git a/tsconfig.base.json b/tsconfig.base.json index e1721840d4fb..d8426dfdef12 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1848,6 +1848,8 @@ "@kbn/stdio-dev-helpers/*": ["packages/kbn-stdio-dev-helpers/*"], "@kbn/storybook": ["packages/kbn-storybook"], "@kbn/storybook/*": ["packages/kbn-storybook/*"], + "@kbn/streams-app-plugin": ["x-pack/plugins/streams_app"], + "@kbn/streams-app-plugin/*": ["x-pack/plugins/streams_app/*"], "@kbn/streams-plugin": ["x-pack/plugins/streams"], "@kbn/streams-plugin/*": ["x-pack/plugins/streams/*"], "@kbn/synthetics-e2e": ["x-pack/plugins/observability_solution/synthetics/e2e"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 7c97dcf1b722..213c3f06f34a 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -112,7 +112,9 @@ "xpack.observabilityLogsOverview": [ "packages/observability/logs_overview/src/components" ], - "xpack.osquery": ["plugins/osquery"], + "xpack.osquery": [ + "plugins/osquery" + ], "xpack.painlessLab": "plugins/painless_lab", "xpack.profiling": [ "plugins/observability_solution/profiling" @@ -148,6 +150,9 @@ "xpack.securitySolutionEss": "plugins/security_solution_ess", "xpack.securitySolutionServerless": "plugins/security_solution_serverless", "xpack.sessionView": "plugins/session_view", + "xpack.streams": [ + "plugins/streams_app" + ], "xpack.slo": "plugins/observability_solution/slo", "xpack.snapshotRestore": "plugins/snapshot_restore", "xpack.spaces": "plugins/spaces", diff --git a/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.test.ts b/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.test.ts index 22cee17bb1a6..aa25821b4ac1 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.test.ts +++ b/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.test.ts @@ -37,4 +37,16 @@ describe('unflattenObject', () => { }, }); }); + + it('handles null values correctly', () => { + expect( + unflattenObject({ + 'agent.name': null, + }) + ).toEqual({ + agent: { + name: null, + }, + }); + }); }); diff --git a/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.ts b/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.ts index 83508d5d2dbf..8a4493905f1d 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.ts +++ b/x-pack/packages/observability/observability_utils/observability_utils_common/object/unflatten_object.ts @@ -6,13 +6,17 @@ */ import { set } from '@kbn/safer-lodash-set'; +import { DedotObject } from '@kbn/utility-types'; -export function unflattenObject(source: Record, target: Record = {}) { +export function unflattenObject>( + source: T, + target: Record = {} +): DedotObject { // eslint-disable-next-line guard-for-in for (const key in source) { const val = source[key as keyof typeof source]; if (Array.isArray(val)) { - const unflattenedArray = val.map((item) => { + const unflattenedArray = val.map((item: unknown) => { if (item && typeof item === 'object' && !Array.isArray(item)) { return unflattenObject(item); } @@ -23,5 +27,6 @@ export function unflattenObject(source: Record, target: Record; } diff --git a/x-pack/packages/observability/observability_utils/observability_utils_common/tsconfig.json b/x-pack/packages/observability/observability_utils/observability_utils_common/tsconfig.json index 7954cdc946e9..e2226268918a 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_common/tsconfig.json +++ b/x-pack/packages/observability/observability_utils/observability_utils_common/tsconfig.json @@ -19,5 +19,6 @@ "@kbn/es-query", "@kbn/safer-lodash-set", "@kbn/inference-common", + "@kbn/utility-types", ] } diff --git a/x-pack/packages/observability/observability_utils/observability_utils_server/es/client/create_observability_es_client.ts b/x-pack/packages/observability/observability_utils/observability_utils_server/es/client/create_observability_es_client.ts index 78ed20a582bc..92c7b8d19e53 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_server/es/client/create_observability_es_client.ts +++ b/x-pack/packages/observability/observability_utils/observability_utils_server/es/client/create_observability_es_client.ts @@ -10,12 +10,15 @@ import type { FieldCapsRequest, FieldCapsResponse, MsearchRequest, + ScalarValue, SearchResponse, } from '@elastic/elasticsearch/lib/api/types'; import { withSpan } from '@kbn/apm-utils'; import type { ElasticsearchClient, Logger } from '@kbn/core/server'; -import type { ESQLSearchResponse, ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; -import { Required } from 'utility-types'; +import type { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; +import { Required, ValuesType } from 'utility-types'; +import { DedotObject } from '@kbn/utility-types'; +import { unflattenObject } from '@kbn/task-manager-plugin/server/metrics/lib'; import { esqlResultToPlainObjects } from '../esql_result_to_plain_objects'; type SearchRequest = ESSearchRequest & { @@ -24,19 +27,52 @@ type SearchRequest = ESSearchRequest & { size: number | boolean; }; -type EsqlQueryParameters = EsqlQueryRequest & { parseOutput?: boolean }; -type EsqlOutputParameters = Omit & { - parseOutput?: true; - format?: 'json'; - columnar?: false; -}; +export interface EsqlOptions { + transform?: 'none' | 'plain' | 'unflatten'; +} + +export type EsqlValue = ScalarValue | ScalarValue[]; + +export type EsqlOutput = Record; + +type MaybeUnflatten, TApply> = TApply extends true + ? DedotObject + : T; + +interface UnparsedEsqlResponseOf { + columns: Array<{ name: keyof TOutput; type: string }>; + values: Array>>; +} -type EsqlParameters = EsqlOutputParameters | EsqlQueryParameters; +interface ParsedEsqlResponseOf< + TOutput extends EsqlOutput, + TOptions extends EsqlOptions | undefined = { transform: 'none' } +> { + hits: Array< + MaybeUnflatten< + { + [key in keyof TOutput]: TOutput[key]; + }, + TOptions extends { transform: 'unflatten' } ? true : false + > + >; +} export type InferEsqlResponseOf< - TOutput = unknown, - TParameters extends EsqlParameters = EsqlParameters -> = TParameters extends EsqlOutputParameters ? TOutput[] : ESQLSearchResponse; + TOutput extends EsqlOutput, + TOptions extends EsqlOptions | undefined = { transform: 'none' } +> = TOptions extends { transform: 'plain' | 'unflatten' } + ? ParsedEsqlResponseOf + : UnparsedEsqlResponseOf; + +export type ObservabilityESSearchRequest = SearchRequest; + +export type ObservabilityEsQueryRequest = Omit; + +export type ParsedEsqlResponse = ParsedEsqlResponseOf; +export type UnparsedEsqlResponse = UnparsedEsqlResponseOf; + +export type EsqlQueryResponse = UnparsedEsqlResponse | ParsedEsqlResponse; /** * An Elasticsearch Client with a fully typed `search` method and built-in @@ -57,14 +93,18 @@ export interface ObservabilityElasticsearchClient { operationName: string, request: Required ): Promise; - esql( + esql( operationName: string, - parameters: TQueryParams - ): Promise>; - esql( + parameters: ObservabilityEsQueryRequest + ): Promise>; + esql< + TOutput extends EsqlOutput = EsqlOutput, + TEsqlOptions extends EsqlOptions = { transform: 'none' } + >( operationName: string, - parameters: TQueryParams - ): Promise>; + parameters: ObservabilityEsQueryRequest, + options: TEsqlOptions + ): Promise>; client: ElasticsearchClient; } @@ -109,32 +149,41 @@ export function createObservabilityEsClient({ }); }); }, - esql( + esql( operationName: string, - { parseOutput = true, format = 'json', columnar = false, ...parameters }: TSearchRequest - ) { - logger.trace(() => `Request (${operationName}):\n${JSON.stringify(parameters, null, 2)}`); - return withSpan({ name: operationName, labels: { plugin } }, () => { - return client.esql.query( - { ...parameters, format, columnar }, - { - querystring: { - drop_null_columns: true, - }, - } - ); - }) - .then((response) => { - logger.trace(() => `Response (${operationName}):\n${JSON.stringify(response, null, 2)}`); - - const esqlResponse = response as unknown as ESQLSearchResponse; - - const shouldParseOutput = parseOutput && !columnar && format === 'json'; - return shouldParseOutput ? esqlResultToPlainObjects(esqlResponse) : esqlResponse; - }) - .catch((error) => { - throw error; - }); + parameters: ObservabilityEsQueryRequest, + options?: EsqlOptions + ): Promise> { + return callWithLogger(operationName, parameters, () => { + return client.esql + .query( + { ...parameters }, + { + querystring: { + drop_null_columns: true, + }, + } + ) + .then((response) => { + const esqlResponse = response as unknown as UnparsedEsqlResponseOf; + + const transform = options?.transform ?? 'none'; + + if (transform === 'none') { + return esqlResponse; + } + + const parsedResponse = { hits: esqlResultToPlainObjects(esqlResponse) }; + + if (transform === 'plain') { + return parsedResponse; + } + + return { + hits: parsedResponse.hits.map((hit) => unflattenObject(hit)), + }; + }) as Promise>; + }); }, search( operationName: string, diff --git a/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.test.ts b/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.test.ts index 4557d0ba0bdd..55d77368cfdf 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.test.ts +++ b/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.test.ts @@ -27,40 +27,15 @@ describe('esqlResultToPlainObjects', () => { expect(output).toEqual([{ name: 'Foo Bar' }]); }); - it('should return columns without "text" or "keyword" in their names', () => { + it('should not unflatten objects', () => { const result: ESQLSearchResponse = { columns: [ - { name: 'name.text', type: 'text' }, - { name: 'age', type: 'keyword' }, - ], - values: [ - ['Foo Bar', 30], - ['Foo Qux', 25], - ], - }; - const output = esqlResultToPlainObjects(result); - expect(output).toEqual([ - { name: 'Foo Bar', age: 30 }, - { name: 'Foo Qux', age: 25 }, - ]); - }); - - it('should handle mixed columns correctly', () => { - const result: ESQLSearchResponse = { - columns: [ - { name: 'name', type: 'text' }, - { name: 'name.text', type: 'text' }, - { name: 'age', type: 'keyword' }, - ], - values: [ - ['Foo Bar', 'Foo Bar', 30], - ['Foo Qux', 'Foo Qux', 25], + { name: 'name', type: 'keyword' }, + { name: 'name.nested', type: 'keyword' }, ], + values: [['Foo Bar', 'Bar Foo']], }; const output = esqlResultToPlainObjects(result); - expect(output).toEqual([ - { name: 'Foo Bar', age: 30 }, - { name: 'Foo Qux', age: 25 }, - ]); + expect(output).toEqual([{ name: 'Foo Bar', 'name.nested': 'Bar Foo' }]); }); }); diff --git a/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.ts b/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.ts index 34781153532c..53f54b608ca3 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.ts +++ b/x-pack/packages/observability/observability_utils/observability_utils_server/es/esql_result_to_plain_objects.ts @@ -6,28 +6,24 @@ */ import type { ESQLSearchResponse } from '@kbn/es-types'; -import { unflattenObject } from '@kbn/observability-utils-common/object/unflatten_object'; -export function esqlResultToPlainObjects( - result: ESQLSearchResponse -): TDocument[] { - return result.values.map((row) => { - return unflattenObject( - row.reduce>((acc, value, index) => { - const column = result.columns[index]; +export function esqlResultToPlainObjects< + TDocument extends Record = Record +>(result: ESQLSearchResponse): TDocument[] { + return result.values.map((row): TDocument => { + return row.reduce>((acc, value, index) => { + const column = result.columns[index]; - if (!column) { - return acc; - } + if (!column) { + return acc; + } - // Removes the type suffix from the column name - const name = column.name.replace(/\.(text|keyword)$/, ''); - if (!acc[name]) { - acc[name] = value; - } + const name = column.name; + if (!acc[name]) { + acc[name] = value; + } - return acc; - }, {}) - ) as TDocument; + return acc; + }, {}) as TDocument; }); } diff --git a/x-pack/packages/observability/observability_utils/observability_utils_server/tsconfig.json b/x-pack/packages/observability/observability_utils/observability_utils_server/tsconfig.json index f51d93089c62..f6dd781184b8 100644 --- a/x-pack/packages/observability/observability_utils/observability_utils_server/tsconfig.json +++ b/x-pack/packages/observability/observability_utils/observability_utils_server/tsconfig.json @@ -24,5 +24,7 @@ "@kbn/alerting-plugin", "@kbn/rule-registry-plugin", "@kbn/rule-data-utils", + "@kbn/utility-types", + "@kbn/task-manager-plugin", ] } diff --git a/x-pack/plugins/entity_manager/server/routes/enablement/check.ts b/x-pack/plugins/entity_manager/server/routes/enablement/check.ts index 5373ac9df50f..100b0ac382dc 100644 --- a/x-pack/plugins/entity_manager/server/routes/enablement/check.ts +++ b/x-pack/plugins/entity_manager/server/routes/enablement/check.ts @@ -45,13 +45,11 @@ import { createEntityManagerServerRoute } from '../create_entity_manager_server_ */ export const checkEntityDiscoveryEnabledRoute = createEntityManagerServerRoute({ endpoint: 'GET /internal/entities/managed/enablement', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint leverages the security plugin to evaluate the privileges needed as part of its core flow', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint leverages the security plugin to evaluate the privileges needed as part of its core flow', }, }, handler: async ({ response, logger, server }) => { diff --git a/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts b/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts index a71a317045c4..1c8755c682f7 100644 --- a/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts +++ b/x-pack/plugins/entity_manager/server/routes/enablement/disable.ts @@ -44,13 +44,11 @@ import { createEntityManagerServerRoute } from '../create_entity_manager_server_ */ export const disableEntityDiscoveryRoute = createEntityManagerServerRoute({ endpoint: 'DELETE /internal/entities/managed/enablement', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint leverages the security plugin to evaluate the privileges needed as part of its core flow', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint leverages the security plugin to evaluate the privileges needed as part of its core flow', }, }, params: z.object({ diff --git a/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts b/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts index 562b798a598a..6ddb65804b90 100644 --- a/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts +++ b/x-pack/plugins/entity_manager/server/routes/enablement/enable.ts @@ -63,13 +63,11 @@ import { startTransforms } from '../../lib/entities/start_transforms'; */ export const enableEntityDiscoveryRoute = createEntityManagerServerRoute({ endpoint: 'PUT /internal/entities/managed/enablement', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint leverages the security plugin to evaluate the privileges needed as part of its core flow', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint leverages the security plugin to evaluate the privileges needed as part of its core flow', }, }, params: z.object({ diff --git a/x-pack/plugins/entity_manager/server/routes/entities/create.ts b/x-pack/plugins/entity_manager/server/routes/entities/create.ts index a22916f3e69f..fc0f4c6e9a1e 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/create.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/create.ts @@ -50,13 +50,11 @@ import { canManageEntityDefinition } from '../../lib/auth'; */ export const createEntityDefinitionRoute = createEntityManagerServerRoute({ endpoint: 'POST /internal/entities/definition', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', }, }, params: z.object({ diff --git a/x-pack/plugins/entity_manager/server/routes/entities/delete.ts b/x-pack/plugins/entity_manager/server/routes/entities/delete.ts index ff5b9624dbb3..ec5b4ada3039 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/delete.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/delete.ts @@ -52,13 +52,11 @@ import { canDeleteEntityDefinition } from '../../lib/auth/privileges'; */ export const deleteEntityDefinitionRoute = createEntityManagerServerRoute({ endpoint: 'DELETE /internal/entities/definition/{id}', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', }, }, params: z.object({ diff --git a/x-pack/plugins/entity_manager/server/routes/entities/get.ts b/x-pack/plugins/entity_manager/server/routes/entities/get.ts index f22e0890e60a..738ed0f44064 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/get.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/get.ts @@ -50,13 +50,11 @@ import { createEntityManagerServerRoute } from '../create_entity_manager_server_ */ export const getEntityDefinitionRoute = createEntityManagerServerRoute({ endpoint: 'GET /internal/entities/definition/{id?}', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', }, }, params: z.object({ diff --git a/x-pack/plugins/entity_manager/server/routes/entities/reset.ts b/x-pack/plugins/entity_manager/server/routes/entities/reset.ts index ab4ba29fa148..5da7a608aed8 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/reset.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/reset.ts @@ -25,13 +25,11 @@ import { stopTransforms } from '../../lib/entities/stop_transforms'; export const resetEntityDefinitionRoute = createEntityManagerServerRoute({ endpoint: 'POST /internal/entities/definition/{id}/_reset', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', }, }, params: z.object({ diff --git a/x-pack/plugins/entity_manager/server/routes/entities/update.ts b/x-pack/plugins/entity_manager/server/routes/entities/update.ts index f1118028cda9..4f23486375f9 100644 --- a/x-pack/plugins/entity_manager/server/routes/entities/update.ts +++ b/x-pack/plugins/entity_manager/server/routes/entities/update.ts @@ -54,13 +54,11 @@ import { canManageEntityDefinition } from '../../lib/auth'; */ export const updateEntityDefinitionRoute = createEntityManagerServerRoute({ endpoint: 'PATCH /internal/entities/definition/{id}', - options: { - security: { - authz: { - enabled: false, - reason: - 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', - }, + security: { + authz: { + enabled: false, + reason: + 'This endpoint mainly manages Elasticsearch resources using the requesting users credentials', }, }, params: z.object({ diff --git a/x-pack/plugins/observability_solution/apm/server/routes/apm_routes/register_apm_server_routes.ts b/x-pack/plugins/observability_solution/apm/server/routes/apm_routes/register_apm_server_routes.ts index 4792223610bb..59c9cdac2df8 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/apm_routes/register_apm_server_routes.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/apm_routes/register_apm_server_routes.ts @@ -11,6 +11,7 @@ import { Logger, KibanaRequest, KibanaResponseFactory, RouteRegistrar } from '@k import { errors } from '@elastic/elasticsearch'; import agent from 'elastic-apm-node'; import { + DefaultRouteCreateOptions, IoTsParamsObject, ServerRouteRepository, stripNullishRequestParameters, @@ -30,6 +31,7 @@ import type { APMIndices } from '@kbn/apm-data-access-plugin/server'; import { ApmFeatureFlags } from '../../../common/apm_feature_flags'; import type { APMCore, + APMRouteCreateOptions, MinimalApmPluginRequestHandlerContext, TelemetryUsageCounter, } from '../typings'; @@ -78,7 +80,11 @@ export function registerRoutes({ const router = core.setup.http.createRouter(); routes.forEach((route) => { - const { params, endpoint, options, handler } = route; + const { endpoint, handler, security } = route; + + const options = ('options' in route ? route.options : {}) as DefaultRouteCreateOptions & + APMRouteCreateOptions; + const params = 'params' in route ? route.params : undefined; const { method, pathname, version } = parseEndpoint(endpoint); @@ -215,6 +221,7 @@ export function registerRoutes({ path: pathname, options, validate: passThroughValidationObject, + security, }, wrappedHandler ); @@ -228,6 +235,7 @@ export function registerRoutes({ path: pathname, access: pathname.includes('/internal/apm') ? 'internal' : 'public', options, + security, }).addVersion( { version, diff --git a/x-pack/plugins/observability_solution/apm/server/routes/typings.ts b/x-pack/plugins/observability_solution/apm/server/routes/typings.ts index f9ea085a11e6..126ae4893764 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/typings.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/typings.ts @@ -9,7 +9,6 @@ import type { CoreSetup, CustomRequestHandlerContext, CoreStart, - RouteConfigOptions, IScopedClusterClient, IUiSettingsClient, SavedObjectsClientContract, @@ -48,21 +47,18 @@ export type MinimalApmPluginRequestHandlerContext = Omit< }; export interface APMRouteCreateOptions { - options: { - tags: Array< - | 'access:apm' - | 'access:apm_write' - | 'access:apm_settings_write' - | 'access:ml:canGetJobs' - | 'access:ml:canCreateJob' - | 'access:ml:canCloseJob' - | 'access:ai_assistant' - | 'oas-tag:APM agent keys' - | 'oas-tag:APM annotations' - >; - body?: { accepts: Array<'application/json' | 'multipart/form-data'> }; - disableTelemetry?: boolean; - } & RouteConfigOptions; + tags: Array< + | 'access:apm' + | 'access:apm_write' + | 'access:apm_settings_write' + | 'access:ml:canGetJobs' + | 'access:ml:canCreateJob' + | 'access:ml:canCloseJob' + | 'access:ai_assistant' + | 'oas-tag:APM agent keys' + | 'oas-tag:APM annotations' + >; + disableTelemetry?: boolean; } export type TelemetryUsageCounter = ReturnType; diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/register_routes.ts index 4e1970d7fc88..db5620e0778f 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/register_routes.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/register_routes.ts @@ -39,14 +39,18 @@ export function registerRoutes({ const router = core.http.createRouter(); routes.forEach((route) => { - const { endpoint, options, handler, params } = route; + const { endpoint, handler } = route; const { pathname, method } = parseEndpoint(endpoint); + const params = 'params' in route ? route.params : undefined; + const options = 'options' in route ? route.options : {}; + (router[method] as RouteRegistrar)( { path: pathname, validate: passThroughValidationObject, options, + security: route.security, }, async (context, request, response) => { try { diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/routes/types.ts b/x-pack/plugins/observability_solution/dataset_quality/server/routes/types.ts index 86dd9e098625..298d0c45efc4 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/routes/types.ts @@ -29,7 +29,5 @@ export interface DatasetQualityRouteHandlerResources { } export interface DatasetQualityRouteCreateOptions { - options: { - tags: string[]; - }; + tags: string[]; } diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts index c109f53be1f1..c7669e6f9acd 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts @@ -44,18 +44,25 @@ export async function getLatestEntity({ return undefined; } - const response = await inventoryEsClient.esql<{ - source_data_stream?: { type?: string | string[] }; - }>('get_latest_entities', { - query: `FROM ${ENTITIES_LATEST_ALIAS} + const response = await inventoryEsClient.esql< + { + 'source_data_stream.type'?: string | string; + }, + { transform: 'plain' } + >( + 'get_latest_entities', + { + query: `FROM ${ENTITIES_LATEST_ALIAS} | WHERE ${ENTITY_TYPE} == ? | WHERE ${hostOrContainerIdentityField} == ? | KEEP ${SOURCE_DATA_STREAM_TYPE} `, - params: [entityType, entityId], - }); + params: [entityType, entityId], + }, + { transform: 'plain' } + ); - return { sourceDataStreamType: response[0].source_data_stream?.type }; + return { sourceDataStreamType: response.hits[0]['source_data_stream.type'] }; } catch (e) { logger.error(e); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts index 87d0c375149e..816b3c6af6ec 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_groups.ts @@ -22,7 +22,7 @@ export async function getEntityGroupsBy({ inventoryEsClient: ObservabilityElasticsearchClient; field: string; esQuery?: QueryDslQueryContainer; -}) { +}): Promise { const from = `FROM ${ENTITIES_LATEST_ALIAS}`; const where = [getBuiltinEntityDefinitionIdESQLWhereClause()]; @@ -31,8 +31,14 @@ export async function getEntityGroupsBy({ const limit = `LIMIT ${MAX_NUMBER_OF_ENTITIES}`; const query = [from, ...where, group, sort, limit].join(' | '); - return inventoryEsClient.esql('get_entities_groups', { - query, - filter: esQuery, - }); + const { hits } = await inventoryEsClient.esql( + 'get_entities_groups', + { + query, + filter: esQuery, + }, + { transform: 'plain' } + ); + + return hits; } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts index e944e27379ab..c1f7894a178b 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_entity_types.ts @@ -7,7 +7,6 @@ import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; import { ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; -import type { EntityInstance } from '@kbn/entities-schema'; import { ENTITIES_LATEST_ALIAS } from '../../../common/entities'; import { getBuiltinEntityDefinitionIdESQLWhereClause } from './query_helper'; @@ -16,14 +15,21 @@ export async function getEntityTypes({ }: { inventoryEsClient: ObservabilityElasticsearchClient; }) { - const entityTypesEsqlResponse = await inventoryEsClient.esql<{ - entity: Pick; - }>('get_entity_types', { - query: `FROM ${ENTITIES_LATEST_ALIAS} + const entityTypesEsqlResponse = await inventoryEsClient.esql< + { + 'entity.type': string; + }, + { transform: 'plain' } + >( + 'get_entity_types', + { + query: `FROM ${ENTITIES_LATEST_ALIAS} | ${getBuiltinEntityDefinitionIdESQLWhereClause()} | STATS count = COUNT(${ENTITY_TYPE}) BY ${ENTITY_TYPE} `, - }); + }, + { transform: 'plain' } + ); - return entityTypesEsqlResponse.map((response) => response.entity.type); + return entityTypesEsqlResponse.hits.map((response) => response['entity.type']); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts index c4bf13c5ec14..9dcf17250ad6 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts @@ -6,13 +6,13 @@ */ import type { QueryDslQueryContainer, ScalarValue } from '@elastic/elasticsearch/lib/api/types'; -import type { EntityInstance } from '@kbn/entities-schema'; import { ENTITY_DISPLAY_NAME, ENTITY_LAST_SEEN, ENTITY_TYPE, } from '@kbn/observability-shared-plugin/common'; import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; +import { unflattenObject } from '@kbn/observability-utils-common/object/unflatten_object'; import { ENTITIES_LATEST_ALIAS, InventoryEntity, @@ -62,17 +62,38 @@ export async function getLatestEntities({ const query = [from, ...where, sort, limit].join(' | '); - const latestEntitiesEsqlResponse = await inventoryEsClient.esql( + const latestEntitiesEsqlResponse = await inventoryEsClient.esql< + { + 'entity.id': string; + 'entity.type': string; + 'entity.definition_id': string; + 'entity.display_name': string; + 'entity.identity_fields': string | string[]; + 'entity.last_seen_timestamp': string; + 'entity.definition_version': string; + 'entity.schema_version': string; + } & Record, + { transform: 'plain' } + >( 'get_latest_entities', { query, filter: esQuery, params, - } + }, + { transform: 'plain' } ); - return latestEntitiesEsqlResponse.map((lastestEntity) => { - const { entity, ...metadata } = lastestEntity; + return latestEntitiesEsqlResponse.hits.map((latestEntity) => { + Object.keys(latestEntity).forEach((key) => { + const keyOfObject = key as keyof typeof latestEntity; + // strip out multi-field aliases + if (keyOfObject.endsWith('.text') || keyOfObject.endsWith('.keyword')) { + delete latestEntity[keyOfObject]; + } + }); + + const { entity, ...metadata } = unflattenObject(latestEntity); return { entityId: entity.id, diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts b/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts index 2f59478f17c0..c3fd3971f09d 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/has_data/get_has_data.ts @@ -17,14 +17,18 @@ export async function getHasData({ logger: Logger; }) { try { - const esqlResults = await inventoryEsClient.esql<{ _count: number }>('get_has_data', { - query: `FROM ${ENTITIES_LATEST_ALIAS} + const esqlResults = await inventoryEsClient.esql<{ _count: number }, { transform: 'plain' }>( + 'get_has_data', + { + query: `FROM ${ENTITIES_LATEST_ALIAS} | ${getBuiltinEntityDefinitionIdESQLWhereClause()} | STATS _count = COUNT(*) | LIMIT 1`, - }); + }, + { transform: 'plain' } + ); - const totalCount = esqlResults[0]._count; + const totalCount = esqlResults.hits[0]._count; return { hasData: totalCount > 0 }; } catch (e) { diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/types.ts b/x-pack/plugins/observability_solution/inventory/server/routes/types.ts index 397710509dab..646d34888ae9 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/types.ts @@ -30,10 +30,8 @@ export interface InventoryRouteHandlerResources { } export interface InventoryRouteCreateOptions { - options: { - timeout?: { - idleSocket?: number; - }; - tags: Array<'access:inventory'>; + timeout?: { + idleSocket?: number; }; + tags: Array<'access:inventory'>; } diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts index afb022cdc9b7..0cee1b701539 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts @@ -53,10 +53,7 @@ export interface InvestigateAppRouteHandlerResources { } export interface InvestigateAppRouteCreateOptions { - options: { - timeout?: { - idleSocket?: number; - }; - tags: []; + timeout?: { + idleSocket?: number; }; } diff --git a/x-pack/plugins/observability_solution/observability/kibana.jsonc b/x-pack/plugins/observability_solution/observability/kibana.jsonc index 1c09efd7dd6e..3a888ce14e5e 100644 --- a/x-pack/plugins/observability_solution/observability/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability/kibana.jsonc @@ -56,7 +56,8 @@ "serverless", "guidedOnboarding", "observabilityAIAssistant", - "investigate" + "investigate", + "streams" ], "requiredBundles": [ "data", @@ -70,4 +71,4 @@ "common" ] } -} \ No newline at end of file +} diff --git a/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts b/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts index 07bb33ebb5a9..16718648c972 100644 --- a/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts +++ b/x-pack/plugins/observability_solution/observability/public/navigation_tree.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import type { NavigationTreeDefinition } from '@kbn/core-chrome-browser'; import type { AddSolutionNavigationArg } from '@kbn/navigation-plugin/public'; -import { of } from 'rxjs'; +import { map, of } from 'rxjs'; import type { ObservabilityPublicPluginsStart } from './plugin'; const title = i18n.translate( @@ -18,7 +18,7 @@ const title = i18n.translate( ); const icon = 'logoObservability'; -export function createNavTree(pluginsStart: ObservabilityPublicPluginsStart) { +function createNavTree({ streamsAvailable }: { streamsAvailable?: boolean }) { const navTree: NavigationTreeDefinition = { body: [ { @@ -87,6 +87,13 @@ export function createNavTree(pluginsStart: ObservabilityPublicPluginsStart) { link: 'inventory', spaceBefore: 'm', }, + ...(streamsAvailable + ? [ + { + link: 'streams' as const, + }, + ] + : []), { id: 'apm', title: i18n.translate('xpack.observability.obltNav.applications', { @@ -558,6 +565,8 @@ export const createDefinition = ( title, icon: 'logoObservability', homePage: 'observabilityOnboarding', - navigationTree$: of(createNavTree(pluginsStart)), + navigationTree$: (pluginsStart.streams?.status$ || of({ status: 'disabled' as const })).pipe( + map(({ status }) => createNavTree({ streamsAvailable: status === 'enabled' })) + ), dataTestSubj: 'observabilitySideNav', }); diff --git a/x-pack/plugins/observability_solution/observability/public/plugin.ts b/x-pack/plugins/observability_solution/observability/public/plugin.ts index 5866a082556b..c37f3cc2f624 100644 --- a/x-pack/plugins/observability_solution/observability/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability/public/plugin.ts @@ -70,6 +70,7 @@ import type { import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; +import type { StreamsPluginStart, StreamsPluginSetup } from '@kbn/streams-plugin/public'; import { observabilityAppId, observabilityFeatureId } from '../common'; import { ALERTS_PATH, @@ -124,6 +125,7 @@ export interface ObservabilityPublicPluginsSetup { licensing: LicensingPluginSetup; serverless?: ServerlessPluginSetup; presentationUtil?: PresentationUtilPluginStart; + streams?: StreamsPluginSetup; } export interface ObservabilityPublicPluginsStart { actionTypeRegistry: ActionTypeRegistryContract; @@ -162,6 +164,7 @@ export interface ObservabilityPublicPluginsStart { dataViewFieldEditor: DataViewFieldEditorStart; toastNotifications: ToastsStart; investigate?: InvestigatePublicStart; + streams?: StreamsPluginStart; } export type ObservabilityPublicStart = ReturnType; diff --git a/x-pack/plugins/observability_solution/observability/server/plugin.ts b/x-pack/plugins/observability_solution/observability/server/plugin.ts index b98fe316c712..4d0e809f882e 100644 --- a/x-pack/plugins/observability_solution/observability/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability/server/plugin.ts @@ -195,7 +195,6 @@ export class ObservabilityPlugin implements Plugin { void core.getStartServices().then(([coreStart, pluginStart]) => { registerRoutes({ core, - config, dependencies: { pluginsSetup: { ...plugins, diff --git a/x-pack/plugins/observability_solution/observability/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/observability/server/routes/register_routes.ts index 5599039a5ce6..9ce2d7c9f182 100644 --- a/x-pack/plugins/observability_solution/observability/server/routes/register_routes.ts +++ b/x-pack/plugins/observability_solution/observability/server/routes/register_routes.ts @@ -4,29 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { errors } from '@elastic/elasticsearch'; -import Boom from '@hapi/boom'; import { RulesClientApi } from '@kbn/alerting-plugin/server/types'; -import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server'; +import { CoreSetup, KibanaRequest, Logger } from '@kbn/core/server'; import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server'; -import { - IoTsParamsObject, - decodeRequestParams, - parseEndpoint, - passThroughValidationObject, - stripNullishRequestParameters, -} from '@kbn/server-route-repository'; +import { registerRoutes as registerServerRoutes } from '@kbn/server-route-repository'; import { SpacesPluginStart } from '@kbn/spaces-plugin/server'; -import axios from 'axios'; -import * as t from 'io-ts'; -import { ObservabilityConfig } from '..'; import { AlertDetailsContextualInsightsService } from '../services'; -import { ObservabilityRequestHandlerContext } from '../types'; import { AbstractObservabilityServerRouteRepository } from './types'; interface RegisterRoutes { - config: ObservabilityConfig; core: CoreSetup; repository: AbstractObservabilityServerRouteRepository; logger: Logger; @@ -46,81 +33,11 @@ export interface RegisterRoutesDependencies { getRulesClientWithRequest: (request: KibanaRequest) => RulesClientApi; } -export function registerRoutes({ config, repository, core, logger, dependencies }: RegisterRoutes) { - const routes = Object.values(repository); - - const router = core.http.createRouter(); - - routes.forEach((route) => { - const { endpoint, options, handler, params } = route; - const { pathname, method } = parseEndpoint(endpoint); - - (router[method] as RouteRegistrar)( - { - path: pathname, - validate: passThroughValidationObject, - options, - }, - async (context, request, response) => { - try { - const decodedParams = decodeRequestParams( - stripNullishRequestParameters({ - params: request.params, - body: request.body, - query: request.query, - }), - (params as IoTsParamsObject) ?? t.strict({}) - ); - - const data = await handler({ - config, - context, - request, - logger, - params: decodedParams, - dependencies, - }); - - if (data === undefined) { - return response.noContent(); - } - - return response.ok({ body: data }); - } catch (error) { - if (axios.isAxiosError(error)) { - logger.error(error); - return response.customError({ - statusCode: error.response?.status || 500, - body: { - message: error.message, - }, - }); - } - - if (Boom.isBoom(error)) { - logger.error(error.output.payload.message); - return response.customError({ - statusCode: error.output.statusCode, - body: { message: error.output.payload.message }, - }); - } - - logger.error(error); - const opts = { - statusCode: 500, - body: { - message: error.message, - }, - }; - - if (error instanceof errors.RequestAbortedError) { - opts.statusCode = 499; - opts.body.message = 'Client closed request'; - } - - return response.customError(opts); - } - } - ); +export function registerRoutes({ repository, core, logger, dependencies }: RegisterRoutes) { + registerServerRoutes({ + core, + dependencies: { dependencies }, + logger, + repository, }); } diff --git a/x-pack/plugins/observability_solution/observability/server/routes/types.ts b/x-pack/plugins/observability_solution/observability/server/routes/types.ts index 394025313764..111bc4e71411 100644 --- a/x-pack/plugins/observability_solution/observability/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/observability/server/routes/types.ts @@ -13,7 +13,6 @@ import { } from './get_global_observability_server_route_repository'; import { ObservabilityRequestHandlerContext } from '../types'; import { RegisterRoutesDependencies } from './register_routes'; -import { ObservabilityConfig } from '..'; export type { ObservabilityServerRouteRepository, APIEndpoint }; @@ -22,14 +21,11 @@ export interface ObservabilityRouteHandlerResources { dependencies: RegisterRoutesDependencies; logger: Logger; request: KibanaRequest; - config: ObservabilityConfig; } export interface ObservabilityRouteCreateOptions { - options: { - tags: string[]; - access?: 'public' | 'internal'; - }; + tags: string[]; + access?: 'public' | 'internal'; } export type AbstractObservabilityServerRouteRepository = ServerRouteRepository; diff --git a/x-pack/plugins/observability_solution/observability/tsconfig.json b/x-pack/plugins/observability_solution/observability/tsconfig.json index 84a691f1033a..b3c18dc24b8e 100644 --- a/x-pack/plugins/observability_solution/observability/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability/tsconfig.json @@ -111,6 +111,7 @@ "@kbn/core-ui-settings-server-mocks", "@kbn/es-types", "@kbn/logging-mocks", + "@kbn/streams-plugin", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts index 80ddf3cbc0a0..1bf892c0d40e 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/functions/context.ts @@ -34,7 +34,7 @@ export function registerContextFunction({ visibility: FunctionVisibility.Internal, }, async ({ messages, screenContexts, chat }, signal) => { - const { analytics } = (await resources.context.core).coreStart; + const { analytics } = await resources.plugins.core.start(); async function getContext() { const screenDescription = compact( diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts index f693fa53c06c..7949276ac6ab 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/plugin.ts @@ -111,7 +111,18 @@ export class ObservabilityAIAssistantPlugin ]; }), }; - }) as ObservabilityAIAssistantRouteHandlerResources['plugins']; + }) as Pick< + ObservabilityAIAssistantRouteHandlerResources['plugins'], + keyof ObservabilityAIAssistantPluginStartDependencies + >; + + const withCore = { + ...routeHandlerPlugins, + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + }; const service = (this.service = new ObservabilityAIAssistantService({ logger: this.logger.get('service'), @@ -133,7 +144,7 @@ export class ObservabilityAIAssistantPlugin core, logger: this.logger, dependencies: { - plugins: routeHandlerPlugins, + plugins: withCore, service: this.service, }, }); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts index a6fe57cb58ad..e80e6fa156b0 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/chat/route.ts @@ -194,7 +194,7 @@ const chatRecallRoute = createObservabilityAIAssistantServerRoute({ const response$ = from( recallAndScore({ - analytics: (await resources.context.core).coreStart.analytics, + analytics: (await resources.plugins.core.start()).analytics, chat: (name, params) => client .chat(name, { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/register_routes.ts index 1a6140968c92..27c7361e8a7f 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/register_routes.ts @@ -6,7 +6,7 @@ */ import type { CoreSetup } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; -import { registerRoutes } from '@kbn/server-route-repository'; +import { DefaultRouteHandlerResources, registerRoutes } from '@kbn/server-route-repository'; import { getGlobalObservabilityAIAssistantServerRouteRepository } from './get_global_observability_ai_assistant_route_repository'; import type { ObservabilityAIAssistantRouteHandlerResources } from './types'; import { ObservabilityAIAssistantPluginStartDependencies } from '../types'; @@ -20,7 +20,7 @@ export function registerServerRoutes({ logger: Logger; dependencies: Omit< ObservabilityAIAssistantRouteHandlerResources, - 'request' | 'context' | 'logger' | 'params' + keyof DefaultRouteHandlerResources >; }) { registerRoutes({ diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/types.ts index b817328d22c6..62365536f382 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/routes/types.ts @@ -6,32 +6,36 @@ */ import type { + CoreSetup, CoreStart, CustomRequestHandlerContext, IScopedClusterClient, IUiSettingsClient, - KibanaRequest, SavedObjectsClientContract, } from '@kbn/core/server'; -import type { Logger } from '@kbn/logging'; import type { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server/types'; import type { RacApiRequestHandlerContext } from '@kbn/rule-registry-plugin/server'; import type { RulesClientApi } from '@kbn/alerting-plugin/server/types'; +import { DefaultRouteHandlerResources } from '@kbn/server-route-repository-utils'; import type { ObservabilityAIAssistantService } from '../service'; import type { ObservabilityAIAssistantPluginSetupDependencies, ObservabilityAIAssistantPluginStartDependencies, } from '../types'; +type ObservabilityAIAssistantRequestHandlerContextBase = CustomRequestHandlerContext<{ + licensing: Pick; + // these two are here for compatibility with APM functions + rac: Pick; + alerting: { + getRulesClient: () => RulesClientApi; + }; +}>; + +// this is the type used across methods, it's stripped down for compatibility +// with the context that's available when executing as an action export type ObservabilityAIAssistantRequestHandlerContext = Omit< - CustomRequestHandlerContext<{ - licensing: Pick; - // these two are here for compatibility with APM functions - rac: Pick; - alerting: { - getRulesClient: () => RulesClientApi; - }; - }>, + ObservabilityAIAssistantRequestHandlerContextBase, 'core' | 'resolve' > & { core: Promise<{ @@ -45,32 +49,41 @@ export type ObservabilityAIAssistantRequestHandlerContext = Omit< savedObjects: { client: SavedObjectsClientContract; }; - coreStart: CoreStart; }>; }; -export interface ObservabilityAIAssistantRouteHandlerResources { - request: KibanaRequest; +interface PluginContractResolveCore { + core: { + setup: CoreSetup; + start: () => Promise; + }; +} + +type PluginContractResolveDependenciesStart = { + [key in keyof ObservabilityAIAssistantPluginStartDependencies]: { + start: () => Promise[key]>; + }; +}; + +type PluginContractResolveDependenciesSetup = { + [key in keyof ObservabilityAIAssistantPluginSetupDependencies]: { + setup: Required[key]; + }; +}; + +export interface ObservabilityAIAssistantRouteHandlerResources + extends Omit { context: ObservabilityAIAssistantRequestHandlerContext; - logger: Logger; service: ObservabilityAIAssistantService; - plugins: { - [key in keyof ObservabilityAIAssistantPluginSetupDependencies]: { - setup: Required[key]; - }; - } & { - [key in keyof ObservabilityAIAssistantPluginStartDependencies]: { - start: () => Promise[key]>; - }; - }; + plugins: PluginContractResolveCore & + PluginContractResolveDependenciesSetup & + PluginContractResolveDependenciesStart; } export interface ObservabilityAIAssistantRouteCreateOptions { - options: { - timeout?: { - payload?: number; - idleSocket?: number; - }; - tags: Array<'access:ai_assistant'>; + timeout?: { + payload?: number; + idleSocket?: number; }; + tags: Array<'access:ai_assistant'>; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json index d5acd7a365b5..709b3117d575 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/tsconfig.json @@ -47,6 +47,7 @@ "@kbn/ai-assistant-common", "@kbn/inference-common", "@kbn/core-lifecycle-server", + "@kbn/server-route-repository-utils", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts index 63e06818a2b7..97fdc01069b9 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/plugin.ts @@ -17,7 +17,6 @@ import { ObservabilityAIAssistantRequestHandlerContext, ObservabilityAIAssistantRouteHandlerResources, } from '@kbn/observability-ai-assistant-plugin/server/routes/types'; -import { ObservabilityAIAssistantPluginStartDependencies } from '@kbn/observability-ai-assistant-plugin/server/types'; import { mapValues } from 'lodash'; import { firstValueFrom } from 'rxjs'; import type { ObservabilityAIAssistantAppConfig } from './config'; @@ -59,13 +58,22 @@ export class ObservabilityAIAssistantAppPlugin setup: value, start: () => core.getStartServices().then((services) => { - const [, pluginsStartContracts] = services; + const [_, pluginsStartContracts] = services; + return pluginsStartContracts[ - key as keyof ObservabilityAIAssistantPluginStartDependencies + key as keyof ObservabilityAIAssistantAppPluginStartDependencies ]; }), }; - }) as ObservabilityAIAssistantRouteHandlerResources['plugins']; + }) as Omit; + + const withCore = { + ...routeHandlerPlugins, + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + }; const initResources = async ( request: KibanaRequest @@ -90,7 +98,6 @@ export class ObservabilityAIAssistantAppPlugin }; }), core: Promise.resolve({ - coreStart, elasticsearch: { client: coreStart.elasticsearch.client.asScoped(request), }, @@ -110,7 +117,7 @@ export class ObservabilityAIAssistantAppPlugin context, service: plugins.observabilityAIAssistant.service, logger: this.logger.get('connector'), - plugins: routeHandlerPlugins, + plugins: withCore, }; }; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts index de02e4cf841c..04fd10c3e506 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.test.ts @@ -94,12 +94,14 @@ describe('observabilityAIAssistant rule_connector', () => { getAdhocInstructions: () => [], }), }, - context: { - core: Promise.resolve({ - coreStart: { http: { basePath: { publicBaseUrl: 'http://kibana.com' } } }, - }), - }, + context: {}, plugins: { + core: { + start: () => + Promise.resolve({ + http: { basePath: { publicBaseUrl: 'http://kibana.com' } }, + }), + }, actions: { start: async () => { return { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts index 33f3bdd2c98f..1f5a097f8f7c 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts @@ -248,7 +248,7 @@ If available, include the link of the conversation at the end of your answer.` isPublic: true, connectorId: execOptions.params.connector, signal: new AbortController().signal, - kibanaPublicUrl: (await resources.context.core).coreStart.http.basePath.publicBaseUrl, + kibanaPublicUrl: (await resources.plugins.core.start()).http.basePath.publicBaseUrl, instructions: [backgroundInstruction], messages: [ { diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts index ccb260a002cf..30aaaf258838 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/plugin.ts @@ -14,8 +14,8 @@ import type { } from '@kbn/core/server'; import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { DefaultRouteHandlerResources, registerRoutes } from '@kbn/server-route-repository'; import { getObservabilityOnboardingServerRouteRepository } from './routes'; -import { registerRoutes } from './routes/register_routes'; import { ObservabilityOnboardingRouteHandlerResources } from './routes/types'; import { ObservabilityOnboardingPluginSetup, @@ -71,16 +71,28 @@ export class ObservabilityOnboardingPlugin }) as ObservabilityOnboardingRouteHandlerResources['plugins']; const config = this.initContext.config.get(); - registerRoutes({ - core, - logger: this.logger, - repository: getObservabilityOnboardingServerRouteRepository(), - plugins: resourcePlugins, + + const dependencies: Omit< + ObservabilityOnboardingRouteHandlerResources, + keyof DefaultRouteHandlerResources + > = { config, kibanaVersion: this.initContext.env.packageInfo.version, + plugins: resourcePlugins, services: { esLegacyConfigService: this.esLegacyConfigService, }, + core: { + setup: core, + start: () => core.getStartServices().then(([coreStart]) => coreStart), + }, + }; + + registerRoutes({ + core, + logger: this.logger, + repository: getObservabilityOnboardingServerRouteRepository(), + dependencies, }); plugins.customIntegrations.registerCustomIntegration({ diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/register_routes.ts deleted file mode 100644 index 8fe51623510e..000000000000 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/register_routes.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { errors } from '@elastic/elasticsearch'; -import Boom from '@hapi/boom'; -import type { IKibanaResponse } from '@kbn/core/server'; -import { CoreSetup, Logger, RouteRegistrar } from '@kbn/core/server'; -import { - IoTsParamsObject, - ServerRouteRepository, - decodeRequestParams, - stripNullishRequestParameters, - parseEndpoint, - passThroughValidationObject, -} from '@kbn/server-route-repository'; -import * as t from 'io-ts'; -import { ObservabilityOnboardingConfig } from '..'; -import { EsLegacyConfigService } from '../services/es_legacy_config_service'; -import { ObservabilityOnboardingRequestHandlerContext } from '../types'; -import { ObservabilityOnboardingRouteHandlerResources } from './types'; - -interface RegisterRoutes { - core: CoreSetup; - repository: ServerRouteRepository; - logger: Logger; - plugins: ObservabilityOnboardingRouteHandlerResources['plugins']; - config: ObservabilityOnboardingConfig; - kibanaVersion: string; - services: { - esLegacyConfigService: EsLegacyConfigService; - }; -} - -export function registerRoutes({ - repository, - core, - logger, - plugins, - config, - kibanaVersion, - services, -}: RegisterRoutes) { - const routes = Object.values(repository); - - const router = core.http.createRouter(); - - routes.forEach((route) => { - const { endpoint, options, handler, params } = route; - const { pathname, method } = parseEndpoint(endpoint); - - (router[method] as RouteRegistrar)( - { - path: pathname, - validate: passThroughValidationObject, - options, - }, - async (context, request, response) => { - try { - const decodedParams = decodeRequestParams( - stripNullishRequestParameters({ - params: request.params, - body: request.body, - query: request.query, - }), - (params as IoTsParamsObject) ?? t.strict({}) - ); - - const data = (await handler({ - context, - request, - response, - logger, - params: decodedParams, - plugins, - core: { - setup: core, - start: async () => { - const [coreStart] = await core.getStartServices(); - return coreStart; - }, - }, - config, - kibanaVersion, - services, - })) as any; - - if (data === undefined) { - return response.noContent(); - } - - if (data instanceof response.noContent().constructor) { - return data as IKibanaResponse; - } - - return response.ok({ body: data }); - } catch (error) { - if (Boom.isBoom(error)) { - logger.error(error.output.payload.message); - return response.customError({ - statusCode: error.output.statusCode, - body: { message: error.output.payload.message }, - }); - } - - logger.error(error); - const opts = { - statusCode: 500, - body: { - message: error.message, - }, - }; - - if (error instanceof errors.RequestAbortedError) { - opts.statusCode = 499; - opts.body.message = 'Client closed request'; - } - - return response.customError(opts); - } - } - ); - }); -} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts index 4b35272eaa33..689ab1473981 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/server/routes/types.ts @@ -46,10 +46,8 @@ export interface ObservabilityOnboardingRouteHandlerResources { } export interface ObservabilityOnboardingRouteCreateOptions { - options: { - tags: string[]; - xsrfRequired?: boolean; - }; + tags: string[]; + xsrfRequired?: boolean; } export const IntegrationRT = t.intersection([ diff --git a/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc b/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc index a5cde081c7c5..8e5a4c25af48 100644 --- a/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_shared/kibana.jsonc @@ -33,4 +33,4 @@ "common" ] } -} \ No newline at end of file +} diff --git a/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts index 6e3c02a5b921..ee58618add23 100644 --- a/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts +++ b/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts @@ -6,12 +6,11 @@ */ import { CoreSetup, Logger } from '@kbn/core/server'; import { ServerRoute, registerRoutes } from '@kbn/server-route-repository'; -import { ServerRouteCreateOptions } from '@kbn/server-route-repository-utils'; import { SLORequestHandlerContext, SLORoutesDependencies } from './types'; interface RegisterRoutes { core: CoreSetup; - repository: Record>; + repository: Record>; logger: Logger; dependencies: SLORoutesDependencies; isServerless: boolean; diff --git a/x-pack/plugins/observability_solution/slo/tsconfig.json b/x-pack/plugins/observability_solution/slo/tsconfig.json index 02a2ea06cc52..001c835fcb5c 100644 --- a/x-pack/plugins/observability_solution/slo/tsconfig.json +++ b/x-pack/plugins/observability_solution/slo/tsconfig.json @@ -99,6 +99,5 @@ "@kbn/observability-alerting-rule-utils", "@kbn/discover-shared-plugin", "@kbn/server-route-repository-client", - "@kbn/server-route-repository-utils" ] } diff --git a/x-pack/plugins/serverless_observability/kibana.jsonc b/x-pack/plugins/serverless_observability/kibana.jsonc index fce943c44865..ad2c1f76ce56 100644 --- a/x-pack/plugins/serverless_observability/kibana.jsonc +++ b/x-pack/plugins/serverless_observability/kibana.jsonc @@ -25,7 +25,9 @@ "discover", "security" ], - "optionalPlugins": [], + "optionalPlugins": [ + "streams" + ], "requiredBundles": [] } -} \ No newline at end of file +} diff --git a/x-pack/plugins/serverless_observability/public/navigation_tree.ts b/x-pack/plugins/serverless_observability/public/navigation_tree.ts index 7501a75abe87..e6fb3c910730 100644 --- a/x-pack/plugins/serverless_observability/public/navigation_tree.ts +++ b/x-pack/plugins/serverless_observability/public/navigation_tree.ts @@ -8,374 +8,387 @@ import { i18n } from '@kbn/i18n'; import type { NavigationTreeDefinition } from '@kbn/core-chrome-browser'; -export const navigationTree: NavigationTreeDefinition = { - body: [ - { type: 'recentlyAccessed' }, - { - type: 'navGroup', - id: 'observability_project_nav', - title: 'Observability', - icon: 'logoObservability', - defaultIsCollapsed: false, - isCollapsible: false, - breadcrumbStatus: 'hidden', - children: [ - { - title: i18n.translate('xpack.serverlessObservability.nav.discover', { - defaultMessage: 'Discover', - }), - link: 'last-used-logs-viewer', - // avoid duplicate "Discover" breadcrumbs - breadcrumbStatus: 'hidden', - renderAs: 'item', - children: [ - { - link: 'discover', - children: [ - { - link: 'observability-logs-explorer', - }, - ], - }, - ], - }, - { - title: i18n.translate('xpack.serverlessObservability.nav.dashboards', { - defaultMessage: 'Dashboards', - }), - link: 'dashboards', - getIsActive: ({ pathNameSerialized, prepend }) => { - return pathNameSerialized.startsWith(prepend('/app/dashboards')); +export const createNavigationTree = ({ + streamsAvailable, +}: { + streamsAvailable?: boolean; +}): NavigationTreeDefinition => { + return { + body: [ + { type: 'recentlyAccessed' }, + { + type: 'navGroup', + id: 'observability_project_nav', + title: 'Observability', + icon: 'logoObservability', + defaultIsCollapsed: false, + isCollapsible: false, + breadcrumbStatus: 'hidden', + children: [ + { + title: i18n.translate('xpack.serverlessObservability.nav.discover', { + defaultMessage: 'Discover', + }), + link: 'last-used-logs-viewer', + // avoid duplicate "Discover" breadcrumbs + breadcrumbStatus: 'hidden', + renderAs: 'item', + children: [ + { + link: 'discover', + children: [ + { + link: 'observability-logs-explorer', + }, + ], + }, + ], }, - }, - { - link: 'observability-overview:alerts', - }, - { - link: 'observability-overview:cases', - renderAs: 'item', - children: [ - { - link: 'observability-overview:cases_configure', - }, - { - link: 'observability-overview:cases_create', - }, - ], - }, - { - title: i18n.translate('xpack.serverlessObservability.nav.slo', { - defaultMessage: 'SLOs', - }), - link: 'slo', - }, - { - link: 'observabilityAIAssistant', - title: i18n.translate('xpack.serverlessObservability.nav.aiAssistant', { - defaultMessage: 'AI Assistant', - }), - }, - { link: 'inventory', spaceBefore: 'm' }, - { - id: 'apm', - title: i18n.translate('xpack.serverlessObservability.nav.applications', { - defaultMessage: 'Applications', - }), - renderAs: 'panelOpener', - children: [ - { - children: [ - { - link: 'apm:services', - title: i18n.translate('xpack.serverlessObservability.nav.apm.services', { - defaultMessage: 'Service inventory', - }), - }, - { link: 'apm:traces' }, - { link: 'apm:dependencies' }, - { link: 'apm:settings' }, - { - id: 'synthetics', - title: i18n.translate('xpack.serverlessObservability.nav.synthetics', { - defaultMessage: 'Synthetics', - }), - children: [ - { - title: i18n.translate( - 'xpack.serverlessObservability.nav.synthetics.overviewItem', - { - defaultMessage: 'Overview', - } - ), - id: 'synthetics-overview', - link: 'synthetics:overview', - breadcrumbStatus: 'hidden', - }, - { - link: 'synthetics:certificates', - title: i18n.translate( - 'xpack.serverlessObservability.nav.synthetics.certificatesItem', - { - defaultMessage: 'TLS certificates', - } - ), - id: 'synthetics-certificates', - breadcrumbStatus: 'hidden', - }, - ], - }, - ], + { + title: i18n.translate('xpack.serverlessObservability.nav.dashboards', { + defaultMessage: 'Dashboards', + }), + link: 'dashboards', + getIsActive: ({ pathNameSerialized, prepend }) => { + return pathNameSerialized.startsWith(prepend('/app/dashboards')); }, - ], - }, - { - id: 'metrics', - title: i18n.translate('xpack.serverlessObservability.nav.infrastructure', { - defaultMessage: 'Infrastructure', - }), - renderAs: 'panelOpener', - children: [ - { - children: [ - { - link: 'metrics:inventory', - title: i18n.translate( - 'xpack.serverlessObservability.nav.infrastructureInventory', - { - defaultMessage: 'Infrastructure inventory', - } - ), - }, - { link: 'metrics:hosts' }, - { link: 'metrics:settings' }, - { link: 'metrics:assetDetails' }, - ], - }, - ], - }, - { - id: 'machine_learning-landing', - renderAs: 'panelOpener', - title: i18n.translate('xpack.serverlessObservability.nav.machineLearning', { - defaultMessage: 'Machine learning', - }), - children: [ - { - children: [ - { - link: 'ml:overview', - }, - { - link: 'ml:notifications', - }, - { - link: 'ml:memoryUsage', - title: i18n.translate( - 'xpack.serverlessObservability.nav.machineLearning.memoryUsage', - { - defaultMessage: 'Memory usage', - } - ), - }, - ], - }, - { - id: 'category-anomaly_detection', - title: i18n.translate('xpack.serverlessObservability.nav.ml.anomaly_detection', { - defaultMessage: 'Anomaly detection', - }), - breadcrumbStatus: 'hidden', - children: [ - { - link: 'ml:anomalyDetection', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.anomaly_detection.jobs', - { - defaultMessage: 'Jobs', - } - ), - }, - { - link: 'ml:anomalyExplorer', - }, - { - link: 'ml:singleMetricViewer', - }, - { - link: 'ml:settings', - }, - { - link: 'ml:suppliedConfigurations', - }, - ], - }, - { - id: 'category-data_frame analytics', - title: i18n.translate('xpack.serverlessObservability.nav.ml.data_frame_analytics', { - defaultMessage: 'Data frame analytics', - }), - breadcrumbStatus: 'hidden', - children: [ - { - link: 'ml:dataFrameAnalytics', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.data_frame_analytics.jobs', - { - defaultMessage: 'Jobs', - } - ), - }, - { - link: 'ml:resultExplorer', - }, - { - link: 'ml:analyticsMap', - }, - ], - }, - { - id: 'category-model_management', - title: i18n.translate('xpack.serverlessObservability.nav.ml.model_management', { - defaultMessage: 'Model management', - }), - breadcrumbStatus: 'hidden', - children: [ - { - link: 'ml:nodesOverview', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.model_management.trainedModels', - { - defaultMessage: 'Trained models', - } - ), - }, - ], - }, - { - id: 'category-data_visualizer', - title: i18n.translate('xpack.serverlessObservability.nav.ml.data_visualizer', { - defaultMessage: 'Data visualizer', - }), - breadcrumbStatus: 'hidden', - children: [ - { - link: 'ml:fileUpload', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.data_visualizer.file_data_visualizer', - { - defaultMessage: 'File data visualizer', - } - ), - }, - { - link: 'ml:indexDataVisualizer', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.data_visualizer.data_view_data_visualizer', - { - defaultMessage: 'Data view data visualizer', - } - ), - }, - { - link: 'ml:dataDrift', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.data_visualizer.data_drift', - { - defaultMessage: 'Data drift', - } - ), - }, - ], - }, - { - id: 'category-aiops_labs', - title: i18n.translate('xpack.serverlessObservability.nav.ml.aiops_labs', { - defaultMessage: 'Aiops labs', - }), - breadcrumbStatus: 'hidden', - children: [ - { - link: 'ml:logRateAnalysis', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.aiops_labs.log_rate_analysis', - { - defaultMessage: 'Log rate analysis', - } - ), - }, - { - link: 'ml:logPatternAnalysis', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.aiops_labs.log_pattern_analysis', - { - defaultMessage: 'Log pattern analysis', - } - ), - }, + }, + { + link: 'observability-overview:alerts', + }, + { + link: 'observability-overview:cases', + renderAs: 'item', + children: [ + { + link: 'observability-overview:cases_configure', + }, + { + link: 'observability-overview:cases_create', + }, + ], + }, + { + title: i18n.translate('xpack.serverlessObservability.nav.slo', { + defaultMessage: 'SLOs', + }), + link: 'slo', + }, + { + link: 'observabilityAIAssistant', + title: i18n.translate('xpack.serverlessObservability.nav.aiAssistant', { + defaultMessage: 'AI Assistant', + }), + }, + { link: 'inventory', spaceBefore: 'm' }, + ...(streamsAvailable + ? [ { - link: 'ml:changePointDetections', - title: i18n.translate( - 'xpack.serverlessObservability.nav.ml.aiops_labs.change_point_detection', - { - defaultMessage: 'Change point detection', - } - ), + link: 'streams' as const, }, - ], - }, - ], - }, - ], - }, - ], - footer: [ - { - type: 'navItem', - title: i18n.translate('xpack.serverlessObservability.nav.getStarted', { - defaultMessage: 'Add data', - }), - link: 'observabilityOnboarding', - icon: 'launch', - }, - { - type: 'navItem', - id: 'devTools', - title: i18n.translate('xpack.serverlessObservability.nav.devTools', { - defaultMessage: 'Developer tools', - }), - link: 'dev_tools', - icon: 'editorCodeBlock', - }, - { - type: 'navGroup', - id: 'project_settings_project_nav', - title: i18n.translate('xpack.serverlessObservability.nav.projectSettings', { - defaultMessage: 'Project settings', - }), - icon: 'gear', - breadcrumbStatus: 'hidden', - children: [ - { - link: 'management', - title: i18n.translate('xpack.serverlessObservability.nav.mngt', { - defaultMessage: 'Management', - }), - }, - { - link: 'integrations', - }, - { - link: 'fleet', - }, - { - id: 'cloudLinkUserAndRoles', - cloudLink: 'userAndRoles', - }, - { - id: 'cloudLinkBilling', - cloudLink: 'billingAndSub', - }, - ], - }, - ], + ] + : []), + { + id: 'apm', + title: i18n.translate('xpack.serverlessObservability.nav.applications', { + defaultMessage: 'Applications', + }), + renderAs: 'panelOpener', + children: [ + { + children: [ + { + link: 'apm:services', + title: i18n.translate('xpack.serverlessObservability.nav.apm.services', { + defaultMessage: 'Service inventory', + }), + }, + { link: 'apm:traces' }, + { link: 'apm:dependencies' }, + { link: 'apm:settings' }, + { + id: 'synthetics', + title: i18n.translate('xpack.serverlessObservability.nav.synthetics', { + defaultMessage: 'Synthetics', + }), + children: [ + { + title: i18n.translate( + 'xpack.serverlessObservability.nav.synthetics.overviewItem', + { + defaultMessage: 'Overview', + } + ), + id: 'synthetics-overview', + link: 'synthetics:overview', + breadcrumbStatus: 'hidden', + }, + { + link: 'synthetics:certificates', + title: i18n.translate( + 'xpack.serverlessObservability.nav.synthetics.certificatesItem', + { + defaultMessage: 'TLS certificates', + } + ), + id: 'synthetics-certificates', + breadcrumbStatus: 'hidden', + }, + ], + }, + ], + }, + ], + }, + { + id: 'metrics', + title: i18n.translate('xpack.serverlessObservability.nav.infrastructure', { + defaultMessage: 'Infrastructure', + }), + renderAs: 'panelOpener', + children: [ + { + children: [ + { + link: 'metrics:inventory', + title: i18n.translate( + 'xpack.serverlessObservability.nav.infrastructureInventory', + { + defaultMessage: 'Infrastructure inventory', + } + ), + }, + { link: 'metrics:hosts' }, + { link: 'metrics:settings' }, + { link: 'metrics:assetDetails' }, + ], + }, + ], + }, + { + id: 'machine_learning-landing', + renderAs: 'panelOpener', + title: i18n.translate('xpack.serverlessObservability.nav.machineLearning', { + defaultMessage: 'Machine learning', + }), + children: [ + { + children: [ + { + link: 'ml:overview', + }, + { + link: 'ml:notifications', + }, + { + link: 'ml:memoryUsage', + title: i18n.translate( + 'xpack.serverlessObservability.nav.machineLearning.memoryUsage', + { + defaultMessage: 'Memory usage', + } + ), + }, + ], + }, + { + id: 'category-anomaly_detection', + title: i18n.translate('xpack.serverlessObservability.nav.ml.anomaly_detection', { + defaultMessage: 'Anomaly detection', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:anomalyDetection', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.anomaly_detection.jobs', + { + defaultMessage: 'Jobs', + } + ), + }, + { + link: 'ml:anomalyExplorer', + }, + { + link: 'ml:singleMetricViewer', + }, + { + link: 'ml:settings', + }, + { + link: 'ml:suppliedConfigurations', + }, + ], + }, + { + id: 'category-data_frame analytics', + title: i18n.translate('xpack.serverlessObservability.nav.ml.data_frame_analytics', { + defaultMessage: 'Data frame analytics', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:dataFrameAnalytics', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_frame_analytics.jobs', + { + defaultMessage: 'Jobs', + } + ), + }, + { + link: 'ml:resultExplorer', + }, + { + link: 'ml:analyticsMap', + }, + ], + }, + { + id: 'category-model_management', + title: i18n.translate('xpack.serverlessObservability.nav.ml.model_management', { + defaultMessage: 'Model management', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:nodesOverview', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.model_management.trainedModels', + { + defaultMessage: 'Trained models', + } + ), + }, + ], + }, + { + id: 'category-data_visualizer', + title: i18n.translate('xpack.serverlessObservability.nav.ml.data_visualizer', { + defaultMessage: 'Data visualizer', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:fileUpload', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_visualizer.file_data_visualizer', + { + defaultMessage: 'File data visualizer', + } + ), + }, + { + link: 'ml:indexDataVisualizer', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_visualizer.data_view_data_visualizer', + { + defaultMessage: 'Data view data visualizer', + } + ), + }, + { + link: 'ml:dataDrift', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.data_visualizer.data_drift', + { + defaultMessage: 'Data drift', + } + ), + }, + ], + }, + { + id: 'category-aiops_labs', + title: i18n.translate('xpack.serverlessObservability.nav.ml.aiops_labs', { + defaultMessage: 'Aiops labs', + }), + breadcrumbStatus: 'hidden', + children: [ + { + link: 'ml:logRateAnalysis', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.aiops_labs.log_rate_analysis', + { + defaultMessage: 'Log rate analysis', + } + ), + }, + { + link: 'ml:logPatternAnalysis', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.aiops_labs.log_pattern_analysis', + { + defaultMessage: 'Log pattern analysis', + } + ), + }, + { + link: 'ml:changePointDetections', + title: i18n.translate( + 'xpack.serverlessObservability.nav.ml.aiops_labs.change_point_detection', + { + defaultMessage: 'Change point detection', + } + ), + }, + ], + }, + ], + }, + ], + }, + ], + footer: [ + { + type: 'navItem', + title: i18n.translate('xpack.serverlessObservability.nav.getStarted', { + defaultMessage: 'Add data', + }), + link: 'observabilityOnboarding', + icon: 'launch', + }, + { + type: 'navItem', + id: 'devTools', + title: i18n.translate('xpack.serverlessObservability.nav.devTools', { + defaultMessage: 'Developer tools', + }), + link: 'dev_tools', + icon: 'editorCodeBlock', + }, + { + type: 'navGroup', + id: 'project_settings_project_nav', + title: i18n.translate('xpack.serverlessObservability.nav.projectSettings', { + defaultMessage: 'Project settings', + }), + icon: 'gear', + breadcrumbStatus: 'hidden', + children: [ + { + link: 'management', + title: i18n.translate('xpack.serverlessObservability.nav.mngt', { + defaultMessage: 'Management', + }), + }, + { + link: 'integrations', + }, + { + link: 'fleet', + }, + { + id: 'cloudLinkUserAndRoles', + cloudLink: 'userAndRoles', + }, + { + id: 'cloudLinkBilling', + cloudLink: 'billingAndSub', + }, + ], + }, + ], + }; }; diff --git a/x-pack/plugins/serverless_observability/public/plugin.ts b/x-pack/plugins/serverless_observability/public/plugin.ts index 05d598b2b3a7..774a76749f8d 100644 --- a/x-pack/plugins/serverless_observability/public/plugin.ts +++ b/x-pack/plugins/serverless_observability/public/plugin.ts @@ -8,8 +8,8 @@ import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { appCategories, appIds } from '@kbn/management-cards-navigation'; -import { of } from 'rxjs'; -import { navigationTree } from './navigation_tree'; +import { map, of } from 'rxjs'; +import { createNavigationTree } from './navigation_tree'; import { createObservabilityDashboardRegistration } from './logs_signal/overview_registration'; import { ServerlessObservabilityPublicSetup, @@ -50,7 +50,11 @@ export class ServerlessObservabilityPlugin setupDeps: ServerlessObservabilityPublicStartDependencies ): ServerlessObservabilityPublicStart { const { serverless, management, security } = setupDeps; - const navigationTree$ = of(navigationTree); + const navigationTree$ = (setupDeps.streams?.status$ || of({ status: 'disabled' })).pipe( + map(({ status }) => { + return createNavigationTree({ streamsAvailable: status === 'enabled' }); + }) + ); serverless.setProjectHome('/app/observability/landing'); serverless.initNavigation('oblt', navigationTree$, { dataTestSubj: 'svlObservabilitySideNav' }); const aiAssistantIsEnabled = core.application.capabilities.observabilityAIAssistant?.show; diff --git a/x-pack/plugins/serverless_observability/public/types.ts b/x-pack/plugins/serverless_observability/public/types.ts index c93865f0f596..23da6c12637d 100644 --- a/x-pack/plugins/serverless_observability/public/types.ts +++ b/x-pack/plugins/serverless_observability/public/types.ts @@ -8,13 +8,14 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DiscoverSetup } from '@kbn/discover-plugin/public'; import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public'; -import { ObservabilityPublicSetup } from '@kbn/observability-plugin/public'; -import { +import type { ObservabilityPublicSetup } from '@kbn/observability-plugin/public'; +import type { ObservabilitySharedPluginSetup, ObservabilitySharedPluginStart, } from '@kbn/observability-shared-plugin/public'; -import { SecurityPluginStart } from '@kbn/security-plugin/public'; -import { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public'; +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; +import type { ServerlessPluginSetup, ServerlessPluginStart } from '@kbn/serverless/public'; +import type { StreamsPluginStart, StreamsPluginSetup } from '@kbn/streams-plugin/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ServerlessObservabilityPublicSetup {} @@ -28,6 +29,7 @@ export interface ServerlessObservabilityPublicSetupDependencies { serverless: ServerlessPluginSetup; management: ManagementSetup; discover: DiscoverSetup; + streams?: StreamsPluginSetup; } export interface ServerlessObservabilityPublicStartDependencies { @@ -36,4 +38,5 @@ export interface ServerlessObservabilityPublicStartDependencies { management: ManagementStart; data: DataPublicPluginStart; security: SecurityPluginStart; + streams?: StreamsPluginStart; } diff --git a/x-pack/plugins/serverless_observability/tsconfig.json b/x-pack/plugins/serverless_observability/tsconfig.json index 3d909bf88b65..5aa97143107a 100644 --- a/x-pack/plugins/serverless_observability/tsconfig.json +++ b/x-pack/plugins/serverless_observability/tsconfig.json @@ -30,5 +30,6 @@ "@kbn/discover-plugin", "@kbn/security-plugin", "@kbn/search-types", + "@kbn/streams-plugin", ] } diff --git a/x-pack/plugins/streams/common/index.ts b/x-pack/plugins/streams/common/index.ts new file mode 100644 index 000000000000..3a7306e46cae --- /dev/null +++ b/x-pack/plugins/streams/common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { StreamDefinition } from './types'; diff --git a/x-pack/plugins/streams/public/api/index.ts b/x-pack/plugins/streams/public/api/index.ts new file mode 100644 index 000000000000..f64fc7f2fdce --- /dev/null +++ b/x-pack/plugins/streams/public/api/index.ts @@ -0,0 +1,52 @@ +/* + * 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 { StreamsRouteRepository } from '../../server'; + +type FetchOptions = Omit & { + body?: any; +}; + +export type StreamsRepositoryClientOptions = Omit< + FetchOptions, + 'query' | 'body' | 'pathname' | 'signal' +> & { + signal: AbortSignal | null; +}; + +export type StreamsRepositoryClient = RouteRepositoryClient< + StreamsRouteRepository, + StreamsRepositoryClientOptions +>; + +export type AutoAbortedStreamsRepositoryClient = RouteRepositoryClient< + StreamsRouteRepository, + Omit +>; + +export type StreamsRepositoryEndpoint = keyof StreamsRouteRepository; + +export type APIReturnType = ReturnOf< + StreamsRouteRepository, + TEndpoint +>; + +export type StreamsAPIClientRequestParamsOf = + ClientRequestParamsOf; + +export function createStreamsRepositoryClient( + core: CoreStart | CoreSetup +): StreamsRepositoryClient { + return createRepositoryClient(core); +} diff --git a/x-pack/plugins/streams/public/index.ts b/x-pack/plugins/streams/public/index.ts index 5b83ea1d297d..bc90fb0f4006 100644 --- a/x-pack/plugins/streams/public/index.ts +++ b/x-pack/plugins/streams/public/index.ts @@ -7,7 +7,12 @@ import { PluginInitializer, PluginInitializerContext } from '@kbn/core/public'; import { Plugin } from './plugin'; +import { StreamsPluginSetup, StreamsPluginStart } from './types'; -export const plugin: PluginInitializer<{}, {}> = (context: PluginInitializerContext) => { +export type { StreamsPluginSetup, StreamsPluginStart }; + +export const plugin: PluginInitializer = ( + context: PluginInitializerContext +) => { return new Plugin(context); }; diff --git a/x-pack/plugins/streams/public/plugin.ts b/x-pack/plugins/streams/public/plugin.ts index f35d18e06ff7..5a2ae3e06684 100644 --- a/x-pack/plugins/streams/public/plugin.ts +++ b/x-pack/plugins/streams/public/plugin.ts @@ -8,25 +8,55 @@ import { CoreSetup, CoreStart, PluginInitializerContext } from '@kbn/core/public'; import { Logger } from '@kbn/logging'; +import { createRepositoryClient } from '@kbn/server-route-repository-client'; +import { from, shareReplay, startWith } from 'rxjs'; +import { once } from 'lodash'; import type { StreamsPublicConfig } from '../common/config'; import { StreamsPluginClass, StreamsPluginSetup, StreamsPluginStart } from './types'; +import { StreamsRepositoryClient } from './api'; export class Plugin implements StreamsPluginClass { public config: StreamsPublicConfig; public logger: Logger; + private repositoryClient!: StreamsRepositoryClient; + constructor(context: PluginInitializerContext<{}>) { this.config = context.config.get(); this.logger = context.logger.get(); } - setup(core: CoreSetup, pluginSetup: StreamsPluginSetup) { - return {}; + setup(core: CoreSetup<{}>, pluginSetup: {}): StreamsPluginSetup { + this.repositoryClient = createRepositoryClient(core); + return { + status$: createStatusObservable(this.logger, this.repositoryClient), + }; } - start(core: CoreStart) { - return {}; + start(core: CoreStart, pluginsStart: {}): StreamsPluginStart { + return { + streamsRepositoryClient: this.repositoryClient, + status$: createStatusObservable(this.logger, this.repositoryClient), + }; } stop() {} } + +const createStatusObservable = once((logger: Logger, repositoryClient: StreamsRepositoryClient) => { + return from( + repositoryClient + .fetch('GET /api/streams/_status', { + signal: new AbortController().signal, + }) + .then( + (response) => ({ + status: response.enabled ? ('enabled' as const) : ('disabled' as const), + }), + (error) => { + logger.error(error); + return { status: 'unknown' as const }; + } + ) + ).pipe(startWith({ status: 'unknown' as const }), shareReplay(1)); +}); diff --git a/x-pack/plugins/streams/public/types.ts b/x-pack/plugins/streams/public/types.ts index 61e5fa94098f..fc88f2a6c20f 100644 --- a/x-pack/plugins/streams/public/types.ts +++ b/x-pack/plugins/streams/public/types.ts @@ -6,11 +6,16 @@ */ import type { Plugin as PluginClass } from '@kbn/core/public'; +import { Observable } from 'rxjs'; +import type { StreamsRepositoryClient } from './api'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface StreamsPluginSetup {} +export interface StreamsPluginSetup { + status$: Observable<{ status: 'unknown' | 'enabled' | 'disabled' }>; +} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface StreamsPluginStart {} +export interface StreamsPluginStart { + streamsRepositoryClient: StreamsRepositoryClient; + status$: Observable<{ status: 'unknown' | 'enabled' | 'disabled' }>; +} -export type StreamsPluginClass = PluginClass<{}, {}, StreamsPluginSetup, StreamsPluginStart>; +export type StreamsPluginClass = PluginClass; diff --git a/x-pack/plugins/streams/server/index.ts b/x-pack/plugins/streams/server/index.ts index bd8aee304ad1..9ef13c62d6b7 100644 --- a/x-pack/plugins/streams/server/index.ts +++ b/x-pack/plugins/streams/server/index.ts @@ -17,3 +17,5 @@ export const plugin = async (context: PluginInitializerContext) = const { StreamsPlugin } = await import('./plugin'); return new StreamsPlugin(context); }; + +export type { ListStreamResponse } from './lib/streams/stream_crud'; diff --git a/x-pack/plugins/streams/server/lib/streams/stream_crud.ts b/x-pack/plugins/streams/server/lib/streams/stream_crud.ts index 78a126905d9a..a74540cdcc62 100644 --- a/x-pack/plugins/streams/server/lib/streams/stream_crud.ts +++ b/x-pack/plugins/streams/server/lib/streams/stream_crud.ts @@ -7,29 +7,29 @@ import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { Logger } from '@kbn/logging'; -import { FieldDefinition, StreamDefinition } from '../../../common/types'; import { STREAMS_INDEX } from '../../../common/constants'; -import { DefinitionNotFound } from './errors'; -import { deleteTemplate, upsertTemplate } from './index_templates/manage_index_templates'; +import { FieldDefinition, StreamDefinition } from '../../../common/types'; import { generateLayer } from './component_templates/generate_layer'; -import { generateIngestPipeline } from './ingest_pipelines/generate_ingest_pipeline'; -import { generateReroutePipeline } from './ingest_pipelines/generate_reroute_pipeline'; -import { generateIndexTemplate } from './index_templates/generate_index_template'; import { deleteComponent, upsertComponent } from './component_templates/manage_component_templates'; -import { getIndexTemplateName } from './index_templates/name'; import { getComponentTemplateName } from './component_templates/name'; -import { getProcessingPipelineName, getReroutePipelineName } from './ingest_pipelines/name'; -import { - deleteIngestPipeline, - upsertIngestPipeline, -} from './ingest_pipelines/manage_ingest_pipelines'; -import { getAncestors } from './helpers/hierarchy'; -import { MalformedFields } from './errors/malformed_fields'; import { deleteDataStream, rolloverDataStreamIfNecessary, upsertDataStream, } from './data_streams/manage_data_streams'; +import { DefinitionNotFound } from './errors'; +import { MalformedFields } from './errors/malformed_fields'; +import { getAncestors } from './helpers/hierarchy'; +import { generateIndexTemplate } from './index_templates/generate_index_template'; +import { deleteTemplate, upsertTemplate } from './index_templates/manage_index_templates'; +import { getIndexTemplateName } from './index_templates/name'; +import { generateIngestPipeline } from './ingest_pipelines/generate_ingest_pipeline'; +import { generateReroutePipeline } from './ingest_pipelines/generate_reroute_pipeline'; +import { + deleteIngestPipeline, + upsertIngestPipeline, +} from './ingest_pipelines/manage_ingest_pipelines'; +import { getProcessingPipelineName, getReroutePipelineName } from './ingest_pipelines/name'; interface BaseParams { scopedClusterClient: IScopedClusterClient; @@ -88,23 +88,40 @@ async function upsertInternalStream({ definition, scopedClusterClient }: BasePar type ListStreamsParams = BaseParams; -export async function listStreams({ scopedClusterClient }: ListStreamsParams) { +export interface ListStreamResponse { + total: number; + definitions: StreamDefinition[]; +} + +export async function listStreams({ + scopedClusterClient, +}: ListStreamsParams): Promise { const response = await scopedClusterClient.asInternalUser.search({ index: STREAMS_INDEX, size: 10000, - fields: ['id'], - _source: false, sort: [{ id: 'asc' }], }); - const definitions = response.hits.hits.map((hit) => hit.fields as { id: string[] }); - return definitions; + const definitions = response.hits.hits.map((hit) => hit._source!); + const total = response.hits.total!; + + return { + definitions, + total: typeof total === 'number' ? total : total.value, + }; } interface ReadStreamParams extends BaseParams { id: string; } -export async function readStream({ id, scopedClusterClient }: ReadStreamParams) { +export interface ReadStreamResponse { + definition: StreamDefinition; +} + +export async function readStream({ + id, + scopedClusterClient, +}: ReadStreamParams): Promise { try { const response = await scopedClusterClient.asInternalUser.get({ id, @@ -126,12 +143,21 @@ interface ReadAncestorsParams extends BaseParams { id: string; } -export async function readAncestors({ id, scopedClusterClient }: ReadAncestorsParams) { +export interface ReadAncestorsResponse { + ancestors: Array<{ definition: StreamDefinition }>; +} + +export async function readAncestors({ + id, + scopedClusterClient, +}: ReadAncestorsParams): Promise { const ancestorIds = getAncestors(id); - return await Promise.all( - ancestorIds.map((ancestorId) => readStream({ scopedClusterClient, id: ancestorId })) - ); + return { + ancestors: await Promise.all( + ancestorIds.map((ancestorId) => readStream({ scopedClusterClient, id: ancestorId })) + ), + }; } interface ReadDescendantsParams extends BaseParams { @@ -167,7 +193,7 @@ export async function validateAncestorFields( id: string, fields: FieldDefinition[] ) { - const ancestors = await readAncestors({ + const { ancestors } = await readAncestors({ id, scopedClusterClient, }); diff --git a/x-pack/plugins/streams/server/plugin.ts b/x-pack/plugins/streams/server/plugin.ts index ef070984803d..937f8c22b5be 100644 --- a/x-pack/plugins/streams/server/plugin.ts +++ b/x-pack/plugins/streams/server/plugin.ts @@ -16,8 +16,7 @@ import { } from '@kbn/core/server'; import { registerRoutes } from '@kbn/server-route-repository'; import { StreamsConfig, configSchema, exposeToBrowserConfig } from '../common/config'; -import { StreamsRouteRepository } from './routes'; -import { RouteDependencies } from './routes/types'; +import { streamsRouteRepository } from './routes'; import { StreamsPluginSetupDependencies, StreamsPluginStartDependencies, @@ -58,8 +57,8 @@ export class StreamsPlugin logger: this.logger, } as StreamsServer; - registerRoutes({ - repository: StreamsRouteRepository, + registerRoutes({ + repository: streamsRouteRepository, dependencies: { server: this.server, getScopedClients: async ({ request }: { request: KibanaRequest }) => { diff --git a/x-pack/plugins/streams/server/routes/esql/route.ts b/x-pack/plugins/streams/server/routes/esql/route.ts new file mode 100644 index 000000000000..0e0e41eee3c7 --- /dev/null +++ b/x-pack/plugins/streams/server/routes/esql/route.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { excludeFrozenQuery } from '@kbn/observability-utils-common/es/queries/exclude_frozen_query'; +import { kqlQuery } from '@kbn/observability-utils-common/es/queries/kql_query'; +import { rangeQuery } from '@kbn/observability-utils-common/es/queries/range_query'; +import { + UnparsedEsqlResponse, + createObservabilityEsClient, +} from '@kbn/observability-utils-server/es/client/create_observability_es_client'; +import { z } from '@kbn/zod'; +import { isNumber } from 'lodash'; +import { createServerRoute } from '../create_server_route'; + +export const executeEsqlRoute = createServerRoute({ + endpoint: 'POST /internal/streams/esql', + params: z.object({ + body: z.object({ + query: z.string(), + operationName: z.string(), + filter: z.object({}).passthrough().optional(), + kuery: z.string().optional(), + start: z.number().optional(), + end: z.number().optional(), + }), + }), + handler: async ({ params, request, logger, getScopedClients }): Promise => { + const { scopedClusterClient } = await getScopedClients({ request }); + const observabilityEsClient = createObservabilityEsClient({ + client: scopedClusterClient.asCurrentUser, + logger, + plugin: 'streams', + }); + + const { + body: { operationName, query, filter, kuery, start, end }, + } = params; + + const response = await observabilityEsClient.esql( + operationName, + { + query, + filter: { + bool: { + filter: [ + filter || { match_all: {} }, + ...kqlQuery(kuery), + ...excludeFrozenQuery(), + ...(isNumber(start) && isNumber(end) ? rangeQuery(start, end) : []), + ], + }, + }, + }, + { transform: 'none' } + ); + + return response; + }, +}); + +export const esqlRoutes = { + ...executeEsqlRoute, +}; diff --git a/x-pack/plugins/streams/server/routes/index.ts b/x-pack/plugins/streams/server/routes/index.ts index 6fc734d3371b..7267dbedeacf 100644 --- a/x-pack/plugins/streams/server/routes/index.ts +++ b/x-pack/plugins/streams/server/routes/index.ts @@ -5,15 +5,18 @@ * 2.0. */ +import { esqlRoutes } from './esql/route'; import { deleteStreamRoute } from './streams/delete'; +import { disableStreamsRoute } from './streams/disable'; import { editStreamRoute } from './streams/edit'; import { enableStreamsRoute } from './streams/enable'; import { forkStreamsRoute } from './streams/fork'; import { listStreamsRoute } from './streams/list'; import { readStreamRoute } from './streams/read'; import { resyncStreamsRoute } from './streams/resync'; +import { streamsStatusRoutes } from './streams/settings'; -export const StreamsRouteRepository = { +export const streamsRouteRepository = { ...enableStreamsRoute, ...resyncStreamsRoute, ...forkStreamsRoute, @@ -21,6 +24,9 @@ export const StreamsRouteRepository = { ...editStreamRoute, ...deleteStreamRoute, ...listStreamsRoute, + ...streamsStatusRoutes, + ...esqlRoutes, + ...disableStreamsRoute, }; -export type StreamsRouteRepository = typeof StreamsRouteRepository; +export type StreamsRouteRepository = typeof streamsRouteRepository; diff --git a/x-pack/plugins/streams/server/routes/streams/delete.ts b/x-pack/plugins/streams/server/routes/streams/delete.ts index 3820975dbe16..369568ff9b7f 100644 --- a/x-pack/plugins/streams/server/routes/streams/delete.ts +++ b/x-pack/plugins/streams/server/routes/streams/delete.ts @@ -8,6 +8,7 @@ import { z } from '@kbn/zod'; import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { Logger } from '@kbn/logging'; +import { badRequest, internal, notFound } from '@hapi/boom'; import { DefinitionNotFound, ForkConditionMissing, @@ -20,18 +21,15 @@ import { MalformedStreamId } from '../../lib/streams/errors/malformed_stream_id' import { getParentId } from '../../lib/streams/helpers/hierarchy'; export const deleteStreamRoute = createServerRoute({ - endpoint: 'DELETE /api/streams/{id} 2023-10-31', + endpoint: 'DELETE /api/streams/{id}', options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, params: z.object({ @@ -39,7 +37,13 @@ export const deleteStreamRoute = createServerRoute({ id: z.string(), }), }), - handler: async ({ response, params, logger, request, getScopedClients }) => { + handler: async ({ + response, + params, + logger, + request, + getScopedClients, + }): Promise<{ acknowledged: true }> => { try { const { scopedClusterClient } = await getScopedClients({ request }); @@ -52,10 +56,10 @@ export const deleteStreamRoute = createServerRoute({ await deleteStream(scopedClusterClient, params.path.id, logger); - return response.ok({ body: { acknowledged: true } }); + return { acknowledged: true }; } catch (e) { if (e instanceof IndexTemplateNotFound || e instanceof DefinitionNotFound) { - return response.notFound({ body: e }); + throw notFound(e); } if ( @@ -63,15 +67,19 @@ export const deleteStreamRoute = createServerRoute({ e instanceof ForkConditionMissing || e instanceof MalformedStreamId ) { - return response.customError({ body: e, statusCode: 400 }); + throw badRequest(e); } - return response.customError({ body: e, statusCode: 500 }); + throw internal(e); } }, }); -async function deleteStream(scopedClusterClient: IScopedClusterClient, id: string, logger: Logger) { +export async function deleteStream( + scopedClusterClient: IScopedClusterClient, + id: string, + logger: Logger +) { try { const { definition } = await readStream({ scopedClusterClient, id }); for (const child of definition.children) { diff --git a/x-pack/plugins/streams/server/routes/streams/disable.ts b/x-pack/plugins/streams/server/routes/streams/disable.ts new file mode 100644 index 000000000000..b760b58f1faf --- /dev/null +++ b/x-pack/plugins/streams/server/routes/streams/disable.ts @@ -0,0 +1,44 @@ +/* + * 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 { badRequest, internal } from '@hapi/boom'; +import { z } from '@kbn/zod'; +import { SecurityException } from '../../lib/streams/errors'; +import { createServerRoute } from '../create_server_route'; +import { deleteStream } from './delete'; + +export const disableStreamsRoute = createServerRoute({ + endpoint: 'POST /api/streams/_disable', + params: z.object({}), + options: { + access: 'internal', + }, + security: { + authz: { + requiredPrivileges: ['streams_write'], + }, + }, + handler: async ({ + request, + response, + logger, + getScopedClients, + }): Promise<{ acknowledged: true }> => { + try { + const { scopedClusterClient } = await getScopedClients({ request }); + + await deleteStream(scopedClusterClient, 'logs', logger); + + return { acknowledged: true }; + } catch (e) { + if (e instanceof SecurityException) { + throw badRequest(e); + } + throw internal(e); + } + }, +}); diff --git a/x-pack/plugins/streams/server/routes/streams/edit.ts b/x-pack/plugins/streams/server/routes/streams/edit.ts index b82b4d54044d..378f1ba3c8f0 100644 --- a/x-pack/plugins/streams/server/routes/streams/edit.ts +++ b/x-pack/plugins/streams/server/routes/streams/edit.ts @@ -8,6 +8,7 @@ import { z } from '@kbn/zod'; import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { Logger } from '@kbn/logging'; +import { badRequest, internal, notFound } from '@hapi/boom'; import { DefinitionNotFound, ForkConditionMissing, @@ -28,18 +29,15 @@ import { getParentId } from '../../lib/streams/helpers/hierarchy'; import { MalformedChildren } from '../../lib/streams/errors/malformed_children'; export const editStreamRoute = createServerRoute({ - endpoint: 'PUT /api/streams/{id} 2023-10-31', + endpoint: 'PUT /api/streams/{id}', options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, params: z.object({ @@ -99,10 +97,10 @@ export const editStreamRoute = createServerRoute({ }); } - return response.ok({ body: { acknowledged: true } }); + return { acknowledged: true }; } catch (e) { if (e instanceof IndexTemplateNotFound || e instanceof DefinitionNotFound) { - return response.notFound({ body: e }); + throw notFound(e); } if ( @@ -110,10 +108,10 @@ export const editStreamRoute = createServerRoute({ e instanceof ForkConditionMissing || e instanceof MalformedStreamId ) { - return response.customError({ body: e, statusCode: 400 }); + throw badRequest(e); } - return response.customError({ body: e, statusCode: 500 }); + throw internal(e); } }, }); diff --git a/x-pack/plugins/streams/server/routes/streams/enable.ts b/x-pack/plugins/streams/server/routes/streams/enable.ts index 27d8929b28e5..e163c6cbc8bb 100644 --- a/x-pack/plugins/streams/server/routes/streams/enable.ts +++ b/x-pack/plugins/streams/server/routes/streams/enable.ts @@ -6,6 +6,7 @@ */ import { z } from '@kbn/zod'; +import { badRequest, internal } from '@hapi/boom'; import { SecurityException } from '../../lib/streams/errors'; import { createServerRoute } from '../create_server_route'; import { syncStream } from '../../lib/streams/stream_crud'; @@ -13,22 +14,24 @@ import { rootStreamDefinition } from '../../lib/streams/root_stream_definition'; import { createStreamsIndex } from '../../lib/streams/internal_stream_mapping'; export const enableStreamsRoute = createServerRoute({ - endpoint: 'POST /api/streams/_enable 2023-10-31', + endpoint: 'POST /api/streams/_enable', params: z.object({}), options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, - handler: async ({ request, response, logger, getScopedClients }) => { + handler: async ({ + request, + response, + logger, + getScopedClients, + }): Promise<{ acknowledged: true }> => { try { const { scopedClusterClient } = await getScopedClients({ request }); await createStreamsIndex(scopedClusterClient); @@ -37,12 +40,12 @@ export const enableStreamsRoute = createServerRoute({ definition: rootStreamDefinition, logger, }); - return response.ok({ body: { acknowledged: true } }); + return { acknowledged: true }; } catch (e) { if (e instanceof SecurityException) { - return response.customError({ body: e, statusCode: 400 }); + throw badRequest(e); } - return response.customError({ body: e, statusCode: 500 }); + throw internal(e); } }, }); diff --git a/x-pack/plugins/streams/server/routes/streams/fork.ts b/x-pack/plugins/streams/server/routes/streams/fork.ts index 44f405287800..8294aebb0b0e 100644 --- a/x-pack/plugins/streams/server/routes/streams/fork.ts +++ b/x-pack/plugins/streams/server/routes/streams/fork.ts @@ -6,6 +6,7 @@ */ import { z } from '@kbn/zod'; +import { badRequest, internal, notFound } from '@hapi/boom'; import { DefinitionNotFound, ForkConditionMissing, @@ -19,18 +20,15 @@ import { MalformedStreamId } from '../../lib/streams/errors/malformed_stream_id' import { isChildOf } from '../../lib/streams/helpers/hierarchy'; export const forkStreamsRoute = createServerRoute({ - endpoint: 'POST /api/streams/{id}/_fork 2023-10-31', + endpoint: 'POST /api/streams/{id}/_fork', options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, params: z.object({ @@ -39,7 +37,12 @@ export const forkStreamsRoute = createServerRoute({ }), body: z.object({ stream: streamDefinitonWithoutChildrenSchema, condition: conditionSchema }), }), - handler: async ({ response, params, logger, request, getScopedClients }) => { + handler: async ({ + params, + logger, + request, + getScopedClients, + }): Promise<{ acknowledged: true }> => { try { if (!params.body.condition) { throw new ForkConditionMissing('You must provide a condition to fork a stream'); @@ -92,10 +95,10 @@ export const forkStreamsRoute = createServerRoute({ logger, }); - return response.ok({ body: { acknowledged: true } }); + return { acknowledged: true }; } catch (e) { if (e instanceof IndexTemplateNotFound || e instanceof DefinitionNotFound) { - return response.notFound({ body: e }); + throw notFound(e); } if ( @@ -103,10 +106,10 @@ export const forkStreamsRoute = createServerRoute({ e instanceof ForkConditionMissing || e instanceof MalformedStreamId ) { - return response.customError({ body: e, statusCode: 400 }); + throw badRequest(e); } - return response.customError({ body: e, statusCode: 500 }); + throw internal(e); } }, }); diff --git a/x-pack/plugins/streams/server/routes/streams/list.ts b/x-pack/plugins/streams/server/routes/streams/list.ts index 2e4f13a89bb4..bd6a5200fe9b 100644 --- a/x-pack/plugins/streams/server/routes/streams/list.ts +++ b/x-pack/plugins/streams/server/routes/streams/list.ts @@ -6,65 +6,78 @@ */ import { z } from '@kbn/zod'; +import { notFound, internal } from '@hapi/boom'; import { createServerRoute } from '../create_server_route'; import { DefinitionNotFound } from '../../lib/streams/errors'; import { listStreams } from '../../lib/streams/stream_crud'; +import { StreamDefinition } from '../../../common'; export const listStreamsRoute = createServerRoute({ - endpoint: 'GET /api/streams 2023-10-31', + endpoint: 'GET /api/streams', options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, params: z.object({}), - handler: async ({ response, request, getScopedClients }) => { + handler: async ({ + response, + request, + getScopedClients, + }): Promise<{ definitions: StreamDefinition[]; trees: StreamTree[] }> => { try { const { scopedClusterClient } = await getScopedClients({ request }); - const definitions = await listStreams({ scopedClusterClient }); + const { definitions } = await listStreams({ scopedClusterClient }); const trees = asTrees(definitions); - return response.ok({ body: { streams: trees } }); + return { definitions, trees }; } catch (e) { if (e instanceof DefinitionNotFound) { - return response.notFound({ body: e }); + throw notFound(e); } - return response.customError({ body: e, statusCode: 500 }); + throw internal(e); } }, }); -interface ListStreamDefinition { +export interface StreamTree { id: string; - children: ListStreamDefinition[]; + children: StreamTree[]; } -function asTrees(definitions: Array<{ id: string[] }>) { - const trees: ListStreamDefinition[] = []; +function asTrees(definitions: StreamDefinition[]): StreamTree[] { + const nodes = new Map(); + + const rootNodes = new Set(); + + function getNode(id: string) { + let node = nodes.get(id); + if (!node) { + node = { id, children: [] }; + nodes.set(id, node); + } + return node; + } + definitions.forEach((definition) => { - const path = definition.id[0].split('.'); - let currentTree = trees; - path.forEach((_id, index) => { - const partialPath = path.slice(0, index + 1).join('.'); - const existingNode = currentTree.find((node) => node.id === partialPath); - if (existingNode) { - currentTree = existingNode.children; - } else { - const newNode = { id: partialPath, children: [] }; - currentTree.push(newNode); - currentTree = newNode.children; - } - }); + const path = definition.id.split('.'); + const parentId = path.slice(0, path.length - 1).join('.'); + const parentNode = parentId.length ? getNode(parentId) : undefined; + const selfNode = getNode(definition.id); + + if (parentNode) { + parentNode.children.push(selfNode); + } else { + rootNodes.add(selfNode); + } }); - return trees; + + return Array.from(rootNodes.values()); } diff --git a/x-pack/plugins/streams/server/routes/streams/read.ts b/x-pack/plugins/streams/server/routes/streams/read.ts index 5ea2aaf5f254..b9d21ef25b67 100644 --- a/x-pack/plugins/streams/server/routes/streams/read.ts +++ b/x-pack/plugins/streams/server/routes/streams/read.ts @@ -6,29 +6,38 @@ */ import { z } from '@kbn/zod'; +import { notFound, internal } from '@hapi/boom'; import { createServerRoute } from '../create_server_route'; import { DefinitionNotFound } from '../../lib/streams/errors'; import { readAncestors, readStream } from '../../lib/streams/stream_crud'; +import { StreamDefinition } from '../../../common'; export const readStreamRoute = createServerRoute({ - endpoint: 'GET /api/streams/{id} 2023-10-31', + endpoint: 'GET /api/streams/{id}', options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, params: z.object({ path: z.object({ id: z.string() }), }), - handler: async ({ response, params, request, getScopedClients }) => { + handler: async ({ + response, + params, + request, + logger, + getScopedClients, + }): Promise< + StreamDefinition & { + inheritedFields: Array; + } + > => { try { const { scopedClusterClient } = await getScopedClients({ request }); const streamEntity = await readStream({ @@ -36,7 +45,7 @@ export const readStreamRoute = createServerRoute({ id: params.path.id, }); - const ancestors = await readAncestors({ + const { ancestors } = await readAncestors({ id: streamEntity.definition.id, scopedClusterClient, }); @@ -48,13 +57,13 @@ export const readStreamRoute = createServerRoute({ ), }; - return response.ok({ body }); + return body; } catch (e) { if (e instanceof DefinitionNotFound) { - return response.notFound({ body: e }); + throw notFound(e); } - return response.customError({ body: e, statusCode: 500 }); + throw internal(e); } }, }); diff --git a/x-pack/plugins/streams/server/routes/streams/resync.ts b/x-pack/plugins/streams/server/routes/streams/resync.ts index 2365252ab00e..8e520410ca5c 100644 --- a/x-pack/plugins/streams/server/routes/streams/resync.ts +++ b/x-pack/plugins/streams/server/routes/streams/resync.ts @@ -10,31 +10,34 @@ import { createServerRoute } from '../create_server_route'; import { syncStream, readStream, listStreams } from '../../lib/streams/stream_crud'; export const resyncStreamsRoute = createServerRoute({ - endpoint: 'POST /api/streams/_resync 2023-10-31', + endpoint: 'POST /api/streams/_resync', options: { - access: 'public', - availability: { - stability: 'experimental', - }, - security: { - authz: { - enabled: false, - reason: - 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', - }, + access: 'internal', + }, + security: { + authz: { + enabled: false, + reason: + 'This API delegates security to the currently logged in user and their Elasticsearch permissions.', }, }, params: z.object({}), - handler: async ({ response, logger, request, getScopedClients }) => { + handler: async ({ + response, + logger, + request, + getScopedClients, + }): Promise<{ acknowledged: true }> => { const { scopedClusterClient } = await getScopedClients({ request }); - const streams = await listStreams({ scopedClusterClient }); + const { definitions: streams } = await listStreams({ scopedClusterClient }); for (const stream of streams) { const { definition } = await readStream({ scopedClusterClient, - id: stream.id[0], + id: stream.id, }); + await syncStream({ scopedClusterClient, definition, @@ -42,6 +45,6 @@ export const resyncStreamsRoute = createServerRoute({ }); } - return response.ok({}); + return { acknowledged: true }; }, }); diff --git a/x-pack/plugins/streams/server/routes/streams/settings.ts b/x-pack/plugins/streams/server/routes/streams/settings.ts new file mode 100644 index 000000000000..6e133b3948dd --- /dev/null +++ b/x-pack/plugins/streams/server/routes/streams/settings.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { STREAMS_INDEX } from '../../../common/constants'; +import { createServerRoute } from '../create_server_route'; + +export const getStreamsStatusRoute = createServerRoute({ + endpoint: 'GET /api/streams/_status', + options: { + access: 'internal', + }, + security: { + authz: { + requiredPrivileges: ['streams_read'], + }, + }, + handler: async ({ request, getScopedClients }): Promise<{ enabled: boolean }> => { + const { scopedClusterClient } = await getScopedClients({ request }); + + return { + enabled: await scopedClusterClient.asInternalUser.indices.exists({ + index: STREAMS_INDEX, + }), + }; + }, +}); + +export const streamsStatusRoutes = { + ...getStreamsStatusRoute, +}; diff --git a/x-pack/plugins/streams/tsconfig.json b/x-pack/plugins/streams/tsconfig.json index c2fde35f9ca2..3f863145f4d2 100644 --- a/x-pack/plugins/streams/tsconfig.json +++ b/x-pack/plugins/streams/tsconfig.json @@ -27,5 +27,8 @@ "@kbn/zod", "@kbn/encrypted-saved-objects-plugin", "@kbn/licensing-plugin", + "@kbn/server-route-repository-client", + "@kbn/observability-utils-server", + "@kbn/observability-utils-common" ] } diff --git a/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx b/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx new file mode 100644 index 000000000000..1660042b2cb6 --- /dev/null +++ b/x-pack/plugins/streams_app/.storybook/get_mock_streams_app_context.tsx @@ -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 { coreMock } from '@kbn/core/public/mocks'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { StreamsPluginStart } from '@kbn/streams-plugin/public'; +import type { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { SharePublicStart } from '@kbn/share-plugin/public/plugin'; +import type { StreamsAppKibanaContext } from '../public/hooks/use_kibana'; + +export function getMockStreamsAppContext(): StreamsAppKibanaContext { + const core = coreMock.createStart(); + + return { + core, + dependencies: { + start: { + observabilityShared: {} as unknown as ObservabilitySharedPluginStart, + dataViews: {} as unknown as DataViewsPublicPluginStart, + data: {} as unknown as DataPublicPluginStart, + unifiedSearch: {} as unknown as UnifiedSearchPublicPluginStart, + streams: {} as unknown as StreamsPluginStart, + share: {} as unknown as SharePublicStart, + }, + }, + services: { + query: jest.fn(), + }, + }; +} diff --git a/x-pack/plugins/streams_app/.storybook/jest_setup.js b/x-pack/plugins/streams_app/.storybook/jest_setup.js new file mode 100644 index 000000000000..32071b8aa3f6 --- /dev/null +++ b/x-pack/plugins/streams_app/.storybook/jest_setup.js @@ -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); diff --git a/x-pack/plugins/streams_app/.storybook/main.js b/x-pack/plugins/streams_app/.storybook/main.js new file mode 100644 index 000000000000..86b48c32f103 --- /dev/null +++ b/x-pack/plugins/streams_app/.storybook/main.js @@ -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; diff --git a/x-pack/plugins/streams_app/.storybook/preview.js b/x-pack/plugins/streams_app/.storybook/preview.js new file mode 100644 index 000000000000..c8155e9c3d92 --- /dev/null +++ b/x-pack/plugins/streams_app/.storybook/preview.js @@ -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]; diff --git a/x-pack/plugins/streams_app/.storybook/storybook_decorator.tsx b/x-pack/plugins/streams_app/.storybook/storybook_decorator.tsx new file mode 100644 index 000000000000..617b5aee8128 --- /dev/null +++ b/x-pack/plugins/streams_app/.storybook/storybook_decorator.tsx @@ -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 { StreamsAppContextProvider } from '../public/components/streams_app_context_provider'; +import { getMockStreamsAppContext } from './get_mock_streams_app_context'; + +export function KibanaReactStorybookDecorator(Story: ComponentType) { + const context = useMemo(() => getMockStreamsAppContext(), []); + return ( + + + + ); +} diff --git a/x-pack/plugins/streams_app/README.md b/x-pack/plugins/streams_app/README.md new file mode 100644 index 000000000000..6e03524f9274 --- /dev/null +++ b/x-pack/plugins/streams_app/README.md @@ -0,0 +1,3 @@ +# Streams app + +Home of the Streams app plugin, which allows users to manage Streams via the UI. diff --git a/x-pack/plugins/streams_app/common/entity_source_query.ts b/x-pack/plugins/streams_app/common/entity_source_query.ts new file mode 100644 index 000000000000..c076d1f6bd87 --- /dev/null +++ b/x-pack/plugins/streams_app/common/entity_source_query.ts @@ -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. + */ + +export function entitySourceQuery({ entity }: { entity: Record }) { + return { + bool: { + filter: Object.entries(entity).map(([key, value]) => ({ term: { [key]: value } })), + }, + }; +} diff --git a/x-pack/plugins/streams_app/common/index.ts b/x-pack/plugins/streams_app/common/index.ts new file mode 100644 index 000000000000..c41a05b84d30 --- /dev/null +++ b/x-pack/plugins/streams_app/common/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { StreamDefinition } from '@kbn/streams-plugin/common'; + +interface EntityBase { + type: string; + displayName: string; + properties: Record; +} + +export type StreamEntity = EntityBase & { type: 'stream'; properties: StreamDefinition }; + +export type Entity = StreamEntity; + +export interface EntityTypeDefinition { + displayName: string; +} diff --git a/x-pack/plugins/streams_app/jest.config.js b/x-pack/plugins/streams_app/jest.config.js new file mode 100644 index 000000000000..d9c01c40a322 --- /dev/null +++ b/x-pack/plugins/streams_app/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: [ + '/x-pack/plugins/streams_app/public', + '/x-pack/plugins/streams_app/common', + '/x-pack/plugins/streams_app/server', + ], + setupFiles: ['/x-pack/plugins/streams_app/.storybook/jest_setup.js'], + collectCoverage: true, + collectCoverageFrom: [ + '/x-pack/plugins/streams_app/{public,common,server}/**/*.{js,ts,tsx}', + ], + + coverageReporters: ['html'], +}; diff --git a/x-pack/plugins/streams_app/kibana.jsonc b/x-pack/plugins/streams_app/kibana.jsonc new file mode 100644 index 000000000000..16666084c53e --- /dev/null +++ b/x-pack/plugins/streams_app/kibana.jsonc @@ -0,0 +1,26 @@ +{ + "type": "plugin", + "id": "@kbn/streams-app-plugin", + "owner": "@simianhacker @flash1293 @dgieselaar", + "group": "observability", + "visibility": "private", + "plugin": { + "id": "streamsApp", + "server": true, + "browser": true, + "configPath": ["xpack", "streamsApp"], + "requiredPlugins": [ + "streams", + "observabilityShared", + "data", + "dataViews", + "unifiedSearch", + "share" + ], + "requiredBundles": [ + "kibanaReact" + ], + "optionalPlugins": [], + "extraPublicDirs": [] + } +} diff --git a/x-pack/plugins/streams_app/public/application.tsx b/x-pack/plugins/streams_app/public/application.tsx new file mode 100644 index 000000000000..720f785ecde4 --- /dev/null +++ b/x-pack/plugins/streams_app/public/application.tsx @@ -0,0 +1,49 @@ +/* + * 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 { APP_WRAPPER_CLASS, type AppMountParameters, type CoreStart } from '@kbn/core/public'; +import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { css } from '@emotion/css'; +import type { StreamsAppStartDependencies } from './types'; +import { StreamsAppServices } from './services/types'; +import { AppRoot } from './components/app_root'; + +export const renderApp = ({ + coreStart, + pluginsStart, + services, + appMountParameters, +}: { + coreStart: CoreStart; + pluginsStart: StreamsAppStartDependencies; + services: StreamsAppServices; +} & { appMountParameters: AppMountParameters }) => { + const { element } = appMountParameters; + + const appWrapperClassName = css` + overflow: auto; + `; + const appWrapperElement = document.getElementsByClassName(APP_WRAPPER_CLASS)[1]; + appWrapperElement.classList.add(appWrapperClassName); + + ReactDOM.render( + + + , + element + ); + return () => { + ReactDOM.unmountComponentAtNode(element); + appWrapperElement.classList.remove(APP_WRAPPER_CLASS); + }; +}; diff --git a/x-pack/plugins/streams_app/public/components/app_root/index.tsx b/x-pack/plugins/streams_app/public/components/app_root/index.tsx new file mode 100644 index 000000000000..c5e8b78ae215 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/app_root/index.tsx @@ -0,0 +1,73 @@ +/* + * 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 { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import React from 'react'; +import { type AppMountParameters, type CoreStart } from '@kbn/core/public'; +import { + BreadcrumbsContextProvider, + RouteRenderer, + RouterProvider, +} from '@kbn/typed-react-router-config'; +import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { StreamsAppContextProvider } from '../streams_app_context_provider'; +import { streamsAppRouter } from '../../routes/config'; +import { StreamsAppStartDependencies } from '../../types'; +import { StreamsAppServices } from '../../services/types'; + +export function AppRoot({ + coreStart, + pluginsStart, + services, + appMountParameters, +}: { + coreStart: CoreStart; + pluginsStart: StreamsAppStartDependencies; + services: StreamsAppServices; +} & { appMountParameters: AppMountParameters }) { + const { history } = appMountParameters; + + const context = { + core: coreStart, + dependencies: { + start: pluginsStart, + }, + services, + }; + + return ( + + + + + + + + + + + ); +} + +export function StreamsAppHeaderActionMenu({ + appMountParameters, +}: { + appMountParameters: AppMountParameters; +}) { + const { setHeaderActionMenu, theme$ } = appMountParameters; + + return ( + + + + <> + + + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx b/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx new file mode 100644 index 000000000000..d2ce4859e66d --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/entity_detail_view/index.tsx @@ -0,0 +1,102 @@ +/* + * 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, EuiIcon, EuiLink, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useStreamsAppBreadcrumbs } from '../../hooks/use_streams_app_breadcrumbs'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; +import { EntityOverviewTabList } from '../entity_overview_tab_list'; +import { LoadingPanel } from '../loading_panel'; +import { StreamsAppPageBody } from '../streams_app_page_body'; +import { StreamsAppPageHeader } from '../streams_app_page_header'; +import { StreamsAppPageHeaderTitle } from '../streams_app_page_header/streams_app_page_header_title'; + +export interface EntityViewTab { + name: string; + label: string; + content: React.ReactElement; +} + +export function EntityDetailViewWithoutParams({ + selectedTab, + tabs, + entity, +}: { + selectedTab: string; + tabs: EntityViewTab[]; + entity: { + displayName?: string; + id: string; + }; +}) { + const router = useStreamsAppRouter(); + useStreamsAppBreadcrumbs(() => { + if (!entity.displayName) { + return []; + } + + return [ + { + title: entity.displayName, + path: `/{key}`, + params: { path: { key: entity.id } }, + } as const, + ]; + }, [entity.displayName, entity.id]); + + if (!entity.displayName) { + return ; + } + + const tabMap = Object.fromEntries( + tabs.map((tab) => { + return [ + tab.name, + { + href: router.link('/{key}/{tab}', { + path: { key: entity.id, tab: tab.name }, + }), + label: tab.label, + content: tab.content, + }, + ]; + }) + ); + + const selectedTabObject = tabMap[selectedTab]; + + return ( + + + + + + {i18n.translate('xpack.streams.entityDetailView.goBackLinkLabel', { + defaultMessage: 'Back', + })} + + + + } + > + { + return { + name: tabKey, + label, + href, + selected: selectedTab === tabKey, + }; + })} + /> + + {selectedTabObject.content} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/entity_detail_view_header_section/index.tsx b/x-pack/plugins/streams_app/public/components/entity_detail_view_header_section/index.tsx new file mode 100644 index 000000000000..4aafb7a7e9bc --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/entity_detail_view_header_section/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGroup, EuiText } from '@elastic/eui'; +import { css } from '@emotion/css'; +import React from 'react'; + +export function EntityDetailViewHeaderSection({ + title, + children, +}: { + title: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + {title} + + {children} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/entity_overview_tab_list/index.tsx b/x-pack/plugins/streams_app/public/components/entity_overview_tab_list/index.tsx new file mode 100644 index 000000000000..08502b26f7ca --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/entity_overview_tab_list/index.tsx @@ -0,0 +1,32 @@ +/* + * 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 { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export function EntityOverviewTabList< + T extends { name: string; label: string; href: string; selected: boolean } +>({ tabs }: { tabs: T[] }) { + const theme = useEuiTheme().euiTheme; + + return ( + + {tabs.map((tab) => { + return ( + + {tab.label} + + ); + })} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/esql_chart/controlled_esql_chart.tsx b/x-pack/plugins/streams_app/public/components/esql_chart/controlled_esql_chart.tsx new file mode 100644 index 000000000000..9008f8ee4709 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/esql_chart/controlled_esql_chart.tsx @@ -0,0 +1,174 @@ +/* + * 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, { useMemo } from 'react'; +import { + AreaSeries, + Axis, + BarSeries, + Chart, + CurveType, + LineSeries, + Position, + ScaleType, + Settings, + Tooltip, + niceTimeFormatter, +} from '@elastic/charts'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { getTimeZone } from '@kbn/observability-utils-browser/utils/ui_settings/get_timezone'; +import { css } from '@emotion/css'; +import { AbortableAsyncState } from '@kbn/observability-utils-browser/hooks/use_abortable_async'; +import type { UnparsedEsqlResponse } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; +import { esqlResultToTimeseries } from '../../util/esql_result_to_timeseries'; +import { useKibana } from '../../hooks/use_kibana'; +import { LoadingPanel } from '../loading_panel'; + +const END_ZONE_LABEL = i18n.translate('xpack.streams.esqlChart.endzone', { + defaultMessage: + 'The selected time range does not include this entire bucket. It might contain partial data.', +}); + +function getChartType(type: 'area' | 'bar' | 'line') { + switch (type) { + case 'area': + return AreaSeries; + case 'bar': + return BarSeries; + default: + return LineSeries; + } +} + +export function ControlledEsqlChart({ + id, + result, + metricNames, + chartType = 'line', + height, +}: { + id: string; + result: AbortableAsyncState; + metricNames: T[]; + chartType?: 'area' | 'bar' | 'line'; + height: number; +}) { + const { + core: { uiSettings }, + } = useKibana(); + + const allTimeseries = useMemo( + () => + esqlResultToTimeseries({ + result, + metricNames, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [result, ...metricNames] + ); + + if (result.loading && !result.value?.values.length) { + return ( + + ); + } + + const xValues = allTimeseries.flatMap(({ data }) => data.map(({ x }) => x)); + + const min = Math.min(...xValues); + const max = Math.max(...xValues); + + const isEmpty = min === 0 && max === 0; + + const xFormatter = niceTimeFormatter([min, max]); + + const xDomain = isEmpty ? { min: 0, max: 1 } : { min, max }; + + const yTickFormat = (value: number | null) => (value === null ? '' : String(value)); + const yLabelFormat = (label: string) => label; + + const timeZone = getTimeZone(uiSettings); + + return ( + + { + const formattedValue = xFormatter(value); + if (max === value) { + return ( + <> + + + + + {END_ZONE_LABEL} + + + {formattedValue} + + ); + } + return formattedValue; + }} + /> + + + + {allTimeseries.map((serie) => { + const Series = getChartType(chartType); + + return ( + + ); + })} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/loading_panel/index.tsx b/x-pack/plugins/streams_app/public/components/loading_panel/index.tsx new file mode 100644 index 000000000000..58e432b84a4b --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/loading_panel/index.tsx @@ -0,0 +1,35 @@ +/* + * 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, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +export function LoadingPanel({ + loading = true, + size, + className, +}: { + loading?: boolean; + size?: React.ComponentProps['size']; + className?: string; +}) { + if (!loading) { + return null; + } + + return ( + + + + + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/not_found/index.tsx b/x-pack/plugins/streams_app/public/components/not_found/index.tsx new file mode 100644 index 000000000000..c0c1d50000cf --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/not_found/index.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiCallOut } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +export function NotFound() { + return ( + + {i18n.translate('xpack.streams.notFound.calloutLabel', { + defaultMessage: 'The current page can not be found.', + })} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/redirect_to/index.tsx b/x-pack/plugins/streams_app/public/components/redirect_to/index.tsx new file mode 100644 index 000000000000..2bde67faa3b9 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/redirect_to/index.tsx @@ -0,0 +1,27 @@ +/* + * 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, { useLayoutEffect } from 'react'; +import { PathsOf, TypeOf } from '@kbn/typed-react-router-config'; +import { DeepPartial } from 'utility-types'; +import { merge } from 'lodash'; +import { StreamsAppRoutes } from '../../routes/config'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; +import { useStreamsAppParams } from '../../hooks/use_streams_app_params'; + +export function RedirectTo< + TPath extends PathsOf, + TParams extends TypeOf +>({ path, params }: { path: TPath; params?: DeepPartial }) { + const router = useStreamsAppRouter(); + const currentParams = useStreamsAppParams('/*'); + useLayoutEffect(() => { + router.replace(path, ...([merge({}, currentParams, params)] as any)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>; +} diff --git a/x-pack/plugins/streams_app/public/components/stream_detail_overview/index.tsx b/x-pack/plugins/streams_app/public/components/stream_detail_overview/index.tsx new file mode 100644 index 000000000000..93a573fd4c01 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/stream_detail_overview/index.tsx @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { calculateAuto } from '@kbn/calculate-auto'; +import { i18n } from '@kbn/i18n'; +import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range'; +import { StreamDefinition } from '@kbn/streams-plugin/common'; +import moment from 'moment'; +import React, { useMemo } from 'react'; +import { useKibana } from '../../hooks/use_kibana'; +import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; +import { ControlledEsqlChart } from '../esql_chart/controlled_esql_chart'; +import { StreamsAppSearchBar } from '../streams_app_search_bar'; + +export function StreamDetailOverview({ definition }: { definition?: StreamDefinition }) { + const { + dependencies: { + start: { + data, + dataViews, + streams: { streamsRepositoryClient }, + share, + }, + }, + } = useKibana(); + + const { + timeRange, + absoluteTimeRange: { start, end }, + setTimeRange, + } = useDateRange({ data }); + + const indexPatterns = useMemo(() => { + if (!definition?.id) { + return undefined; + } + + const isRoot = definition.id.indexOf('.') === -1; + + const dataStreamOfDefinition = definition.id; + + return isRoot + ? [dataStreamOfDefinition, `${dataStreamOfDefinition}.*`] + : [`${dataStreamOfDefinition}*`]; + }, [definition?.id]); + + const discoverLocator = useMemo( + () => share.url.locators.get('DISCOVER_APP_LOCATOR'), + [share.url.locators] + ); + + const queries = useMemo(() => { + if (!indexPatterns) { + return undefined; + } + + const baseQuery = `FROM ${indexPatterns.join(', ')}`; + + const bucketSize = Math.round( + calculateAuto.atLeast(50, moment.duration(1, 'minute'))!.asSeconds() + ); + + const histogramQuery = `${baseQuery} | STATS metric = COUNT(*) BY @timestamp = BUCKET(@timestamp, ${bucketSize} seconds)`; + + return { + baseQuery, + histogramQuery, + }; + }, [indexPatterns]); + + const discoverLink = useMemo(() => { + if (!discoverLocator || !queries?.baseQuery) { + return undefined; + } + + return discoverLocator.getRedirectUrl({ + query: { + esql: queries.baseQuery, + }, + }); + }, [queries?.baseQuery, discoverLocator]); + + const histogramQueryFetch = useStreamsAppFetch( + async ({ signal }) => { + if (!queries?.histogramQuery || !indexPatterns) { + return undefined; + } + + const existingIndices = await dataViews.getExistingIndices(indexPatterns); + + if (existingIndices.length === 0) { + return undefined; + } + + return streamsRepositoryClient.fetch('POST /internal/streams/esql', { + params: { + body: { + operationName: 'get_histogram_for_stream', + query: queries.histogramQuery, + start, + end, + }, + }, + signal, + }); + }, + [indexPatterns, dataViews, streamsRepositoryClient, queries?.histogramQuery, start, end] + ); + + return ( + <> + + + + { + if (!isUpdate) { + histogramQueryFetch.refresh(); + return; + } + + if (dateRange) { + setTimeRange({ from: dateRange.from, to: dateRange?.to, mode: dateRange.mode }); + } + }} + onRefresh={() => { + histogramQueryFetch.refresh(); + }} + placeholder={i18n.translate( + 'xpack.streams.entityDetailOverview.searchBarPlaceholder', + { + defaultMessage: 'Filter data by using KQL', + } + )} + dateRangeFrom={timeRange.from} + dateRangeTo={timeRange.to} + /> + + + {i18n.translate('xpack.streams.streamDetailOverview.openInDiscoverButtonLabel', { + defaultMessage: 'Open in Discover', + })} + + + + + + + + + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx b/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx new file mode 100644 index 000000000000..ebf72a58d32a --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/stream_detail_view/index.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EntityDetailViewWithoutParams, EntityViewTab } from '../entity_detail_view'; +import { useStreamsAppParams } from '../../hooks/use_streams_app_params'; +import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; +import { useKibana } from '../../hooks/use_kibana'; +import { StreamDetailOverview } from '../stream_detail_overview'; + +export function StreamDetailView() { + const { + path: { key, tab }, + } = useStreamsAppParams('/{key}/{tab}'); + + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + } = useKibana(); + + const { value: streamEntity } = useStreamsAppFetch( + ({ signal }) => { + return streamsRepositoryClient.fetch('GET /api/streams/{id}', { + signal, + params: { + path: { + id: key, + }, + }, + }); + }, + [streamsRepositoryClient, key] + ); + + const entity = { + id: key, + displayName: key, + }; + + const tabs: EntityViewTab[] = [ + { + name: 'overview', + content: , + label: i18n.translate('xpack.streams.streamDetailView.overviewTab', { + defaultMessage: 'Overview', + }), + }, + { + name: 'management', + content: <>, + label: i18n.translate('xpack.streams.streamDetailView.managementTab', { + defaultMessage: 'Management', + }), + }, + ]; + + return ; +} diff --git a/x-pack/plugins/streams_app/public/components/stream_list_view/index.tsx b/x-pack/plugins/streams_app/public/components/stream_list_view/index.tsx new file mode 100644 index 000000000000..e0530fc6bf5f --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/stream_list_view/index.tsx @@ -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 React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiSearchBar } from '@elastic/eui'; +import { useKibana } from '../../hooks/use_kibana'; +import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch'; +import { StreamsAppPageHeader } from '../streams_app_page_header'; +import { StreamsAppPageHeaderTitle } from '../streams_app_page_header/streams_app_page_header_title'; +import { StreamsAppPageBody } from '../streams_app_page_body'; +import { StreamsTable } from '../streams_table'; + +export function StreamListView() { + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + } = useKibana(); + + const [query, setQuery] = useState(''); + + const streamsListFetch = useStreamsAppFetch( + ({ signal }) => { + return streamsRepositoryClient.fetch('GET /api/streams', { + signal, + }); + }, + [streamsRepositoryClient] + ); + + return ( + + + } + /> + + + { + setQuery(nextQuery.queryText); + }} + /> + + + + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_context_provider/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_context_provider/index.tsx new file mode 100644 index 000000000000..fd5886c08939 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_context_provider/index.tsx @@ -0,0 +1,27 @@ +/* + * 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, { useMemo } from 'react'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { StreamsAppKibanaContext } from '../../hooks/use_kibana'; + +export function StreamsAppContextProvider({ + context, + children, +}: { + context: StreamsAppKibanaContext; + children: React.ReactNode; +}) { + const servicesForContext = useMemo(() => { + const { core, ...services } = context; + return { + ...core, + ...services, + }; + }, [context]); + + return {children}; +} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx new file mode 100644 index 000000000000..0f13dc31e277 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_page_body/index.tsx @@ -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 React from 'react'; +import { EuiPanel, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export function StreamsAppPageBody({ children }: { children: React.ReactNode }) { + const theme = useEuiTheme().euiTheme; + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_page_header/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_page_header/index.tsx new file mode 100644 index 000000000000..1171772116f2 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_page_header/index.tsx @@ -0,0 +1,35 @@ +/* + * 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, EuiPageHeader, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; +import React from 'react'; + +export function StreamsAppPageHeader({ + title, + children, + verticalPaddingSize = 'l', +}: { + title: React.ReactNode; + children?: React.ReactNode; + verticalPaddingSize?: 'none' | 'l'; +}) { + const theme = useEuiTheme().euiTheme; + + return ( + + + {title} + + {children} + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_page_header/streams_app_page_header_title.tsx b/x-pack/plugins/streams_app/public/components/streams_app_page_header/streams_app_page_header_title.tsx new file mode 100644 index 000000000000..ff7d6581dea4 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_page_header/streams_app_page_header_title.tsx @@ -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 { EuiTitle } from '@elastic/eui'; +import React from 'react'; + +export function StreamsAppPageHeaderTitle({ title }: { title: string }) { + return ( + +

{title}

+
+ ); +} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx new file mode 100644 index 000000000000..c474f54c2274 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_page_template/index.tsx @@ -0,0 +1,44 @@ +/* + * 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 { css } from '@emotion/css'; +import React from 'react'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; +import { useKibana } from '../../hooks/use_kibana'; + +export function StreamsAppPageTemplate({ children }: { children: React.ReactNode }) { + const { + dependencies: { + start: { observabilityShared }, + }, + } = useKibana(); + + const { PageTemplate } = observabilityShared.navigation; + + return ( + + + + {children} + + + ); +} diff --git a/x-pack/plugins/streams_app/public/components/streams_app_router_breadcrumb/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_router_breadcrumb/index.tsx new file mode 100644 index 000000000000..88aab4662de6 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_router_breadcrumb/index.tsx @@ -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 { createRouterBreadcrumbComponent } from '@kbn/typed-react-router-config'; +import type { StreamsAppRoutes } from '../../routes/config'; + +export const StreamsAppRouterBreadcrumb = createRouterBreadcrumbComponent(); diff --git a/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx b/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx new file mode 100644 index 000000000000..563fb752efbd --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_app_search_bar/index.tsx @@ -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 { css } from '@emotion/css'; +import type { TimeRange } from '@kbn/es-query'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import React, { useMemo } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useKibana } from '../../hooks/use_kibana'; + +const parentClassName = css` + width: 100%; +`; + +interface Props { + query?: string; + dateRangeFrom?: string; + dateRangeTo?: string; + onQueryChange?: (payload: { dateRange?: TimeRange; query: string }) => void; + onQuerySubmit?: (payload: { dateRange?: TimeRange; query: string }, isUpdate?: boolean) => void; + onRefresh?: Required>['onRefresh']; + placeholder?: string; + dataViews?: DataView[]; +} + +export function StreamsAppSearchBar({ + dateRangeFrom, + dateRangeTo, + onQueryChange, + onQuerySubmit, + onRefresh, + query, + placeholder, + dataViews, +}: Props) { + const { + dependencies: { + start: { unifiedSearch }, + }, + } = useKibana(); + + const queryObj = useMemo(() => (query ? { query, language: 'kuery' } : undefined), [query]); + + const showQueryInput = query === undefined; + + return ( +
+ { + onQuerySubmit?.( + { dateRange, query: (nextQuery?.query as string | undefined) ?? '' }, + isUpdate + ); + }} + onQueryChange={({ dateRange, query: nextQuery }) => { + onQueryChange?.({ dateRange, query: (nextQuery?.query as string | undefined) ?? '' }); + }} + query={queryObj} + showQueryInput={showQueryInput} + showFilterBar={false} + showQueryMenu={false} + showDatePicker={Boolean(dateRangeFrom && dateRangeTo)} + showSubmitButton={true} + dateRangeFrom={dateRangeFrom} + dateRangeTo={dateRangeTo} + onRefresh={onRefresh} + displayStyle="inPage" + disableQueryLanguageSwitcher + placeholder={placeholder} + indexPatterns={dataViews} + /> +
+ ); +} diff --git a/x-pack/plugins/streams_app/public/components/streams_table/index.tsx b/x-pack/plugins/streams_app/public/components/streams_table/index.tsx new file mode 100644 index 000000000000..f92c94f115e9 --- /dev/null +++ b/x-pack/plugins/streams_app/public/components/streams_table/index.tsx @@ -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 { + EuiBasicTable, + EuiBasicTableColumn, + EuiFlexGroup, + EuiIcon, + EuiLink, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { AbortableAsyncState } from '@kbn/observability-utils-browser/hooks/use_abortable_async'; +import { StreamDefinition } from '@kbn/streams-plugin/common'; +import React, { useMemo } from 'react'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; + +export function StreamsTable({ + listFetch, + query, +}: { + listFetch: AbortableAsyncState<{ definitions: StreamDefinition[] }>; + query: string; +}) { + const router = useStreamsAppRouter(); + + const items = useMemo(() => { + return listFetch.value?.definitions ?? []; + }, [listFetch.value?.definitions]); + + const filteredItems = useMemo(() => { + if (!query) { + return items; + } + + return items.filter((item) => item.id.toLowerCase().includes(query.toLowerCase())); + }, [query, items]); + + const columns = useMemo>>(() => { + return [ + { + field: 'id', + name: i18n.translate('xpack.streams.streamsTable.nameColumnTitle', { + defaultMessage: 'Name', + }), + render: (_, { id }) => { + return ( + + + + {id} + + + ); + }, + }, + ]; + }, [router]); + + return ( + + +

+ {i18n.translate('xpack.streams.streamsTable.tableTitle', { + defaultMessage: 'Streams', + })} +

+
+ +
+ ); +} diff --git a/x-pack/plugins/streams_app/public/hooks/use_kibana.tsx b/x-pack/plugins/streams_app/public/hooks/use_kibana.tsx new file mode 100644 index 000000000000..9c6b23465fb1 --- /dev/null +++ b/x-pack/plugins/streams_app/public/hooks/use_kibana.tsx @@ -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 { CoreStart } from '@kbn/core/public'; +import { useMemo } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { StreamsAppStartDependencies } from '../types'; +import type { StreamsAppServices } from '../services/types'; + +export interface StreamsAppKibanaContext { + core: CoreStart; + dependencies: { + start: StreamsAppStartDependencies; + }; + services: StreamsAppServices; +} + +const useTypedKibana = (): StreamsAppKibanaContext => { + const context = useKibana>(); + + return useMemo(() => { + const { dependencies, services, ...core } = context.services; + + return { + core, + dependencies, + services, + }; + }, [context.services]); +}; + +export { useTypedKibana as useKibana }; diff --git a/x-pack/plugins/streams_app/public/hooks/use_streams_app_breadcrumbs.ts b/x-pack/plugins/streams_app/public/hooks/use_streams_app_breadcrumbs.ts new file mode 100644 index 000000000000..e3ac760e3b77 --- /dev/null +++ b/x-pack/plugins/streams_app/public/hooks/use_streams_app_breadcrumbs.ts @@ -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 { createUseBreadcrumbs } from '@kbn/typed-react-router-config'; +import { StreamsAppRoutes } from '../routes/config'; + +export const useStreamsAppBreadcrumbs = createUseBreadcrumbs(); diff --git a/x-pack/plugins/streams_app/public/hooks/use_streams_app_fetch.ts b/x-pack/plugins/streams_app/public/hooks/use_streams_app_fetch.ts new file mode 100644 index 000000000000..08b112d4f207 --- /dev/null +++ b/x-pack/plugins/streams_app/public/hooks/use_streams_app_fetch.ts @@ -0,0 +1,73 @@ +/* + * 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 { + UseAbortableAsync, + useAbortableAsync, +} from '@kbn/observability-utils-browser/hooks/use_abortable_async'; +import { omit } from 'lodash'; +import { useKibana } from './use_kibana'; + +export const useStreamsAppFetch: UseAbortableAsync<{}, { disableToastOnError?: boolean }> = ( + callback, + deps, + options +) => { + const { + core: { notifications }, + } = useKibana(); + + const onError = (error: Error) => { + let requestUrl: string | undefined; + + if (!options?.disableToastOnError) { + if ( + 'body' in error && + typeof error.body === 'object' && + !!error.body && + 'message' in error.body && + typeof error.body.message === 'string' + ) { + error.message = error.body.message; + } + + if ( + 'request' in error && + typeof error.request === 'object' && + !!error.request && + 'url' in error.request && + typeof error.request.url === 'string' + ) { + requestUrl = error.request.url; + } + + notifications.toasts.addError(error, { + title: i18n.translate('xpack.streams.failedToFetchError', { + defaultMessage: 'Failed to fetch data{requestUrlSuffix}', + values: { + requestUrlSuffix: requestUrl ? ` (${requestUrl})` : '', + }, + }), + }); + } + }; + + const optionsForHook = { + ...omit(options, 'disableToastOnError'), + onError, + }; + + return useAbortableAsync( + ({ signal }) => { + return callback({ signal }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + deps, + optionsForHook + ); +}; diff --git a/x-pack/plugins/streams_app/public/hooks/use_streams_app_params.ts b/x-pack/plugins/streams_app/public/hooks/use_streams_app_params.ts new file mode 100644 index 000000000000..2931a6fa64f8 --- /dev/null +++ b/x-pack/plugins/streams_app/public/hooks/use_streams_app_params.ts @@ -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 { StreamsAppRoutes } from '../routes/config'; + +export function useStreamsAppParams>( + path: TPath +): TypeOf { + return useParams(path)! as TypeOf; +} diff --git a/x-pack/plugins/streams_app/public/hooks/use_streams_app_route_path.ts b/x-pack/plugins/streams_app/public/hooks/use_streams_app_route_path.ts new file mode 100644 index 000000000000..78e63ead57da --- /dev/null +++ b/x-pack/plugins/streams_app/public/hooks/use_streams_app_route_path.ts @@ -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 { StreamsAppRoutes } from '../routes/config'; + +export function useStreamsAppRoutePath() { + const path = useRoutePath(); + + return path as PathsOf; +} diff --git a/x-pack/plugins/streams_app/public/hooks/use_streams_app_router.ts b/x-pack/plugins/streams_app/public/hooks/use_streams_app_router.ts new file mode 100644 index 000000000000..17472044b7b4 --- /dev/null +++ b/x-pack/plugins/streams_app/public/hooks/use_streams_app_router.ts @@ -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 { StreamsAppRouter, StreamsAppRoutes } from '../routes/config'; +import { streamsAppRouter } from '../routes/config'; +import { useKibana } from './use_kibana'; + +interface StatefulStreamsAppRouter extends StreamsAppRouter { + push>( + path: T, + ...params: TypeAsArgs> + ): void; + replace>( + path: T, + ...params: TypeAsArgs> + ): void; +} + +export function useStreamsAppRouter(): StatefulStreamsAppRouter { + const { + core: { + http, + application: { navigateToApp }, + }, + } = useKibana(); + + const link = (...args: any[]) => { + // @ts-expect-error + return streamsAppRouter.link(...args); + }; + + return useMemo( + () => ({ + ...streamsAppRouter, + push: (...args) => { + const next = link(...args); + navigateToApp('streams', { path: next, replace: false }); + }, + replace: (path, ...args) => { + const next = link(path, ...args); + navigateToApp('streams', { path: next, replace: true }); + }, + link: (path, ...args) => { + return http.basePath.prepend('/app/streams' + link(path, ...args)); + }, + }), + [navigateToApp, http.basePath] + ); +} diff --git a/x-pack/plugins/streams_app/public/index.ts b/x-pack/plugins/streams_app/public/index.ts new file mode 100644 index 000000000000..eea2d8b7452a --- /dev/null +++ b/x-pack/plugins/streams_app/public/index.ts @@ -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 { StreamsAppPlugin } from './plugin'; +import type { + StreamsAppPublicSetup, + StreamsAppPublicStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies, + ConfigSchema, +} from './types'; + +export type { StreamsAppPublicSetup, StreamsAppPublicStart }; + +export const plugin: PluginInitializer< + StreamsAppPublicSetup, + StreamsAppPublicStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies +> = (pluginInitializerContext: PluginInitializerContext) => + new StreamsAppPlugin(pluginInitializerContext); diff --git a/x-pack/plugins/streams_app/public/plugin.ts b/x-pack/plugins/streams_app/public/plugin.ts new file mode 100644 index 000000000000..9df399693d02 --- /dev/null +++ b/x-pack/plugins/streams_app/public/plugin.ts @@ -0,0 +1,139 @@ +/* + * 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 { map } from 'rxjs'; +import { + AppMountParameters, + AppUpdater, + CoreSetup, + CoreStart, + DEFAULT_APP_CATEGORIES, + Plugin, + PluginInitializerContext, +} from '@kbn/core/public'; +import type { Logger } from '@kbn/logging'; +import { STREAMS_APP_ID } from '@kbn/deeplinks-observability/constants'; +import type { + ConfigSchema, + StreamsAppPublicSetup, + StreamsAppPublicStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies, +} from './types'; +import { StreamsAppServices } from './services/types'; + +export class StreamsAppPlugin + implements + Plugin< + StreamsAppPublicSetup, + StreamsAppPublicStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies + > +{ + logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + setup( + coreSetup: CoreSetup, + pluginsSetup: StreamsAppSetupDependencies + ): StreamsAppPublicSetup { + pluginsSetup.observabilityShared.navigation.registerSections( + pluginsSetup.streams.status$.pipe( + map(({ status }) => { + if (status !== 'enabled') { + return []; + } + + return [ + { + label: '', + sortKey: 101, + entries: [ + { + label: i18n.translate('xpack.streams.streamsAppLinkTitle', { + defaultMessage: 'Streams', + }), + app: STREAMS_APP_ID, + path: '/', + isTechnicalPreview: true, + matchPath(currentPath: string) { + return ['/', ''].some((testPath) => currentPath.startsWith(testPath)); + }, + }, + ], + }, + ]; + }) + ) + ); + + coreSetup.application.register({ + id: STREAMS_APP_ID, + title: i18n.translate('xpack.streams.appTitle', { + defaultMessage: 'Streams', + }), + euiIconType: 'logoObservability', + appRoute: '/app/streams', + category: DEFAULT_APP_CATEGORIES.observability, + order: 8001, + updater$: pluginsSetup.streams.status$.pipe( + map(({ status }): AppUpdater => { + return (app) => { + if (status !== 'enabled') { + return { + visibleIn: [], + deepLinks: [], + }; + } + + return { + visibleIn: ['sideNav', 'globalSearch'], + deepLinks: + status === 'enabled' + ? [ + { + id: 'streams', + title: i18n.translate('xpack.streams.streamsAppDeepLinkTitle', { + defaultMessage: 'Streams', + }), + path: '/', + }, + ] + : [], + }; + }; + }) + ), + mount: async (appMountParameters: AppMountParameters) => { + // Load application bundle and Get start services + const [{ renderApp }, [coreStart, pluginsStart]] = await Promise.all([ + import('./application'), + coreSetup.getStartServices(), + ]); + + const services: StreamsAppServices = {}; + + return renderApp({ + coreStart, + pluginsStart, + services, + appMountParameters, + }); + }, + }); + + return {}; + } + + start(coreStart: CoreStart, pluginsStart: StreamsAppStartDependencies): StreamsAppPublicStart { + return {}; + } +} diff --git a/x-pack/plugins/streams_app/public/routes/config.tsx b/x-pack/plugins/streams_app/public/routes/config.tsx new file mode 100644 index 000000000000..e3efdc6d871e --- /dev/null +++ b/x-pack/plugins/streams_app/public/routes/config.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { createRouter, Outlet, RouteMap } from '@kbn/typed-react-router-config'; +import * as t from 'io-ts'; +import React from 'react'; +import { StreamDetailView } from '../components/stream_detail_view'; +import { StreamsAppPageTemplate } from '../components/streams_app_page_template'; +import { StreamsAppRouterBreadcrumb } from '../components/streams_app_router_breadcrumb'; +import { RedirectTo } from '../components/redirect_to'; +import { StreamListView } from '../components/stream_list_view'; + +/** + * The array of route definitions to be used when the application + * creates the routes. + */ +const streamsAppRoutes = { + '/': { + element: ( + + + + + + ), + children: { + '/{key}': { + element: , + params: t.type({ + path: t.type({ + key: t.string, + }), + }), + children: { + '/{key}': { + element: , + }, + '/{key}/{tab}': { + element: , + params: t.type({ + path: t.type({ + tab: t.string, + }), + }), + }, + }, + }, + '/': { + element: , + }, + }, + }, +} satisfies RouteMap; + +export type StreamsAppRoutes = typeof streamsAppRoutes; + +export const streamsAppRouter = createRouter(streamsAppRoutes); + +export type StreamsAppRouter = typeof streamsAppRouter; diff --git a/x-pack/plugins/streams_app/public/services/types.ts b/x-pack/plugins/streams_app/public/services/types.ts new file mode 100644 index 000000000000..7f75493d2525 --- /dev/null +++ b/x-pack/plugins/streams_app/public/services/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StreamsAppServices {} diff --git a/x-pack/plugins/streams_app/public/types.ts b/x-pack/plugins/streams_app/public/types.ts new file mode 100644 index 000000000000..58d44784fe03 --- /dev/null +++ b/x-pack/plugins/streams_app/public/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { + DataViewsPublicPluginSetup, + DataViewsPublicPluginStart, +} from '@kbn/data-views-plugin/public'; +import type { + ObservabilitySharedPluginSetup, + ObservabilitySharedPluginStart, +} from '@kbn/observability-shared-plugin/public'; +import type { StreamsPluginSetup, StreamsPluginStart } from '@kbn/streams-plugin/public'; +import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { SharePublicSetup, SharePublicStart } from '@kbn/share-plugin/public/plugin'; +/* eslint-disable @typescript-eslint/no-empty-interface*/ + +export interface ConfigSchema {} + +export interface StreamsAppSetupDependencies { + streams: StreamsPluginSetup; + data: DataPublicPluginSetup; + dataViews: DataViewsPublicPluginSetup; + observabilityShared: ObservabilitySharedPluginSetup; + unifiedSearch: {}; + share: SharePublicSetup; +} + +export interface StreamsAppStartDependencies { + streams: StreamsPluginStart; + data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; + observabilityShared: ObservabilitySharedPluginStart; + unifiedSearch: UnifiedSearchPublicPluginStart; + share: SharePublicStart; +} + +export interface StreamsAppPublicSetup {} + +export interface StreamsAppPublicStart {} diff --git a/x-pack/plugins/streams_app/public/util/esql_result_to_timeseries.ts b/x-pack/plugins/streams_app/public/util/esql_result_to_timeseries.ts new file mode 100644 index 000000000000..c32bbf89135b --- /dev/null +++ b/x-pack/plugins/streams_app/public/util/esql_result_to_timeseries.ts @@ -0,0 +1,99 @@ +/* + * 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 { AbortableAsyncState } from '@kbn/observability-utils-browser/hooks/use_abortable_async'; +import type { UnparsedEsqlResponse } from '@kbn/observability-utils-server/es/client/create_observability_es_client'; +import { orderBy } from 'lodash'; + +interface Timeseries { + id: string; + label: string; + metricNames: T[]; + data: Array<{ x: number } & Record>; +} + +export function esqlResultToTimeseries({ + result, + metricNames, +}: { + result: AbortableAsyncState; + metricNames: T[]; +}): Array> { + const columns = result.value?.columns; + + const rows = result.value?.values; + + if (!columns?.length || !rows?.length) { + return []; + } + + const timestampColumn = columns.find((col) => col.name === '@timestamp'); + + if (!timestampColumn) { + return []; + } + + const collectedSeries: Map> = new Map(); + + rows.forEach((columnsInRow) => { + const values = new Map(); + const labels = new Map(); + let timestamp: number; + + columnsInRow.forEach((value, index) => { + const column = columns[index]; + const isTimestamp = column.name === '@timestamp'; + const isMetric = metricNames.indexOf(column.name as T) !== -1; + + if (isTimestamp) { + timestamp = new Date(value as string | number).getTime(); + } else if (isMetric) { + values.set(column.name, value as number | null); + } else { + labels.set(column.name, String(value)); + } + }); + + const seriesKey = + Array.from(labels.entries()) + .map(([key, value]) => [key, value].join(':')) + .sort() + .join(',') || '-'; + + if (!collectedSeries.has(seriesKey)) { + collectedSeries.set(seriesKey, { + id: seriesKey, + data: [], + label: seriesKey, + metricNames, + }); + } + + const series = collectedSeries.get(seriesKey)!; + + const coordinate = { + x: timestamp!, + } as { x: number } & Record; + + values.forEach((value, key) => { + if (key !== 'x') { + // @ts-expect-error + coordinate[key as T] = value; + } + }); + + series.data.push(coordinate); + + return collectedSeries; + }); + + return Array.from(collectedSeries.entries()).map(([id, timeseries]) => { + return { + ...timeseries, + data: orderBy(timeseries.data, 'x', 'asc'), + }; + }); +} diff --git a/x-pack/plugins/streams_app/server/config.ts b/x-pack/plugins/streams_app/server/config.ts new file mode 100644 index 000000000000..73e631c7f6d9 --- /dev/null +++ b/x-pack/plugins/streams_app/server/config.ts @@ -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: true }), +}); + +export type StreamsAppConfig = TypeOf; diff --git a/x-pack/plugins/streams_app/server/index.ts b/x-pack/plugins/streams_app/server/index.ts new file mode 100644 index 000000000000..1ca3b3c6e3de --- /dev/null +++ b/x-pack/plugins/streams_app/server/index.ts @@ -0,0 +1,35 @@ +/* + * 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 { StreamsAppConfig } from './config'; +import { StreamsAppPlugin } from './plugin'; +import type { + StreamsAppServerSetup, + StreamsAppServerStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies, +} from './types'; + +export type { StreamsAppServerSetup, StreamsAppServerStart }; + +import { config as configSchema } from './config'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export const plugin: PluginInitializer< + StreamsAppServerSetup, + StreamsAppServerStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies +> = async (pluginInitializerContext: PluginInitializerContext) => + new StreamsAppPlugin(pluginInitializerContext); diff --git a/x-pack/plugins/streams_app/server/plugin.ts b/x-pack/plugins/streams_app/server/plugin.ts new file mode 100644 index 000000000000..00fb61c1a9cc --- /dev/null +++ b/x-pack/plugins/streams_app/server/plugin.ts @@ -0,0 +1,42 @@ +/* + * 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 type { + ConfigSchema, + StreamsAppServerSetup, + StreamsAppServerStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies, +} from './types'; + +export class StreamsAppPlugin + implements + Plugin< + StreamsAppServerSetup, + StreamsAppServerStart, + StreamsAppSetupDependencies, + StreamsAppStartDependencies + > +{ + logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } + setup( + coreSetup: CoreSetup, + pluginsSetup: StreamsAppSetupDependencies + ): StreamsAppServerSetup { + return {}; + } + + start(core: CoreStart, pluginsStart: StreamsAppStartDependencies): StreamsAppServerStart { + return {}; + } +} diff --git a/x-pack/plugins/streams_app/server/types.ts b/x-pack/plugins/streams_app/server/types.ts new file mode 100644 index 000000000000..e425ae7422d7 --- /dev/null +++ b/x-pack/plugins/streams_app/server/types.ts @@ -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 { StreamsPluginStart, StreamsPluginSetup } from '@kbn/streams-plugin/server'; + +/* eslint-disable @typescript-eslint/no-empty-interface*/ + +export interface ConfigSchema {} + +export interface StreamsAppSetupDependencies { + streams: StreamsPluginSetup; +} + +export interface StreamsAppStartDependencies { + streams: StreamsPluginStart; +} + +export interface StreamsAppServerSetup {} + +export interface StreamsAppServerStart {} diff --git a/x-pack/plugins/streams_app/tsconfig.json b/x-pack/plugins/streams_app/tsconfig.json new file mode 100644 index 000000000000..39acb94665ae --- /dev/null +++ b/x-pack/plugins/streams_app/tsconfig.json @@ -0,0 +1,37 @@ +{ + "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/data-plugin", + "@kbn/data-views-plugin", + "@kbn/observability-shared-plugin", + "@kbn/unified-search-plugin", + "@kbn/react-kibana-context-render", + "@kbn/shared-ux-link-redirect-app", + "@kbn/typed-react-router-config", + "@kbn/i18n", + "@kbn/observability-utils-browser", + "@kbn/kibana-react-plugin", + "@kbn/es-query", + "@kbn/logging", + "@kbn/deeplinks-observability", + "@kbn/config-schema", + "@kbn/calculate-auto", + "@kbn/streams-plugin", + "@kbn/share-plugin", + "@kbn/observability-utils-server", + ] +} diff --git a/yarn.lock b/yarn.lock index f299e48d4bb2..58264896a826 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7482,6 +7482,10 @@ version "0.0.0" uid "" +"@kbn/streams-app-plugin@link:x-pack/plugins/streams_app": + version "0.0.0" + uid "" + "@kbn/streams-plugin@link:x-pack/plugins/streams": version "0.0.0" uid ""