🌊 Streams: Overview page redesign (#214196)

This PR overhauls the overview page.

Classic stream:
<img width="1004" alt="Screenshot 2025-03-12 at 21 00 39"
src="https://github.com/user-attachments/assets/a058da08-0ae2-48cc-abca-359b23288b32"
/>

Wired stream:
<img width="1019" alt="Screenshot 2025-03-12 at 21 01 56"
src="https://github.com/user-attachments/assets/bca04537-f79b-4814-8e31-9d3dae18ad90"
/>


## Doubts / things I changed from the design

* Quick links is just all dashboards, so I adjusted the wording
accordingly. Also, since we render all dashboards, there isn't really
value in "View all assets"
* The panel on top is already stating the count of docs, why should we
repeat that in the histogram panel?
* No search bar - in the beginning we said we don't want this page to
become discover, a search bar feels like we are going there. Also, what
should the user enter there? I don't think we want to buy deeper in KQL
* Should the count of docs be the total count of the count for the
currently selected time range? Not sure what makes more sense
* For wired streams I left the tabs in place to switch between child
streams and quick links. We can revisit this once we get closer to
actually releasing wired streams

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Joe Reuter 2025-03-20 20:48:10 +01:00 committed by GitHub
parent f89e03c286
commit 184d0a32ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 772 additions and 367 deletions

View file

@ -14,3 +14,4 @@ export { useDebouncedValue } from './src/debounced_value';
export { ChartType } from './src/types';
export { getDatasourceId } from './src/get_datasource_id';
export { mapVisToChartType } from './src/map_vis_to_chart_type';
export { computeInterval } from './src/compute_interval';

View file

@ -11,7 +11,6 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { TimeRange } from '@kbn/es-query';
// follows the same logic with vega auto_date function
// we could move to a package and reuse in the future
const barTarget = 50; // same as vega
const roundInterval = (interval: number) => {
{

View file

@ -13,5 +13,6 @@
"@kbn/interpreter",
"@kbn/data-views-plugin",
"@kbn/es-query",
"@kbn/data-plugin",
]
}

View file

@ -8,7 +8,7 @@
*/
import { TimeRange } from '@kbn/es-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Timefilter } from './timefilter';
export interface TimefilterHook {
@ -18,6 +18,7 @@ export interface TimefilterHook {
end: number;
};
setTimeRange: React.Dispatch<React.SetStateAction<TimeRange>>;
refreshAbsoluteTimeRange: () => boolean;
}
export function createUseTimefilterHook(timefilter: Timefilter) {
@ -25,11 +26,14 @@ export function createUseTimefilterHook(timefilter: Timefilter) {
const [timeRange, setTimeRange] = useState(() => timefilter.getTime());
const [absoluteTimeRange, setAbsoluteTimeRange] = useState(() => timefilter.getAbsoluteTime());
const absoluteTimeRangeRef = useRef(absoluteTimeRange);
useEffect(() => {
const timeUpdateSubscription = timefilter.getTimeUpdate$().subscribe({
next: () => {
setTimeRange(() => timefilter.getTime());
const newAbsoluteTimeRange = timefilter.getAbsoluteTime();
absoluteTimeRangeRef.current = newAbsoluteTimeRange;
setAbsoluteTimeRange(() => timefilter.getAbsoluteTime());
},
});
@ -51,6 +55,19 @@ export function createUseTimefilterHook(timefilter: Timefilter) {
[]
);
const refreshAbsoluteTimeRange = useCallback(() => {
const newAbsoluteTimeRange = timefilter.getAbsoluteTime();
if (
newAbsoluteTimeRange.from !== absoluteTimeRangeRef.current.from ||
newAbsoluteTimeRange.to !== absoluteTimeRangeRef.current.to
) {
setAbsoluteTimeRange(newAbsoluteTimeRange);
absoluteTimeRangeRef.current = newAbsoluteTimeRange;
return true;
}
return false;
}, []);
const asEpoch = useMemo(() => {
return {
start: new Date(absoluteTimeRange.from).getTime(),
@ -62,6 +79,7 @@ export function createUseTimefilterHook(timefilter: Timefilter) {
timeRange,
absoluteTimeRange: asEpoch,
setTimeRange: setTimeRangeMemoized,
refreshAbsoluteTimeRange,
};
};
}

View file

@ -32,6 +32,7 @@ import {
getLensAttributesFromSuggestion,
ChartType,
mapVisToChartType,
computeInterval,
} from '@kbn/visualization-utils';
import { LegendSize } from '@kbn/visualizations-plugin/public';
import { XYConfiguration } from '@kbn/visualizations-plugin/common';
@ -51,7 +52,6 @@ import {
injectESQLQueryIntoLensLayers,
TIMESTAMP_COLUMN,
} from '../utils/external_vis_context';
import { computeInterval } from '../utils/compute_interval';
import { enrichLensAttributesWithTablesData } from '../utils/lens_vis_from_table';
const UNIFIED_HISTOGRAM_LAYER_ID = 'unifiedHistogram';

View file

@ -44811,7 +44811,6 @@
"xpack.streams.dashboardTable.dashboardNameColumnTitle": "Nom du tableau de bord",
"xpack.streams.dashboardTable.tagsColumnTitle": "Balises",
"xpack.streams.entityDetailOverview.createChildStream": "Créer un flux enfant",
"xpack.streams.entityDetailOverview.docCount": "{docCount} documents",
"xpack.streams.entityDetailOverview.noChildStreams": "Créer des sous-flux pour diviser les données ayant des différences de politiques de conservation, de schémas, etc.",
"xpack.streams.entityDetailOverview.searchBarPlaceholder": "Filtrer les données avec KQL",
"xpack.streams.entityDetailOverview.tabs.quicklinks": "Liens rapides",

View file

@ -44848,7 +44848,6 @@
"xpack.streams.dashboardTable.dashboardNameColumnTitle": "仪表板名称",
"xpack.streams.dashboardTable.tagsColumnTitle": "标签",
"xpack.streams.entityDetailOverview.createChildStream": "创建子数据流",
"xpack.streams.entityDetailOverview.docCount": "{docCount} 个文档",
"xpack.streams.entityDetailOverview.noChildStreams": "创建子流以分割具有不同保留策略、方案等的数据。",
"xpack.streams.entityDetailOverview.searchBarPlaceholder": "通过使用 KQL 来筛选数据",
"xpack.streams.entityDetailOverview.tabs.quicklinks": "快速链接",

View file

@ -6,5 +6,20 @@
*/
import { formatNumber } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export const formatBytes = (value: number) => formatNumber(value, '0.0 b');
export const formatIngestionRate = (bytesPerDay: number, perDayOnly = false) => {
const perDay = formatBytes(bytesPerDay);
const perMonth = formatBytes(bytesPerDay * 30);
if (perDayOnly)
return i18n.translate('xpack.streams.streamDetailOverview.ingestionRatePerDay', {
defaultMessage: '{perDay} / Day',
values: { perDay },
});
return i18n.translate('xpack.streams.streamDetailOverview.ingestionRatePerDayPerMonth', {
defaultMessage: '{perDay} / Day ({perMonth} / Month)',
values: { perDay, perMonth },
});
};

View file

@ -38,7 +38,7 @@ import { LifecycleEditAction } from './modal';
import { IlmLink } from './ilm_link';
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
import { DataStreamStats } from './hooks/use_data_stream_stats';
import { formatBytes } from './helpers/format_bytes';
import { formatIngestionRate } from './helpers/format_bytes';
export function RetentionMetadata({
definition,
@ -243,9 +243,3 @@ function MetadataRow({
</EuiFlexGroup>
);
}
const formatIngestionRate = (bytesPerDay: number) => {
const perDay = formatBytes(bytesPerDay);
const perMonth = formatBytes(bytesPerDay * 30);
return `${perDay} / Day - ${perMonth} / Month`;
};

View file

@ -30,6 +30,7 @@ export interface EntityViewTab {
name: string;
label: string;
content: React.ReactElement;
background: boolean;
}
export function EntityDetailViewWithoutParams({
@ -75,6 +76,7 @@ export function EntityDetailViewWithoutParams({
}),
label: tab.label,
content: tab.content,
background: tab.background,
},
];
})
@ -126,7 +128,9 @@ export function EntityDetailViewWithoutParams({
/>
</StreamsAppPageHeader>
</EuiFlexItem>
<StreamsAppPageBody>{selectedTabObject.content}</StreamsAppPageBody>
<StreamsAppPageBody background={selectedTabObject.background}>
{selectedTabObject.content}
</StreamsAppPageBody>
</EuiFlexGroup>
);
}

View file

@ -62,12 +62,14 @@ export function ControlledEsqlChart<T extends string>({
metricNames,
chartType = 'line',
height,
timerange,
}: {
id: string;
result: AbortableAsyncState<UnparsedEsqlResponse>;
metricNames: T[];
chartType?: 'area' | 'bar' | 'line';
height: number;
height?: number;
timerange?: { start: number; end: number };
}) {
const {
core: { uiSettings },
@ -84,12 +86,14 @@ export function ControlledEsqlChart<T extends string>({
[result, ...metricNames]
);
const effectiveHeight = height ? `${height}px` : '100%';
if (result.loading && !result.value?.values.length) {
return (
<LoadingPanel
loading
className={css`
height: ${height}px;
height: ${effectiveHeight};
`}
/>
);
@ -97,8 +101,9 @@ export function ControlledEsqlChart<T extends string>({
const xValues = allTimeseries.flatMap(({ data }) => data.map(({ x }) => x));
const min = Math.min(...xValues);
const max = Math.max(...xValues);
// todo - pull in time range here
const min = timerange?.start ?? Math.min(...xValues);
const max = timerange?.end ?? Math.max(...xValues);
const isEmpty = min === 0 && max === 0;
@ -115,7 +120,7 @@ export function ControlledEsqlChart<T extends string>({
<Chart
id={id}
className={css`
height: ${height}px;
height: ${effectiveHeight};
`}
>
<Tooltip
@ -146,7 +151,7 @@ export function ControlledEsqlChart<T extends string>({
}}
/>
<Settings
showLegend
showLegend={false}
legendPosition={Position.Bottom}
xDomain={xDomain}
locale={i18n.getLocale()}
@ -173,6 +178,7 @@ export function ControlledEsqlChart<T extends string>({
<Series
timeZone={timeZone}
key={serie.id}
color="#61A2FF"
id={serie.id}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}

View file

@ -0,0 +1,77 @@
/*
* 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, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { css } from '@emotion/css';
import { IngestStreamGetResponse, isDescendantOf } from '@kbn/streams-schema';
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
import { AssetImage } from '../asset_image';
import { StreamsList } from '../streams_list';
import { useWiredStreams } from '../../hooks/use_wired_streams';
export function ChildStreamList({ definition }: { definition?: IngestStreamGetResponse }) {
const router = useStreamsAppRouter();
const { wiredStreams } = useWiredStreams();
const childrenStreams = useMemo(() => {
if (!definition) {
return [];
}
return wiredStreams?.filter((d) => isDescendantOf(definition.stream.name, d.name));
}, [definition, wiredStreams]);
if (definition && childrenStreams?.length === 0) {
return (
<EuiFlexItem grow>
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem
grow={false}
className={css`
max-width: 350px;
`}
>
<EuiFlexGroup direction="column" gutterSize="s">
<AssetImage type="welcome" />
<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
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/management/{subtab}', {
path: {
key: definition?.stream.name,
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
}
return <StreamsList streams={childrenStreams} showControls={false} />;
}

View file

@ -0,0 +1,71 @@
/*
* 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { AbortableAsyncState } from '@kbn/react-hooks';
import type { UnparsedEsqlResponse } from '@kbn/traced-es-client';
import { ControlledEsqlChart } from '../../esql_chart/controlled_esql_chart';
interface StreamChartPanelProps {
histogramQueryFetch: AbortableAsyncState<UnparsedEsqlResponse | undefined>;
discoverLink?: string;
timerange: {
start: number;
end: number;
};
}
export function StreamChartPanel({
histogramQueryFetch,
discoverLink,
timerange,
}: StreamChartPanelProps) {
return (
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup
direction="column"
className={css`
height: 100%;
`}
>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="s">
{i18n.translate('xpack.streams.streamDetailOverview.logRate', {
defaultMessage: 'Documents',
})}
</EuiText>
</EuiFlexItem>
<EuiButtonEmpty
data-test-subj="streamsDetailOverviewOpenInDiscoverButton"
iconType="discoverApp"
href={discoverLink}
isDisabled={!discoverLink}
>
{i18n.translate('xpack.streams.streamDetailOverview.openInDiscoverButtonLabel', {
defaultMessage: 'Open in Discover',
})}
</EuiButtonEmpty>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow>
<ControlledEsqlChart
result={histogramQueryFetch}
id="entity_log_rate"
metricNames={['metric']}
chartType={'bar'}
timerange={timerange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,204 @@
/*
* 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,
EuiFlexItem,
EuiIconTip,
EuiPanel,
EuiText,
formatNumber,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import React, { ReactNode } from 'react';
import { IngestStreamGetResponse, IngestStreamLifecycleILM } from '@kbn/streams-schema';
import { IlmLocatorParams } from '@kbn/index-lifecycle-management-common-shared';
import { LocatorPublic } from '@kbn/share-plugin/public';
import type { StreamDetailsResponse } from '@kbn/streams-plugin/server/routes/internal/streams/crud/route';
import { IlmLink } from '../../data_management/stream_detail_lifecycle/ilm_link';
import {
formatBytes,
formatIngestionRate,
} from '../../data_management/stream_detail_lifecycle/helpers/format_bytes';
import { DataStreamStats } from '../../data_management/stream_detail_lifecycle/hooks/use_data_stream_stats';
interface StreamStatsPanelProps {
definition?: IngestStreamGetResponse;
dataStreamStats?: DataStreamStats;
docCount?: StreamDetailsResponse;
ilmLocator?: LocatorPublic<IlmLocatorParams>;
}
const RetentionDisplay = ({
definition,
ilmLocator,
}: {
definition?: IngestStreamGetResponse;
ilmLocator?: LocatorPublic<IlmLocatorParams>;
}) => {
if (!definition) return <>-</>;
if ('dsl' in definition.effective_lifecycle) {
return (
<>
{definition?.effective_lifecycle.dsl.data_retention ||
i18n.translate('xpack.streams.entityDetailOverview.unlimited', {
defaultMessage: 'Keep indefinitely',
})}
</>
);
}
return (
<IlmLink
lifecycle={definition.effective_lifecycle as IngestStreamLifecycleILM}
ilmLocator={ilmLocator}
/>
);
};
interface StatItemProps {
label: ReactNode;
value: ReactNode;
withBorder?: boolean;
}
const StatItem = ({ label, value, withBorder = false }: StatItemProps) => {
const { euiTheme } = useEuiTheme();
const borderStyle = withBorder
? css`
border-left: 1px solid ${euiTheme.colors.borderBaseSubdued};
padding-left: ${euiTheme.size.s};
`
: '';
return (
<EuiFlexItem grow className={borderStyle}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiText size="xs" color="subdued">
{label}
</EuiText>
<EuiText
size="m"
className={css`
font-weight: bold;
`}
>
{value}
</EuiText>
</EuiFlexGroup>
</EuiFlexItem>
);
};
export function StreamStatsPanel({
definition,
dataStreamStats,
docCount,
ilmLocator,
}: StreamStatsPanelProps) {
const retentionLabel = i18n.translate('xpack.streams.entityDetailOverview.retention', {
defaultMessage: 'Data retention',
});
const documentCountLabel = i18n.translate('xpack.streams.entityDetailOverview.count', {
defaultMessage: 'Document count',
});
const storageSizeLabel = i18n.translate('xpack.streams.entityDetailOverview.size', {
defaultMessage: 'Storage size',
});
const ingestionLabel = i18n.translate('xpack.streams.entityDetailOverview.ingestion', {
defaultMessage: 'Ingestion',
});
return (
<EuiFlexGroup direction="row" gutterSize="s">
<EuiFlexItem grow={3}>
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiText size="xs" color="subdued">
{retentionLabel}
</EuiText>
<EuiText size="m">
<RetentionDisplay definition={definition} ilmLocator={ilmLocator} />
</EuiText>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={9}>
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup>
<StatItem
label={documentCountLabel}
value={docCount ? formatNumber(docCount.details.count || 0, 'decimal0') : '-'}
/>
<StatItem
label={
<>
{storageSizeLabel}
<EuiIconTip
content={i18n.translate('xpack.streams.streamDetailOverview.sizeTip', {
defaultMessage:
'Estimated size based on the number of documents in the current time range and the total size of the stream.',
})}
position="right"
/>
</>
}
value={
dataStreamStats && docCount
? formatBytes(getStorageSizeForTimeRange(dataStreamStats, docCount))
: '-'
}
withBorder
/>
<StatItem
label={
<>
{ingestionLabel}
<EuiIconTip
content={i18n.translate(
'xpack.streams.streamDetailLifecycle.ingestionRateDetails',
{
defaultMessage:
'Estimated average (stream total size divided by the number of days since creation).',
}
)}
position="right"
/>
</>
}
value={
dataStreamStats ? formatIngestionRate(dataStreamStats.bytesPerDay || 0, true) : '-'
}
withBorder
/>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
);
}
function getStorageSizeForTimeRange(
dataStreamStats: DataStreamStats,
docCount: StreamDetailsResponse
) {
const storageSize = dataStreamStats.sizeBytes;
const totalCount = dataStreamStats.totalDocs;
const countForTimeRange = docCount.details.count;
if (!storageSize || !totalCount || !countForTimeRange) {
return 0;
}
const bytesPerDoc = totalCount ? storageSize / totalCount : 0;
return bytesPerDoc * countForTimeRange;
}

View file

@ -0,0 +1,61 @@
/*
* 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, EuiPanel, EuiTab, EuiTabs } from '@elastic/eui';
import { css } from '@emotion/css';
import React, { useState, ReactNode } from 'react';
interface Tab {
id: string;
name: string;
content: ReactNode;
}
interface TabsPanelProps {
tabs: Tab[];
}
export function TabsPanel({ tabs }: TabsPanelProps) {
const [selectedTab, setSelectedTab] = useState<string | undefined>(undefined);
if (tabs.length === 0) {
return null;
}
return (
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup
direction="column"
gutterSize="s"
className={css`
height: 100%;
`}
>
{tabs.length === 1 ? (
tabs[0].content
) : (
<>
<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>
</EuiPanel>
);
}

View file

@ -4,348 +4,5 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiPanel,
EuiTab,
EuiTabs,
EuiText,
} from '@elastic/eui';
import { calculateAuto } from '@kbn/calculate-auto';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import React, { useMemo } from 'react';
import { css } from '@emotion/css';
import {
IngestStreamGetResponse,
isDescendantOf,
isUnwiredStreamGetResponse,
isWiredStreamDefinition,
} from '@kbn/streams-schema';
import type { SanitizedDashboardAsset } from '@kbn/streams-plugin/server/routes/dashboards/route';
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';
import { useDashboardsFetch } from '../../hooks/use_dashboards_fetch';
import { DashboardsTable } from '../stream_detail_dashboards_view/dashboard_table';
import { AssetImage } from '../asset_image';
import { useWiredStreams } from '../../hooks/use_wired_streams';
const formatNumber = (val: number) => {
return Number(val).toLocaleString('en', {
maximumFractionDigits: 1,
});
};
export function StreamDetailOverview({ definition }: { definition?: IngestStreamGetResponse }) {
const {
dependencies: {
start: {
data,
dataViews,
streams: { streamsRepositoryClient },
share,
},
},
} = useKibana();
const {
timeRange,
setTimeRange,
absoluteTimeRange: { start, end },
} = data.query.timefilter.timefilter.useTimefilter();
const indexPatterns = useMemo(() => {
return getIndexPatterns(definition?.stream);
}, [definition]);
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]
);
const docCountFetch = useStreamsAppFetch(
async ({ signal }) => {
if (
!definition ||
(isUnwiredStreamGetResponse(definition) && !definition.data_stream_exists)
) {
return undefined;
}
return streamsRepositoryClient.fetch('GET /internal/streams/{name}/_details', {
signal,
params: {
path: {
name: definition.stream.name,
},
query: {
start: String(start),
end: String(end),
},
},
});
},
[definition, dataViews, streamsRepositoryClient, start, end]
);
const [selectedTab, setSelectedTab] = React.useState<string | undefined>(undefined);
const tabs = [
...(definition && isWiredStreamDefinition(definition.stream)
? [
{
id: 'streams',
name: i18n.translate('xpack.streams.entityDetailOverview.tabs.streams', {
defaultMessage: 'Streams',
}),
content: <ChildStreamList definition={definition} />,
},
]
: []),
{
id: 'quicklinks',
name: i18n.translate('xpack.streams.entityDetailOverview.tabs.quicklinks', {
defaultMessage: 'Quick Links',
}),
content: <QuickLinks definition={definition} />,
},
];
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<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;
}
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}
/>
</EuiFlexItem>
<EuiButton
data-test-subj="streamsDetailOverviewOpenInDiscoverButton"
iconType="discoverApp"
href={discoverLink}
color="text"
>
{i18n.translate('xpack.streams.streamDetailOverview.openInDiscoverButtonLabel', {
defaultMessage: 'Open in Discover',
})}
</EuiButton>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel hasShadow={false} hasBorder>
<EuiFlexGroup direction="column">
<ControlledEsqlChart
result={histogramQueryFetch}
id="entity_log_rate"
metricNames={['metric']}
height={200}
chartType={'bar'}
/>
</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>
</>
);
}
const EMPTY_DASHBOARD_LIST: SanitizedDashboardAsset[] = [];
function QuickLinks({ definition }: { definition?: IngestStreamGetResponse }) {
const dashboardsFetch = useDashboardsFetch(definition?.stream.name);
return (
<DashboardsTable
entityId={definition?.stream.name}
dashboards={dashboardsFetch.value?.dashboards ?? EMPTY_DASHBOARD_LIST}
loading={dashboardsFetch.loading}
/>
);
}
function ChildStreamList({ definition }: { definition?: IngestStreamGetResponse }) {
const router = useStreamsAppRouter();
const { wiredStreams } = useWiredStreams();
const childrenStreams = useMemo(() => {
if (!definition) {
return [];
}
return wiredStreams?.filter((d) => isDescendantOf(definition.stream.name, d.name));
}, [definition, wiredStreams]);
if (definition && childrenStreams?.length === 0) {
return (
<EuiFlexItem grow>
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem
grow={false}
className={css`
max-width: 350px;
`}
>
<EuiFlexGroup direction="column" gutterSize="s">
<AssetImage type="welcome" />
<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
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/management/{subtab}', {
path: {
key: definition?.stream.name,
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
}
return <StreamsList streams={childrenStreams} showControls={false} />;
}
export { StreamDetailOverview } from './stream_detail_overview';

View file

@ -0,0 +1,69 @@
/*
* 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, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { css } from '@emotion/css';
import { IngestStreamGetResponse } from '@kbn/streams-schema';
import type { SanitizedDashboardAsset } from '@kbn/streams-plugin/server/routes/dashboards/route';
import { useDashboardsFetch } from '../../hooks/use_dashboards_fetch';
import { AssetImage } from '../asset_image';
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
import { DashboardsTable } from '../stream_detail_dashboards_view/dashboard_table';
const EMPTY_DASHBOARD_LIST: SanitizedDashboardAsset[] = [];
export function QuickLinks({ definition }: { definition?: IngestStreamGetResponse }) {
const router = useStreamsAppRouter();
const dashboardsFetch = useDashboardsFetch(definition?.stream.name);
if (definition && !dashboardsFetch.loading && dashboardsFetch.value?.dashboards.length === 0) {
return (
<EuiFlexItem grow>
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem
grow={false}
className={css`
max-width: 200px;
`}
>
<EuiFlexGroup direction="column" gutterSize="s">
<AssetImage type="welcome" />
<EuiText size="xs" textAlign="center" color="subdued">
{i18n.translate('xpack.streams.entityDetailOverview.linkDashboardsText', {
defaultMessage: 'Link dashboards to this stream for quick access',
})}
</EuiText>
<EuiFlexGroup justifyContent="center">
<EuiLink
href={router.link('/{key}/{tab}', {
path: {
key: definition?.stream.name,
tab: 'dashboards',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.addDashboardButton', {
defaultMessage: 'Add dashboards',
})}
</EuiLink>
</EuiFlexGroup>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
}
return (
<DashboardsTable
dashboards={dashboardsFetch.value?.dashboards ?? EMPTY_DASHBOARD_LIST}
loading={dashboardsFetch.loading}
/>
);
}

View file

@ -0,0 +1,221 @@
/*
* 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, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { IngestStreamGetResponse, isWiredStreamDefinition } from '@kbn/streams-schema';
import { ILM_LOCATOR_ID, IlmLocatorParams } from '@kbn/index-lifecycle-management-common-shared';
import { computeInterval } from '@kbn/visualization-utils';
import { useKibana } from '../../hooks/use_kibana';
import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch';
import { StreamsAppSearchBar } from '../streams_app_search_bar';
import { getIndexPatterns } from '../../util/hierarchy_helpers';
import { useDataStreamStats } from '../data_management/stream_detail_lifecycle/hooks/use_data_stream_stats';
import { QuickLinks } from './quick_links';
import { ChildStreamList } from './child_stream_list';
import { StreamStatsPanel } from './components/stream_stats_panel';
import { StreamChartPanel } from './components/stream_chart_panel';
import { TabsPanel } from './components/tabs_panel';
export function StreamDetailOverview({ definition }: { definition?: IngestStreamGetResponse }) {
const {
dependencies: {
start: {
data,
dataViews,
streams: { streamsRepositoryClient },
share,
},
},
} = useKibana();
const {
timeRange,
setTimeRange,
absoluteTimeRange: { start, end },
refreshAbsoluteTimeRange,
} = data.query.timefilter.timefilter.useTimefilter();
const indexPatterns = useMemo(() => {
return getIndexPatterns(definition?.stream);
}, [definition]);
const discoverLocator = useMemo(
() => share.url.locators.get('DISCOVER_APP_LOCATOR'),
[share.url.locators]
);
const bucketSize = useMemo(() => computeInterval(timeRange, data), [data, timeRange]);
const queries = useMemo(() => {
if (!indexPatterns) {
return undefined;
}
const baseQuery = `FROM ${indexPatterns.join(', ')}`;
const histogramQuery = `${baseQuery} | STATS metric = COUNT(*) BY @timestamp = BUCKET(@timestamp, ${bucketSize})`;
return {
baseQuery,
histogramQuery,
};
}, [bucketSize, 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]
);
const docCountFetch = useStreamsAppFetch(
async ({ signal }) => {
if (!definition) {
return undefined;
}
return streamsRepositoryClient.fetch('GET /internal/streams/{name}/_details', {
signal,
params: {
path: {
name: definition.stream.name,
},
query: {
start: String(start),
end: String(end),
},
},
});
},
[definition, streamsRepositoryClient, start, end]
);
const dataStreamStats = useDataStreamStats({ definition });
const tabs = useMemo(
() => [
...(definition && isWiredStreamDefinition(definition.stream)
? [
{
id: 'streams',
name: i18n.translate('xpack.streams.entityDetailOverview.tabs.streams', {
defaultMessage: 'Streams',
}),
content: <ChildStreamList definition={definition} />,
},
]
: []),
{
id: 'quicklinks',
name: i18n.translate('xpack.streams.entityDetailOverview.tabs.quicklinks', {
defaultMessage: 'Quick Links',
}),
content: <QuickLinks definition={definition} />,
},
],
[definition]
);
const ilmLocator = share.url.locators.get<IlmLocatorParams>(ILM_LOCATOR_ID);
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" justifyContent="flexEnd">
<EuiFlexItem grow>
<StreamsAppSearchBar
onQuerySubmit={({ dateRange }, isUpdate) => {
if (!isUpdate) {
if (!refreshAbsoluteTimeRange()) {
// if absolute time range didn't change, we need to manually refresh the histogram
// otherwise it will be refreshed by the changed absolute time range
histogramQueryFetch.refresh();
docCountFetch.refresh();
}
return;
}
if (dateRange) {
setTimeRange({ from: dateRange.from, to: dateRange?.to, mode: dateRange.mode });
}
}}
onRefresh={() => {
histogramQueryFetch.refresh();
docCountFetch.refresh();
}}
placeholder={i18n.translate(
'xpack.streams.entityDetailOverview.searchBarPlaceholder',
{
defaultMessage: 'Filter data by using KQL',
}
)}
dateRangeFrom={timeRange.from}
dateRangeTo={timeRange.to}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<StreamStatsPanel
definition={definition}
dataStreamStats={dataStreamStats.stats}
docCount={docCountFetch.value}
ilmLocator={ilmLocator}
/>
</EuiFlexItem>
<EuiFlexItem grow>
<EuiFlexGroup direction="row">
<EuiFlexItem grow={4}>{definition && <TabsPanel tabs={tabs} />}</EuiFlexItem>
<EuiFlexItem grow={8}>
<StreamChartPanel
histogramQueryFetch={histogramQueryFetch}
discoverLink={discoverLink}
timerange={{ start, end }}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

View file

@ -45,6 +45,7 @@ export function StreamDetailViewContent({ name, tab }: { name: string; tab: stri
label: i18n.translate('xpack.streams.streamDetailView.overviewTab', {
defaultMessage: 'Overview',
}),
background: false,
},
{
name: 'dashboards',
@ -52,6 +53,7 @@ export function StreamDetailViewContent({ name, tab }: { name: string; tab: stri
label: i18n.translate('xpack.streams.streamDetailView.dashboardsTab', {
defaultMessage: 'Dashboards',
}),
background: true,
},
{
name: 'management',
@ -59,6 +61,7 @@ export function StreamDetailViewContent({ name, tab }: { name: string; tab: stri
label: i18n.translate('xpack.streams.streamDetailView.managementTab', {
defaultMessage: 'Management',
}),
background: true,
},
];

View file

@ -47,7 +47,7 @@ export function StreamListView() {
}
/>
</EuiFlexItem>
<StreamsAppPageBody>
<StreamsAppPageBody background>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiSearchBar

View file

@ -8,7 +8,13 @@ import React from 'react';
import { EuiPanel, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
export function StreamsAppPageBody({ children }: { children: React.ReactNode }) {
export function StreamsAppPageBody({
children,
background,
}: {
children: React.ReactNode;
background: boolean;
}) {
const theme = useEuiTheme().euiTheme;
return (
<EuiPanel
@ -19,6 +25,7 @@ export function StreamsAppPageBody({ children }: { children: React.ReactNode })
border-radius: 0px;
display: flex;
overflow-y: auto;
${!background ? `background-color: transparent;` : ''}
`}
paddingSize="l"
>

View file

@ -27,13 +27,11 @@
"@kbn/react-kibana-context-render",
"@kbn/code-editor",
"@kbn/ui-theme",
"@kbn/calculate-auto",
"@kbn/kibana-react-plugin",
"@kbn/es-query",
"@kbn/server-route-repository-client",
"@kbn/logging",
"@kbn/config-schema",
"@kbn/calculate-auto",
"@kbn/streams-plugin",
"@kbn/share-plugin",
"@kbn/code-editor",
@ -59,6 +57,7 @@
"@kbn/licensing-plugin",
"@kbn/datemath",
"@kbn/xstate-utils",
"@kbn/visualization-utils",
"@kbn/utility-types",
"@kbn/discover-utils",
"@kbn/discover-shared-plugin",