Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🌊 Stream overview page #204079

Merged
merged 13 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading