🌊 Stream overview page (#204079)

Stacked on https://github.com/elastic/kibana/pull/204004

<img width="1275" alt="Screenshot 2024-12-12 at 17 19 58"
src="https://github.com/user-attachments/assets/2ad14305-15c0-4522-8e70-5691c50e381b"
/>

Adds some bits to the stream overview page:
* Number of docs for the current time range (let's stop here and don't
build more of Kibana)
* List of child streams for wired streams
* Quick links tab (currently empty)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Joe Reuter 2025-01-08 18:34:22 +01:00 committed by GitHub
parent 2a7a53aaf3
commit 58d1522bfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 604 additions and 97 deletions

View file

@ -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({

View file

@ -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,
};

View file

@ -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<StreamDetailsResponse> => {
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);
}
},
});

View file

@ -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<string | undefined>(undefined);
const tabs = [
...(definition && isWiredReadStream(definition)
? [
{
id: 'streams',
name: i18n.translate('xpack.streams.entityDetailOverview.tabs.streams', {
defaultMessage: 'Streams',
}),
content: <ChildStreamList stream={definition} />,
},
]
: []),
{
id: 'quicklinks',
name: i18n.translate('xpack.streams.entityDetailOverview.tabs.quicklinks', {
defaultMessage: 'Quick Links',
}),
content: <>TODO</>,
},
];
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" gutterSize="s">
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiFlexItem>
{docCountFetch.loading ? (
<EuiLoadingSpinner size="m" />
) : (
docCountFetch.value && (
<EuiText>
{i18n.translate('xpack.streams.entityDetailOverview.docCount', {
defaultMessage: '{docCount} documents',
values: { docCount: formatNumber(docCountFetch.value.details.count) },
})}
</EuiText>
)
)}
</EuiFlexItem>
<EuiFlexItem grow>
<StreamsAppSearchBar
onQuerySubmit={({ dateRange }, isUpdate) => {
if (!isUpdate) {
histogramQueryFetch.refresh();
docCountFetch.refresh();
return;
}
@ -166,7 +236,112 @@ export function StreamDetailOverview({ definition }: { definition?: ReadStreamDe
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFlexGroup direction="column" gutterSize="s">
{definition && (
<>
<EuiTabs>
{tabs.map((tab, index) => (
<EuiTab
isSelected={(!selectedTab && index === 0) || selectedTab === tab.id}
onClick={() => setSelectedTab(tab.id)}
key={tab.id}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
{
tabs.find((tab, index) => (!selectedTab && index === 0) || selectedTab === tab.id)
?.content
}
</>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
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 (
<EuiFlexItem grow>
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem
grow={false}
className={css`
max-width: 350px;
`}
>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiImage
src={illustration}
alt="Illustration"
className={css`
width: 250px;
`}
/>
<EuiText size="m" textAlign="center">
{i18n.translate('xpack.streams.entityDetailOverview.noChildStreams', {
defaultMessage: 'Create streams for your logs',
})}
</EuiText>
<EuiText size="xs" textAlign="center">
{i18n.translate('xpack.streams.entityDetailOverview.noChildStreams', {
defaultMessage:
'Create sub streams to split out data with different retention policies, schemas, and more.',
})}
</EuiText>
<EuiFlexGroup justifyContent="center">
<EuiButton
iconType="plusInCircle"
href={router.link('/{key}/management/{subtab}', {
path: {
key: stream?.name as string,
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
}
return <StreamsList definitions={childDefinitions} showControls={false} />;
}

View file

@ -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() {
/>
</EuiFlexItem>
<EuiFlexItem grow>
<StreamsTable listFetch={streamsListFetch} query={query} />
<StreamsList definitions={streamsListFetch.value?.streams} query={query} showControls />
</EuiFlexItem>
</EuiFlexGroup>
</StreamsAppPageBody>

View file

@ -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<Record<string, boolean>>({});
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 (
<EuiFlexGroup direction="column" gutterSize="m">
{showControls && (
<>
<EuiTitle size="xxs">
<h2>
{i18n.translate('xpack.streams.streamsTable.tableTitle', {
defaultMessage: 'Streams',
})}
</h2>
</EuiTitle>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m" justifyContent="spaceBetween">
{Object.keys(collapsed).length === 0 ? (
<EuiButtonEmpty
iconType="fold"
size="s"
onClick={() =>
setCollapsed(Object.fromEntries(items.map((item) => [item.name, true])))
}
>
{i18n.translate('xpack.streams.streamsTable.collapseAll', {
defaultMessage: 'Collapse all',
})}
</EuiButtonEmpty>
) : (
<EuiButtonEmpty iconType="unfold" onClick={() => setCollapsed({})} size="s">
{i18n.translate('xpack.streams.streamsTable.expandAll', {
defaultMessage: 'Expand all',
})}
</EuiButtonEmpty>
)}
<EuiSwitch
label={i18n.translate('xpack.streams.streamsTable.showClassicStreams', {
defaultMessage: 'Show classic streams',
})}
compressed
checked={showClassic}
onChange={(e) => setShowClassic(e.target.checked)}
/>
</EuiFlexGroup>
</EuiFlexItem>
</>
)}
<EuiFlexItem grow={false}>
{treeView.map((tree) => (
<StreamNode key={tree.id} node={tree} collapsed={collapsed} setCollapsed={setCollapsed} />
))}
</EuiFlexItem>
</EuiFlexGroup>
);
}
function StreamNode({
node,
collapsed,
setCollapsed,
}: {
node: StreamTree;
collapsed: Record<string, boolean>;
setCollapsed: (collapsed: Record<string, boolean>) => 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 (
<EuiFlexGroup
direction="column"
gutterSize="xs"
className={css`
margin-top: ${euiThemeVars.euiSizeXS};
margin-left: ${euiThemeVars.euiSizeS};
`}
>
<EuiFlexGroup
direction="row"
gutterSize="s"
alignItems="center"
className={css`
padding: ${euiThemeVars.euiSizeXS};
border-radius: ${euiThemeVars.euiBorderRadius};
&:hover {
background-color: ${euiThemeVars.euiColorLightestShade};
.links {
opacity: 1;
}
}
`}
>
{node.children.length > 0 && (
// Using a regular button here instead of the EUI one to control styling
<button
type="button"
onClick={() => {
setCollapsed?.({ ...collapsed, [node.id]: !collapsed?.[node.id] });
}}
className={css`
background: none;
margin-left: -${euiThemeVars.euiSizeXS};
margin-right: ${euiThemeVars.euiSizeXS};
`}
>
<EuiIcon type={collapsed?.[node.id] ? 'arrowRight' : 'arrowDown'} />
</button>
)}
<EuiLink color="text" href={router.link('/{key}', { path: { key: node.id } })}>
{node.id}
</EuiLink>
{node.type === 'root' && (
<EuiBadge color="hollow">
<EuiIcon type="branch" size="s" />
</EuiBadge>
)}
{node.type === 'classic' && (
<EuiBadge color="hollow">
<EuiIcon type="bullseye" size="s" />
</EuiBadge>
)}
<EuiFlexGroup
className={`links ${css`
opacity: 0;
`}`}
alignItems="center"
gutterSize="s"
>
<EuiToolTip
content={i18n.translate('xpack.streams.streamsTable.openInNewTab', {
defaultMessage: 'Open in new tab',
})}
>
<EuiButtonIcon
aria-label={i18n.translate('xpack.streams.streamsTable.openInNewTab', {
defaultMessage: 'Open in new tab',
})}
iconType="popout"
target="_blank"
href={router.link('/{key}', { path: { key: node.id } })}
/>
</EuiToolTip>
<EuiToolTip
content={i18n.translate('xpack.streams.streamsTable.openInDiscover', {
defaultMessage: 'Open in Discover',
})}
>
<EuiButtonIcon
iconType="discoverApp"
href={discoverUrl}
aria-label={i18n.translate('xpack.streams.streamsTable.openInDiscover', {
defaultMessage: 'Open in Discover',
})}
/>
</EuiToolTip>
<EuiToolTip
content={i18n.translate('xpack.streams.streamsTable.management', {
defaultMessage: 'Management',
})}
>
<EuiButtonIcon
iconType="gear"
aria-label={i18n.translate('xpack.streams.streamsTable.management', {
defaultMessage: 'Management',
})}
href={router.link('/{key}/management', { path: { key: node.id } })}
/>
</EuiToolTip>
</EuiFlexGroup>
</EuiFlexGroup>
{node.children.length > 0 && !collapsed?.[node.id] && (
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="xs">
{node.children.map((child, index) => (
<NestedView key={child.id} last={index === node.children.length - 1}>
<StreamNode node={child} collapsed={collapsed} setCollapsed={setCollapsed} />
</NestedView>
))}
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}

View file

@ -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<Array<EuiBasicTableColumn<StreamDefinition>>>(() => {
return [
{
field: 'name',
name: i18n.translate('xpack.streams.streamsTable.nameColumnTitle', {
defaultMessage: 'Name',
}),
render: (_, { name, stream }) => {
return (
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
<EuiIcon type={isWiredStreamConfig(stream) ? 'branch' : 'bullseye'} />
<EuiLink
data-test-subj="logsaiColumnsLink"
href={router.link('/{key}', { path: { key: name } })}
>
{name}
</EuiLink>
</EuiFlexGroup>
);
},
},
];
}, [router]);
return (
<EuiFlexGroup direction="column" gutterSize="m">
<EuiTitle size="xxs">
<h2>
{i18n.translate('xpack.streams.streamsTable.tableTitle', {
defaultMessage: 'Streams',
})}
</h2>
</EuiTitle>
<EuiBasicTable columns={columns} items={filteredItems} loading={listFetch.loading} />
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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}*`];
}