diff --git a/x-pack/solutions/observability/plugins/streams/server/lib/streams/stream_crud.ts b/x-pack/solutions/observability/plugins/streams/server/lib/streams/stream_crud.ts index 4d01e9d38fd27..762769487ef96 100644 --- a/x-pack/solutions/observability/plugins/streams/server/lib/streams/stream_crud.ts +++ b/x-pack/solutions/observability/plugins/streams/server/lib/streams/stream_crud.ts @@ -222,7 +222,6 @@ async function listManagedStreams({ const streams = streamsSearchResponse.hits.hits.map((hit) => ({ ...hit._source!, - managed: true, })); const privileges = await scopedClusterClient.asCurrentUser.security.hasPrivileges({ diff --git a/x-pack/solutions/observability/plugins/streams/server/routes/index.ts b/x-pack/solutions/observability/plugins/streams/server/routes/index.ts index fe10f3e282c46..3e80d57dfff3c 100644 --- a/x-pack/solutions/observability/plugins/streams/server/routes/index.ts +++ b/x-pack/solutions/observability/plugins/streams/server/routes/index.ts @@ -8,6 +8,7 @@ import { dashboardRoutes } from './dashboards/route'; import { esqlRoutes } from './esql/route'; import { deleteStreamRoute } from './streams/delete'; +import { streamDetailRoute } from './streams/details'; import { disableStreamsRoute } from './streams/disable'; import { editStreamRoute } from './streams/edit'; import { enableStreamsRoute } from './streams/enable'; @@ -33,6 +34,7 @@ export const streamsRouteRepository = { ...disableStreamsRoute, ...dashboardRoutes, ...sampleStreamRoute, + ...streamDetailRoute, ...unmappedFieldsRoute, ...schemaFieldsSimulationRoute, }; diff --git a/x-pack/solutions/observability/plugins/streams/server/routes/streams/details.ts b/x-pack/solutions/observability/plugins/streams/server/routes/streams/details.ts new file mode 100644 index 0000000000000..085139289f9d8 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams/server/routes/streams/details.ts @@ -0,0 +1,86 @@ +/* + * 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 { z } from '@kbn/zod'; +import { notFound, internal } from '@hapi/boom'; +import { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; +import { createServerRoute } from '../create_server_route'; +import { DefinitionNotFound } from '../../lib/streams/errors'; +import { readStream } from '../../lib/streams/stream_crud'; + +export interface StreamDetailsResponse { + details: { + count: number; + }; +} + +export const streamDetailRoute = createServerRoute({ + endpoint: 'GET /api/streams/{id}/_details', + options: { + 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() }), + query: z.object({ + start: z.string(), + end: z.string(), + }), + }), + handler: async ({ + response, + params, + request, + logger, + getScopedClients, + }): Promise => { + try { + const { scopedClusterClient } = await getScopedClients({ request }); + const streamEntity = await readStream({ + scopedClusterClient, + id: params.path.id, + }); + + // check doc count + const docCountResponse = await scopedClusterClient.asCurrentUser.search({ + index: streamEntity.name, + body: { + track_total_hits: true, + query: { + range: { + '@timestamp': { + gte: params.query.start, + lte: params.query.end, + }, + }, + }, + size: 0, + }, + }); + + const count = (docCountResponse.hits.total as SearchTotalHits).value; + + return { + details: { + count, + }, + }; + } catch (e) { + if (e instanceof DefinitionNotFound) { + throw notFound(e); + } + + throw internal(e); + } + }, +}); diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx index 1fdc95821172e..09aaebc395410 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_detail_overview/index.tsx @@ -4,17 +4,38 @@ * 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 { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLoadingSpinner, + EuiPanel, + EuiTab, + EuiTabs, + EuiText, +} 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 moment from 'moment'; import React, { useMemo } from 'react'; -import { ReadStreamDefinition } from '@kbn/streams-schema'; +import { css } from '@emotion/css'; +import { ReadStreamDefinition, isWiredReadStream, isWiredStream } from '@kbn/streams-schema'; +import { useDateRange } from '@kbn/observability-utils-browser/hooks/use_date_range'; +import illustration from '../assets/illustration.png'; 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'; +import { getIndexPatterns } from '../../util/hierarchy_helpers'; +import { StreamsList } from '../streams_list'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; + +const formatNumber = (val: number) => { + return Number(val).toLocaleString('en', { + maximumFractionDigits: 1, + }); +}; export function StreamDetailOverview({ definition }: { definition?: ReadStreamDefinition }) { const { @@ -35,18 +56,8 @@ export function StreamDetailOverview({ definition }: { definition?: ReadStreamDe } = useDateRange({ data }); const indexPatterns = useMemo(() => { - if (!definition?.name) { - return undefined; - } - - const isRoot = definition.name.indexOf('.') === -1; - - const dataStreamOfDefinition = definition.name; - - return isRoot - ? [dataStreamOfDefinition, `${dataStreamOfDefinition}.*`] - : [`${dataStreamOfDefinition}*`]; - }, [definition?.name]); + return getIndexPatterns(definition); + }, [definition]); const discoverLocator = useMemo( () => share.url.locators.get('DISCOVER_APP_LOCATOR'), @@ -111,16 +122,75 @@ export function StreamDetailOverview({ definition }: { definition?: ReadStreamDe [indexPatterns, dataViews, streamsRepositoryClient, queries?.histogramQuery, start, end] ); + const docCountFetch = useStreamsAppFetch( + async ({ signal }) => { + if (!definition) { + return undefined; + } + return streamsRepositoryClient.fetch('GET /api/streams/{id}/_details', { + signal, + params: { + path: { + id: definition.name as string, + }, + query: { + start: String(start), + end: String(end), + }, + }, + }); + }, + [definition, dataViews, streamsRepositoryClient, start, end] + ); + + const [selectedTab, setSelectedTab] = React.useState(undefined); + + const tabs = [ + ...(definition && isWiredReadStream(definition) + ? [ + { + id: 'streams', + name: i18n.translate('xpack.streams.entityDetailOverview.tabs.streams', { + defaultMessage: 'Streams', + }), + content: , + }, + ] + : []), + { + id: 'quicklinks', + name: i18n.translate('xpack.streams.entityDetailOverview.tabs.quicklinks', { + defaultMessage: 'Quick Links', + }), + content: <>TODO, + }, + ]; + return ( <> - + + + {docCountFetch.loading ? ( + + ) : ( + docCountFetch.value && ( + + {i18n.translate('xpack.streams.entityDetailOverview.docCount', { + defaultMessage: '{docCount} documents', + values: { docCount: formatNumber(docCountFetch.value.details.count) }, + })} + + ) + )} + { if (!isUpdate) { histogramQueryFetch.refresh(); + docCountFetch.refresh(); return; } @@ -166,7 +236,112 @@ export function StreamDetailOverview({ definition }: { definition?: ReadStreamDe + + + {definition && ( + <> + + {tabs.map((tab, index) => ( + setSelectedTab(tab.id)} + key={tab.id} + > + {tab.name} + + ))} + + { + tabs.find((tab, index) => (!selectedTab && index === 0) || selectedTab === tab.id) + ?.content + } + + )} + + ); } + +function ChildStreamList({ stream }: { stream?: ReadStreamDefinition }) { + const { + dependencies: { + start: { + streams: { streamsRepositoryClient }, + }, + }, + } = useKibana(); + const router = useStreamsAppRouter(); + + const streamsListFetch = useStreamsAppFetch( + ({ signal }) => { + return streamsRepositoryClient.fetch('GET /api/streams', { + signal, + }); + }, + [streamsRepositoryClient] + ); + + const childDefinitions = useMemo(() => { + if (!stream) { + return []; + } + return streamsListFetch.value?.streams.filter( + (d) => isWiredStream(d) && d.name.startsWith(stream.name as string) + ); + }, [stream, streamsListFetch.value?.streams]); + + if (stream && childDefinitions?.length === 1) { + return ( + + + + + + + {i18n.translate('xpack.streams.entityDetailOverview.noChildStreams', { + defaultMessage: 'Create streams for your logs', + })} + + + {i18n.translate('xpack.streams.entityDetailOverview.noChildStreams', { + defaultMessage: + 'Create sub streams to split out data with different retention policies, schemas, and more.', + })} + + + + {i18n.translate('xpack.streams.entityDetailOverview.createChildStream', { + defaultMessage: 'Create child stream', + })} + + + + + + + ); + } + + return ; +} diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_list_view/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_list_view/index.tsx index 7ffda5f40295a..9e727363456d6 100644 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/stream_list_view/index.tsx +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/stream_list_view/index.tsx @@ -12,7 +12,7 @@ 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'; +import { StreamsList } from '../streams_list'; export function StreamListView() { const { @@ -61,7 +61,7 @@ export function StreamListView() { /> - + diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/streams_list/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/streams_list/index.tsx new file mode 100644 index 0000000000000..4df0bee6147d4 --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/components/streams_list/index.tsx @@ -0,0 +1,301 @@ +/* + * 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 { + EuiBadge, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiSwitch, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { css } from '@emotion/css'; +import { StreamDefinition, isWiredStream } from '@kbn/streams-schema'; +import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; +import { NestedView } from '../nested_view'; +import { useKibana } from '../../hooks/use_kibana'; +import { getIndexPatterns } from '../../util/hierarchy_helpers'; + +export interface StreamTree { + id: string; + type: 'wired' | 'root' | 'classic'; + definition: StreamDefinition; + children: StreamTree[]; +} + +function asTrees(definitions: StreamDefinition[]) { + const trees: StreamTree[] = []; + const wiredDefinitions = definitions.filter((definition) => isWiredStream(definition)); + wiredDefinitions.sort((a, b) => a.name.split('.').length - b.name.split('.').length); + + wiredDefinitions.forEach((definition) => { + let currentTree = trees; + let existingNode: StreamTree | undefined; + // traverse the tree following the prefix of the current id. + // once we reach the leaf, the current id is added as child - this works because the ids are sorted by depth + while ((existingNode = currentTree.find((node) => definition.name.startsWith(node.id)))) { + currentTree = existingNode.children; + } + if (!existingNode) { + const newNode: StreamTree = { + id: definition.name, + children: [], + definition, + type: definition.name.split('.').length === 1 ? 'root' : 'wired', + }; + currentTree.push(newNode); + } + }); + + return trees; +} + +export function StreamsList({ + definitions, + query, + showControls, +}: { + definitions: StreamDefinition[] | undefined; + query?: string; + showControls: boolean; +}) { + const [collapsed, setCollapsed] = React.useState>({}); + const [showClassic, setShowClassic] = React.useState(true); + const items = useMemo(() => { + return definitions ?? []; + }, [definitions]); + + const filteredItems = useMemo(() => { + return items + .filter((item) => showClassic || isWiredStream(item)) + .filter((item) => !query || item.name.toLowerCase().includes(query.toLowerCase())); + }, [query, items, showClassic]); + + const classicStreams = useMemo(() => { + return filteredItems.filter((item) => !isWiredStream(item)); + }, [filteredItems]); + + const treeView = useMemo(() => { + const trees = asTrees(filteredItems); + const classicList = classicStreams.map((definition) => ({ + id: definition.name, + type: 'classic' as const, + definition, + children: [], + })); + return [...trees, ...classicList]; + }, [filteredItems, classicStreams]); + + return ( + + {showControls && ( + <> + +

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

+
+ + + {Object.keys(collapsed).length === 0 ? ( + + setCollapsed(Object.fromEntries(items.map((item) => [item.name, true]))) + } + > + {i18n.translate('xpack.streams.streamsTable.collapseAll', { + defaultMessage: 'Collapse all', + })} + + ) : ( + setCollapsed({})} size="s"> + {i18n.translate('xpack.streams.streamsTable.expandAll', { + defaultMessage: 'Expand all', + })} + + )} + setShowClassic(e.target.checked)} + /> + + + + )} + + {treeView.map((tree) => ( + + ))} + +
+ ); +} + +function StreamNode({ + node, + collapsed, + setCollapsed, +}: { + node: StreamTree; + collapsed: Record; + setCollapsed: (collapsed: Record) => void; +}) { + const router = useStreamsAppRouter(); + const { + dependencies: { + start: { share }, + }, + } = useKibana(); + const discoverLocator = useMemo( + () => share.url.locators.get('DISCOVER_APP_LOCATOR'), + [share.url.locators] + ); + + const discoverUrl = useMemo(() => { + const indexPatterns = getIndexPatterns(node.definition); + + if (!discoverLocator || !indexPatterns) { + return undefined; + } + + return discoverLocator.getRedirectUrl({ + query: { + esql: `FROM ${indexPatterns.join(', ')}`, + }, + }); + }, [discoverLocator, node]); + + return ( + + + {node.children.length > 0 && ( + // Using a regular button here instead of the EUI one to control styling + + )} + + {node.id} + + {node.type === 'root' && ( + + + + )} + {node.type === 'classic' && ( + + + + )} + + + + + + + + + + + + + {node.children.length > 0 && !collapsed?.[node.id] && ( + + + {node.children.map((child, index) => ( + + + + ))} + + + )} + + ); +} diff --git a/x-pack/solutions/observability/plugins/streams_app/public/components/streams_table/index.tsx b/x-pack/solutions/observability/plugins/streams_app/public/components/streams_table/index.tsx deleted file mode 100644 index ef80d1346edd4..0000000000000 --- a/x-pack/solutions/observability/plugins/streams_app/public/components/streams_table/index.tsx +++ /dev/null @@ -1,78 +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 { - 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 React, { useMemo } from 'react'; -import { isWiredStreamConfig, StreamDefinition } from '@kbn/streams-schema'; -import { useStreamsAppRouter } from '../../hooks/use_streams_app_router'; - -export function StreamsTable({ - listFetch, - query, -}: { - listFetch: AbortableAsyncState<{ streams: StreamDefinition[] }>; - query: string; -}) { - const router = useStreamsAppRouter(); - - const items = useMemo(() => { - return listFetch.value?.streams ?? []; - }, [listFetch.value?.streams]); - - const filteredItems = useMemo(() => { - if (!query) { - return items; - } - - return items.filter((item) => item.name.toLowerCase().includes(query.toLowerCase())); - }, [query, items]); - - const columns = useMemo>>(() => { - return [ - { - field: 'name', - name: i18n.translate('xpack.streams.streamsTable.nameColumnTitle', { - defaultMessage: 'Name', - }), - render: (_, { name, stream }) => { - return ( - - - - {name} - - - ); - }, - }, - ]; - }, [router]); - - return ( - - -

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

-
- -
- ); -} diff --git a/x-pack/solutions/observability/plugins/streams_app/public/util/hierarchy_helpers.ts b/x-pack/solutions/observability/plugins/streams_app/public/util/hierarchy_helpers.ts new file mode 100644 index 0000000000000..a956e3f486afe --- /dev/null +++ b/x-pack/solutions/observability/plugins/streams_app/public/util/hierarchy_helpers.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 { StreamDefinition, isIngestStream, isWiredStream } from '@kbn/streams-schema'; + +export function getIndexPatterns(definition: StreamDefinition | undefined) { + if (!definition) { + return undefined; + } + if (!isWiredStream(definition) && isIngestStream(definition)) { + return [definition.name as string]; + } + const isRoot = definition.name.indexOf('.') === -1; + const dataStreamOfDefinition = definition.name; + return isRoot + ? [dataStreamOfDefinition, `${dataStreamOfDefinition}.*`] + : [`${dataStreamOfDefinition}*`]; +}