Skip to content

Commit

Permalink
🌊 Stream overview page (#204079)
Browse files Browse the repository at this point in the history
Stacked on #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 <[email protected]>
  • Loading branch information
flash1293 and kibanamachine authored Jan 8, 2025
1 parent 2a7a53a commit 58d1522
Show file tree
Hide file tree
Showing 8 changed files with 604 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +34,7 @@ export const streamsRouteRepository = {
...disableStreamsRoute,
...dashboardRoutes,
...sampleStreamRoute,
...streamDetailRoute,
...unmappedFieldsRoute,
...schemaFieldsSimulationRoute,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'),
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>
Expand Down
Loading

0 comments on commit 58d1522

Please sign in to comment.