[Dataset quality] Flyout Summary section (#179479)

Closes https://github.com/elastic/kibana/issues/170492

## Summary

The PR Implements the Dataset Quality Flyout summary KPIs: "Docs count",
"Size", "Services", "Hosts" and "Degraded docs".

|Stateful|Serverless|
|:---:|:---|
|<img alt="Screenshot 2024-03-26 at 19 14 34"
src="d75de56f-0916-48a6-a101-fc3d8f084f4d">|<img
alt="Screenshot 2024-03-26 at 17 02 05"
src="46d58946-2ed5-4c21-b53c-be3d43e7b857">|

"Show all" links for "Services" and "Hosts" metrics depend on some
development in APM and Infra plugins and therefore will be implemented
in a follow up issue.

Note that "Size" metric is excluded on Serverless as the endpoint uses
ES's `_stats` endpoint which is not available on Serverless (at the time
of creation of this PR). The code contains some conditions and cases to
tackle this, which should be considered as a temporary measure. All of
the code related to these changes should either be removed or modified
once there's a way to calculate the size of an index/dataStream on
Serverless (see [related
](https://github.com/elastic/kibana/issues/178954)). The following
changes are made in particular:
 - Size UI component on the Flyout will be hidden on Serverless
- `dataset_quality/data_streams/{dataStream}/details` endpoint will
return `NaN` for size on Serverless
- Unit, integration and end-to-end tests on Serverless handle
"sizeBytes" property accordingly
This commit is contained in:
Abdul Wahab Zahid 2024-04-24 13:20:31 +02:00 committed by GitHub
parent 67a2eb54c9
commit e1ec9ee17e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 2731 additions and 472 deletions

1
.github/CODEOWNERS vendored
View file

@ -1133,6 +1133,7 @@ packages/kbn-monaco/src/esql @elastic/kibana-esql
# Logs
/x-pack/test/api_integration/apis/logs_ui @elastic/obs-ux-logs-team
/x-pack/test/dataset_quality_api_integration @elastic/obs-ux-logs-team
/x-pack/test_serverless/api_integration/test_suites/observability/dataset_quality_api_integration @elastic/obs-ux-logs-team
/x-pack/test/functional/apps/observability_logs_explorer @elastic/obs-ux-logs-team
/x-pack/test_serverless/functional/test_suites/observability/observability_logs_explorer @elastic/obs-ux-logs-team
/x-pack/test/functional/apps/dataset_quality @elastic/obs-ux-logs-team

View file

@ -76,9 +76,19 @@ export const degradedDocsRt = rt.type({
export type DegradedDocs = rt.TypeOf<typeof degradedDocsRt>;
export const dataStreamSettingsRt = rt.partial({
createdOn: rt.union([rt.null, rt.number]), // rt.null is needed because `createdOn` is not available on Serverless
});
export type DataStreamSettings = rt.TypeOf<typeof dataStreamSettingsRt>;
export const dataStreamDetailsRt = rt.partial({
createdOn: rt.number,
lastActivity: rt.number,
degradedDocsCount: rt.number,
docsCount: rt.number,
sizeBytes: rt.union([rt.null, rt.number]), // rt.null is only needed for https://github.com/elastic/kibana/issues/178954
services: rt.record(rt.string, rt.array(rt.string)),
hosts: rt.record(rt.string, rt.array(rt.string)),
});
export type DataStreamDetails = rt.TypeOf<typeof dataStreamDetailsRt>;
@ -95,6 +105,8 @@ export const getDataStreamsDegradedDocsStatsResponseRt = rt.exact(
})
);
export const getDataStreamsSettingsResponseRt = rt.exact(dataStreamSettingsRt);
export const getDataStreamsDetailsResponseRt = rt.exact(dataStreamDetailsRt);
export const dataStreamsEstimatedDataInBytesRT = rt.type({

View file

@ -18,4 +18,11 @@ export const DEFAULT_SORT_DIRECTION = 'asc';
export const NONE = 'none';
export const DEFAULT_TIME_RANGE = { from: 'now-24h', to: 'now' };
export const DEFAULT_DATEPICKER_REFRESH = { value: 60000, pause: false };
export const DEFAULT_DEGRADED_DOCS = { percentage: 0, count: 0 };
export const NUMBER_FORMAT = '0,0.[000]';
export const BYTE_NUMBER_FORMAT = '0.0 b';
export const MAX_HOSTS_METRIC_VALUE = 50;

View file

@ -30,8 +30,17 @@ export type GetDataStreamsDegradedDocsStatsResponse =
export type DataStreamDegradedDocsStatServiceResponse = DegradedDocsStatType[];
export type DegradedDocsStatType = GetDataStreamsDegradedDocsStatsResponse['degradedDocs'][0];
export type GetDataStreamDetailsParams =
export type GetDataStreamSettingsParams =
APIClientRequestParamsOf<`GET /internal/dataset_quality/data_streams/{dataStream}/settings`>['params']['path'];
export type GetDataStreamSettingsResponse =
APIReturnType<`GET /internal/dataset_quality/data_streams/{dataStream}/settings`>;
type GetDataStreamDetailsPathParams =
APIClientRequestParamsOf<`GET /internal/dataset_quality/data_streams/{dataStream}/details`>['params']['path'];
type GetDataStreamDetailsQueryParams =
APIClientRequestParamsOf<`GET /internal/dataset_quality/data_streams/{dataStream}/details`>['params']['query'];
export type GetDataStreamDetailsParams = GetDataStreamDetailsPathParams &
GetDataStreamDetailsQueryParams;
export type GetDataStreamDetailsResponse =
APIReturnType<`GET /internal/dataset_quality/data_streams/{dataStream}/details`>;
@ -47,4 +56,4 @@ export type GetIntegrationDashboardsResponse =
export type DashboardType = GetIntegrationDashboardsResponse['dashboards'][0];
export type { DataStreamStat } from './data_stream_stat';
export type { DataStreamDetails } from '../api_types';
export type { DataStreamDetails, DataStreamSettings } from '../api_types';

View file

@ -88,6 +88,10 @@ export const flyoutIntegrationNameText = i18n.translate(
}
);
export const flyoutSummaryText = i18n.translate('xpack.datasetQuality.flyoutSummaryTitle', {
defaultMessage: 'Summary',
});
export const flyoutDegradedDocsText = i18n.translate(
'xpack.datasetQuality.flyout.degradedDocsTitle',
{
@ -110,6 +114,29 @@ export const flyoutDegradedDocsPercentageText = i18n.translate(
}
);
export const flyoutDocsCountTotalText = i18n.translate(
'xpack.datasetQuality.flyoutDocsCountTotal',
{
defaultMessage: 'Docs count (total)',
}
);
export const flyoutSizeText = i18n.translate('xpack.datasetQuality.flyoutSizeText', {
defaultMessage: 'Size',
});
export const flyoutServicesText = i18n.translate('xpack.datasetQuality.flyoutServicesText', {
defaultMessage: 'Services',
});
export const flyoutHostsText = i18n.translate('xpack.datasetQuality.flyoutHostsText', {
defaultMessage: 'Hosts',
});
export const flyoutShowAllText = i18n.translate('xpack.datasetQuality.flyoutShowAllText', {
defaultMessage: 'Show all',
});
/*
Summary Panel
*/

View file

@ -6,9 +6,10 @@
*/
import React from 'react';
import { formatNumber } from '@elastic/eui';
import { formatBytes } from '@kbn/formatters';
import { useSummaryPanelContext } from '../../../hooks';
import { BYTE_NUMBER_FORMAT } from '../../../../common/constants';
import {
summaryPanelEstimatedDataText,
summaryPanelEstimatedDataTooltipText,
@ -22,7 +23,7 @@ export function EstimatedData() {
<LastDayDataPlaceholder
title={summaryPanelEstimatedDataText}
tooltip={summaryPanelEstimatedDataTooltipText}
value={formatBytes(estimatedData.estimatedDataInBytes)}
value={formatNumber(estimatedData.estimatedDataInBytes, BYTE_NUMBER_FORMAT)}
isLoading={isEstimatedDataLoading}
/>
);

View file

@ -16,6 +16,7 @@ import {
EuiToolTip,
EuiButtonIcon,
EuiText,
formatNumber,
EuiSkeletonRectangle,
} from '@elastic/eui';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
@ -24,10 +25,10 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { css } from '@emotion/react';
import { formatBytes } from '@kbn/formatters';
import {
DEGRADED_QUALITY_MINIMUM_PERCENTAGE,
POOR_QUALITY_MINIMUM_PERCENTAGE,
BYTE_NUMBER_FORMAT,
} from '../../../../common/constants';
import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat';
import { QualityIndicator } from '../../quality_indicator';
@ -207,7 +208,7 @@ export const getDatasetQualityTableColumns = ({
borderRadius="m"
isLoading={loadingDataStreamStats}
>
{formatBytes(dataStreamStat.sizeBytes || 0)}
{formatNumber(dataStreamStat.sizeBytes || 0, BYTE_NUMBER_FORMAT)}
</EuiSkeletonRectangle>
),
width: '100px',

View file

@ -7,6 +7,7 @@
import { EuiSkeletonRectangle, EuiFlexGroup, EuiLink } from '@elastic/eui';
import React from 'react';
import { _IGNORED } from '../../../../common/es_fields';
import { useLinkToLogsExplorer } from '../../../hooks';
import { QualityPercentageIndicator } from '../../quality_indicator';
import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat';
@ -24,7 +25,7 @@ export const DegradedDocsPercentageLink = ({
const logsExplorerLinkProps = useLinkToLogsExplorer({
dataStreamStat,
query: { language: 'kuery', query: '_ignored:*' },
query: { language: 'kuery', query: `${_IGNORED}: *` },
});
return (

View file

@ -8,7 +8,7 @@
import React from 'react';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types';
import { DataStreamDetails } from '../../../common/data_streams_stats';
import { DataStreamDetails, DataStreamSettings } from '../../../common/data_streams_stats';
import {
flyoutDatasetCreatedOnText,
flyoutDatasetDetailsText,
@ -18,18 +18,27 @@ import { FieldsList, FieldsListLoading } from './fields_list';
interface DatasetSummaryProps {
fieldFormats: FieldFormatsStart;
dataStreamSettings?: DataStreamSettings;
dataStreamSettingsLoading: boolean;
dataStreamDetails?: DataStreamDetails;
dataStreamDetailsLoading: boolean;
}
export function DatasetSummary({ dataStreamDetails, fieldFormats }: DatasetSummaryProps) {
export function DatasetSummary({
dataStreamSettings,
dataStreamSettingsLoading,
dataStreamDetails,
dataStreamDetailsLoading,
fieldFormats,
}: DatasetSummaryProps) {
const dataFormatter = fieldFormats.getDefaultInstance(KBN_FIELD_TYPES.DATE, [
ES_FIELD_TYPES.DATE,
]);
const formattedLastActivity = dataStreamDetails?.lastActivity
? dataFormatter.convert(dataStreamDetails?.lastActivity)
: '-';
const formattedCreatedOn = dataStreamDetails?.createdOn
? dataFormatter.convert(dataStreamDetails.createdOn)
const formattedCreatedOn = dataStreamSettings?.createdOn
? dataFormatter.convert(dataStreamSettings.createdOn)
: '-';
return (
@ -39,10 +48,12 @@ export function DatasetSummary({ dataStreamDetails, fieldFormats }: DatasetSumma
{
fieldTitle: flyoutDatasetLastActivityText,
fieldValue: formattedLastActivity,
isLoading: dataStreamDetailsLoading,
},
{
fieldTitle: flyoutDatasetCreatedOnText,
fieldValue: formattedCreatedOn,
isLoading: dataStreamSettingsLoading,
},
]}
/>

View file

@ -5,166 +5,94 @@
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiSpacer,
EuiTitle,
EuiText,
EuiSuperDatePicker,
OnRefreshProps,
EuiToolTip,
EuiIcon,
EuiCode,
OnTimeChangeProps,
EuiSkeletonRectangle,
} from '@elastic/eui';
import {
UnifiedBreakdownFieldSelector,
fieldSupportsBreakdown,
} from '@kbn/unified-histogram-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { UnifiedBreakdownFieldSelector } from '@kbn/unified-histogram-plugin/public';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { useDegradedDocsChart } from '../../../hooks';
import { useCreateDataView } from '../../../hooks';
import { indexNameToDataStreamParts } from '../../../../common/utils';
import { DEFAULT_LOGS_DATA_VIEW, DEFAULT_TIME_RANGE } from '../../../../common/constants';
import { DEFAULT_TIME_RANGE, DEFAULT_DATEPICKER_REFRESH } from '../../../../common/constants';
import { flyoutDegradedDocsText } from '../../../../common/translations';
import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
import { useDatasetQualityContext } from '../../dataset_quality/context';
import { DegradedDocsChart } from './degraded_docs_chart';
const DEFAULT_REFRESH = { value: 60000, pause: false };
export function DegradedDocs({
dataStream,
timeRange = { ...DEFAULT_TIME_RANGE, refresh: DEFAULT_REFRESH },
breakdownField,
timeRange = { ...DEFAULT_TIME_RANGE, refresh: DEFAULT_DATEPICKER_REFRESH },
lastReloadTime,
onTimeRangeChange,
}: {
dataStream?: string;
timeRange?: TimeRangeConfig;
breakdownField?: string;
lastReloadTime: number;
onTimeRangeChange: (props: Pick<OnTimeChangeProps, 'start' | 'end'>) => void;
}) {
const { service } = useDatasetQualityContext();
const { dataView } = useCreateDataView({
indexPatternString: getDataViewIndexPattern(dataStream),
});
const { dataView, breakdown, ...chartProps } = useDegradedDocsChart({ dataStream });
const [breakdownDataViewField, setBreakdownDataViewField] = useState<DataViewField | undefined>(
undefined
);
const [lastReloadTime, setLastReloadTime] = useState<number>(Date.now());
useEffect(() => {
if (dataView) {
const dataViewField = getDataViewField(dataView, breakdownField);
if (dataViewField) {
const isFieldBreakable = fieldSupportsBreakdown(dataViewField);
if (isFieldBreakable) {
setBreakdownDataViewField(dataViewField);
} else {
setBreakdownDataViewField(undefined);
// TODO: If needed, notify user that the field is not breakable
}
} else {
setBreakdownDataViewField(undefined);
}
if (breakdown.dataViewField && breakdown.fieldSupportsBreakdown) {
setBreakdownDataViewField(breakdown.dataViewField);
} else {
setBreakdownDataViewField(undefined);
}
}, [dataView, breakdownField]);
const handleRefresh = useCallback((_refreshProps: OnRefreshProps) => {
setLastReloadTime(Date.now());
}, []);
const handleTimeChange = useCallback(
(durationRange) => {
service.send({
type: 'UPDATE_INSIGHTS_TIME_RANGE',
timeRange: {
from: durationRange.start,
to: durationRange.end,
refresh: timeRange.refresh ?? DEFAULT_REFRESH,
},
});
},
[service, timeRange.refresh]
);
const handleBreakdownFieldChange = useCallback(
(field: DataViewField | undefined) => {
service.send({
type: 'BREAKDOWN_FIELD_CHANGE',
breakdownField: field?.name ?? null,
});
},
[service]
);
if (breakdown.dataViewField && !breakdown.fieldSupportsBreakdown) {
// TODO: If needed, notify user that the field is not breakable
}
}, [setBreakdownDataViewField, breakdown.dataViewField, breakdown.fieldSupportsBreakdown]);
return (
<EuiPanel hasBorder grow={false}>
<EuiFlexGroup direction="column" justifyContent="center">
<EuiFlexGroup
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem
css={css`
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
`}
justifyContent="flexStart"
alignItems="center"
gutterSize="xs"
>
<EuiTitle size="s">
<EuiText>{flyoutDegradedDocsText}</EuiText>
<EuiTitle size="xxxs">
<h6>{flyoutDegradedDocsText}</h6>
</EuiTitle>
<EuiToolTip content={degradedDocsTooltip}>
<EuiIcon size="m" color="subdued" type="questionInCircle" className="eui-alignTop" />
</EuiToolTip>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexGroup
css={css`
flex-wrap: wrap-reverse;
`}
alignItems="center"
justifyContent="spaceBetween"
>
{dataView ? (
<UnifiedBreakdownFieldSelector
dataView={dataView}
breakdown={{ field: breakdownDataViewField }}
onBreakdownFieldChange={handleBreakdownFieldChange}
/>
) : (
<EuiSkeletonRectangle width={160} height={32} />
)}
<EuiFlexGroup
css={css`
flex-grow: 0;
margin-left: auto;
`}
>
<EuiSuperDatePicker
width="auto"
compressed={true}
isLoading={false}
start={timeRange.from}
end={timeRange.to}
onTimeChange={handleTimeChange}
onRefresh={handleRefresh}
isQuickSelectOnly={false}
showUpdateButton="iconOnly"
updateButtonProps={{ fill: false }}
/>
</EuiFlexGroup>
</EuiFlexGroup>
<EuiSkeletonRectangle width={160} height={32} isLoading={!dataView}>
<UnifiedBreakdownFieldSelector
dataView={dataView!}
breakdown={{ field: breakdownDataViewField }}
onBreakdownFieldChange={breakdown.onChange}
/>
</EuiSkeletonRectangle>
</EuiFlexGroup>
<EuiSpacer />
<EuiSpacer size="m" />
<DegradedDocsChart
dataStream={dataStream}
{...chartProps}
timeRange={timeRange}
lastReloadTime={lastReloadTime}
dataView={dataView}
breakdownDataViewField={breakdownDataViewField}
onTimeRangeChange={onTimeRangeChange}
/>
</EuiPanel>
);
@ -183,13 +111,3 @@ const degradedDocsTooltip = (
}}
/>
);
function getDataViewIndexPattern(dataStream: string | undefined) {
return dataStream ? `${indexNameToDataStreamParts(dataStream).type}-*-*` : DEFAULT_LOGS_DATA_VIEW;
}
function getDataViewField(dataView: DataView | undefined, fieldName: string | undefined) {
return fieldName && dataView
? dataView.fields.find((field) => field.name === fieldName)
: undefined;
}

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiLoadingChart } from '@elastic/eui';
import { EuiFlexGroup, EuiLoadingChart, OnTimeChangeProps } from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/common';
import { KibanaErrorBoundary } from '@kbn/shared-ux-error-boundary';
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { flyoutDegradedDocsTrendText } from '../../../../common/translations';
import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
@ -25,25 +24,35 @@ const DISABLED_ACTIONS = [
'create-ml-ad-job-action',
];
export function DegradedDocsChart({
dataStream,
timeRange,
lastReloadTime,
dataView,
breakdownDataViewField,
}: {
dataStream?: string;
interface DegradedDocsChartProps
extends Pick<
ReturnType<typeof useDegradedDocsChart>,
'attributes' | 'isChartLoading' | 'onChartLoading' | 'extraActions'
> {
timeRange: TimeRangeConfig;
lastReloadTime: number;
dataView?: DataView;
breakdownDataViewField?: DataViewField;
}) {
onTimeRangeChange: (props: Pick<OnTimeChangeProps, 'start' | 'end'>) => void;
}
export function DegradedDocsChart({
attributes,
isChartLoading,
onChartLoading,
extraActions,
timeRange,
lastReloadTime,
onTimeRangeChange,
}: DegradedDocsChartProps) {
const {
services: { lens },
} = useKibanaContextForPlugin();
const { attributes, filterQuery, extraActions, isChartLoading, handleChartLoading } =
useDegradedDocsChart({ dataStream, breakdownDataViewField });
const handleBrushEnd = useCallback(
({ range: [start, end] }: { range: number[] }) => {
onTimeRangeChange({ start: new Date(start).toISOString(), end: new Date(end).toISOString() });
},
[onTimeRangeChange]
);
return (
<>
@ -59,8 +68,11 @@ export function DegradedDocsChart({
) : (
<lens.EmbeddableComponent
id="datasetQualityFlyoutDegradedDocsTrend"
style={{ height: CHART_HEIGHT }}
css={lensEmbeddableComponentStyles}
style={{ height: CHART_HEIGHT }}
overrides={{
settings: { legendAction: 'ignore' },
}}
viewMode={ViewMode.VIEW}
hidePanelTitles={true}
disabledActions={DISABLED_ACTIONS}
@ -70,11 +82,8 @@ export function DegradedDocsChart({
extraActions={extraActions}
disableTriggers={false}
lastReloadRequestTime={lastReloadTime}
query={{
language: 'kuery',
query: filterQuery || '',
}}
onLoad={handleChartLoading}
onLoad={onChartLoading}
onBrushEnd={handleBrushEnd}
/>
)}
</EuiFlexGroup>

View file

@ -242,7 +242,7 @@ function getChartColumns(breakdownField?: string): Record<string, GenericIndexPa
},
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
missingBucket: true,
parentFormat: {
id: 'terms',
},

View file

@ -15,6 +15,7 @@ import {
EuiHorizontalRule,
EuiSkeletonTitle,
EuiSkeletonText,
EuiSkeletonRectangle,
} from '@elastic/eui';
export function FieldsList({
@ -23,7 +24,7 @@ export function FieldsList({
actionsMenu: ActionsMenu,
}: {
title: string;
fields: Array<{ fieldTitle: string; fieldValue: ReactNode }>;
fields: Array<{ fieldTitle: string; fieldValue: ReactNode; isLoading: boolean }>;
actionsMenu?: ReactNode;
}) {
return (
@ -36,7 +37,7 @@ export function FieldsList({
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup direction="column" gutterSize="none">
{fields.map(({ fieldTitle, fieldValue }, index) => (
{fields.map(({ fieldTitle, fieldValue, isLoading: isFieldLoading }, index) => (
<Fragment key={index + fieldTitle}>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
@ -44,9 +45,11 @@ export function FieldsList({
<span>{fieldTitle}</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={4} data-test-subj="datasetQualityFlyoutFieldValue">
{fieldValue}
</EuiFlexItem>
<EuiSkeletonRectangle width={260} isLoading={isFieldLoading} title={title}>
<EuiFlexItem grow={4} data-test-subj="datasetQualityFlyoutFieldValue">
{fieldValue}
</EuiFlexItem>
</EuiSkeletonRectangle>
</EuiFlexGroup>
{index < fields.length - 1 ? <EuiHorizontalRule margin="s" /> : null}

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import React, { Fragment } from 'react';
import { css } from '@emotion/react';
import {
EuiButtonEmpty,
EuiFlexGroup,
@ -13,26 +15,27 @@ import {
EuiFlyoutBody,
EuiFlyoutFooter,
EuiSpacer,
EuiHorizontalRule,
EuiPanel,
} from '@elastic/eui';
import React, { Fragment } from 'react';
import { flyoutCancelText } from '../../../common/translations';
import { useDatasetQualityFlyout } from '../../hooks';
import { DatasetSummary, DatasetSummaryLoading } from './dataset_summary';
import { Header } from './header';
import { IntegrationSummary } from './integration_summary';
import { FlyoutProps } from './types';
import { DegradedDocs } from './degraded_docs_trend/degraded_docs';
import { FlyoutSummary } from './flyout_summary/flyout_summary';
// Allow for lazy loading
// eslint-disable-next-line import/no-default-export
export default function Flyout({ dataset, closeFlyout }: FlyoutProps) {
const {
dataStreamStat,
dataStreamSettings,
dataStreamDetails,
dataStreamDetailsLoading,
fieldFormats,
timeRange,
breakdownField,
loadingState,
} = useDatasetQualityFlyout();
return (
@ -45,26 +48,44 @@ export default function Flyout({ dataset, closeFlyout }: FlyoutProps) {
>
<>
<Header dataStreamStat={dataset} />
<EuiFlyoutBody data-test-subj="datasetQualityFlyoutBody">
<DegradedDocs
dataStream={dataStreamStat?.rawName}
timeRange={timeRange}
breakdownField={breakdownField}
/>
<EuiFlyoutBody css={flyoutBodyStyles} data-test-subj="datasetQualityFlyoutBody">
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="l">
<FlyoutSummary
dataStream={dataset.rawName}
dataStreamStat={dataStreamStat}
dataStreamDetails={dataStreamDetails}
dataStreamDetailsLoading={loadingState.dataStreamDetailsLoading}
timeRange={timeRange}
/>
</EuiPanel>
<EuiSpacer />
<EuiHorizontalRule margin="none" />
{dataStreamDetailsLoading ? (
<DatasetSummaryLoading />
) : dataStreamStat ? (
<Fragment>
<DatasetSummary dataStreamDetails={dataStreamDetails} fieldFormats={fieldFormats} />
<EuiSpacer />
{dataStreamStat.integration && (
<IntegrationSummary integration={dataStreamStat.integration} />
)}
</Fragment>
) : null}
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="l">
{loadingState.dataStreamDetailsLoading && loadingState.dataStreamSettingsLoading ? (
<DatasetSummaryLoading />
) : dataStreamStat ? (
<Fragment>
<DatasetSummary
dataStreamSettings={dataStreamSettings}
dataStreamSettingsLoading={loadingState.dataStreamSettingsLoading}
dataStreamDetails={dataStreamDetails}
dataStreamDetailsLoading={loadingState.dataStreamDetailsLoading}
fieldFormats={fieldFormats}
/>
{dataStreamStat.integration && (
<>
<EuiSpacer />
<IntegrationSummary
integration={dataStreamStat.integration}
dashboardsLoading={loadingState.datasetIntegrationsLoading}
/>
</>
)}
</Fragment>
) : null}
</EuiPanel>
</EuiFlyoutBody>
<EuiFlyoutFooter>
@ -85,3 +106,9 @@ export default function Flyout({ dataset, closeFlyout }: FlyoutProps) {
</EuiFlyout>
);
}
const flyoutBodyStyles = css`
.euiFlyoutBody__overflowContent {
padding: 0;
}
`;

View file

@ -0,0 +1,104 @@
/*
* 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 React, { useCallback, useState } from 'react';
import { OnRefreshProps, OnTimeChangeProps, EuiSpacer } from '@elastic/eui';
import { DegradedDocs } from '../degraded_docs_trend/degraded_docs';
import { DataStreamDetails } from '../../../../common/api_types';
import { DEFAULT_TIME_RANGE, DEFAULT_DATEPICKER_REFRESH } from '../../../../common/constants';
import { useDatasetQualityContext } from '../../dataset_quality/context';
import { FlyoutDataset, TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
import { FlyoutSummaryHeader } from './flyout_summary_header';
import { FlyoutSummaryKpis, FlyoutSummaryKpisLoading } from './flyout_summary_kpis';
export function FlyoutSummary({
dataStream,
dataStreamStat,
dataStreamDetails,
dataStreamDetailsLoading,
timeRange = { ...DEFAULT_TIME_RANGE, refresh: DEFAULT_DATEPICKER_REFRESH },
}: {
dataStream: string;
dataStreamStat?: FlyoutDataset;
dataStreamDetails?: DataStreamDetails;
dataStreamDetailsLoading: boolean;
timeRange?: TimeRangeConfig;
}) {
const { service } = useDatasetQualityContext();
const [lastReloadTime, setLastReloadTime] = useState<number>(Date.now());
const updateTimeRange = useCallback(
({ start, end, refreshInterval }: OnRefreshProps) => {
service.send({
type: 'UPDATE_INSIGHTS_TIME_RANGE',
timeRange: {
from: start,
to: end,
refresh: { ...DEFAULT_DATEPICKER_REFRESH, value: refreshInterval },
},
});
},
[service]
);
const handleTimeChange = useCallback(
({ isInvalid, ...timeRangeProps }: OnTimeChangeProps) => {
if (!isInvalid) {
updateTimeRange({ refreshInterval: timeRange.refresh.value, ...timeRangeProps });
}
},
[updateTimeRange, timeRange.refresh]
);
const handleTimeRangeChange = useCallback(
({ start, end }: Pick<OnTimeChangeProps, 'start' | 'end'>) => {
updateTimeRange({ start, end, refreshInterval: timeRange.refresh.value });
},
[updateTimeRange, timeRange.refresh]
);
const handleRefresh = useCallback(
(refreshProps: OnRefreshProps) => {
updateTimeRange(refreshProps);
setLastReloadTime(Date.now());
},
[updateTimeRange]
);
return (
<>
<FlyoutSummaryHeader
timeRange={timeRange}
onTimeChange={handleTimeChange}
onRefresh={handleRefresh}
/>
<EuiSpacer size="m" />
{dataStreamStat ? (
<FlyoutSummaryKpis
dataStreamStat={dataStreamStat}
dataStreamDetails={dataStreamDetails}
isLoading={dataStreamDetailsLoading}
timeRange={timeRange}
/>
) : (
<FlyoutSummaryKpisLoading />
)}
<EuiSpacer />
<DegradedDocs
dataStream={dataStream}
timeRange={timeRange}
lastReloadTime={lastReloadTime}
onTimeRangeChange={handleTimeRangeChange}
/>
</>
);
}

View file

@ -0,0 +1,78 @@
/*
* 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 React from 'react';
import { css } from '@emotion/react';
import {
EuiFlexGroup,
EuiIcon,
EuiSuperDatePicker,
EuiTitle,
EuiToolTip,
OnRefreshProps,
OnTimeChangeProps,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { flyoutSummaryText } from '../../../../common/translations';
import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
export function FlyoutSummaryHeader({
timeRange,
onTimeChange,
onRefresh,
}: {
timeRange: TimeRangeConfig;
onTimeChange: (timeChangeProps: OnTimeChangeProps) => void;
onRefresh: (refreshProps: OnRefreshProps) => void;
}) {
return (
<EuiFlexGroup alignItems="center" wrap={true}>
<EuiFlexGroup
css={css`
flex-grow: 1;
`}
justifyContent="flexStart"
alignItems="center"
gutterSize="xs"
>
<EuiTitle size="s">
<span>{flyoutSummaryText}</span>
</EuiTitle>
<EuiToolTip content={flyoutSummaryTooltip}>
<EuiIcon size="m" color="subdued" type="questionInCircle" className="eui-alignTop" />
</EuiToolTip>
</EuiFlexGroup>
<EuiFlexGroup
css={css`
flex-grow: 0;
`}
>
<EuiSuperDatePicker
width="auto"
compressed={true}
isLoading={false}
start={timeRange.from}
end={timeRange.to}
onTimeChange={onTimeChange}
onRefresh={onRefresh}
isQuickSelectOnly={false}
showUpdateButton="iconOnly"
updateButtonProps={{ fill: false }}
/>
</EuiFlexGroup>
</EuiFlexGroup>
);
}
const flyoutSummaryTooltip = (
<FormattedMessage
id="xpack.datasetQuality.flyoutSummaryTooltip"
defaultMessage="Stats of the dataset within the selected time range."
/>
);

View file

@ -0,0 +1,100 @@
/*
* 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 React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiTitle,
EuiText,
EuiLink,
useEuiTheme,
EuiSkeletonTitle,
EuiSkeletonRectangle,
} from '@elastic/eui';
export function FlyoutSummaryKpiItem({
title,
value,
link,
isLoading,
}: {
title: string;
value: string;
link?: {
label: string;
href: string;
};
isLoading: boolean;
}) {
const { euiTheme } = useEuiTheme();
return (
<EuiPanel
data-test-subj={`datasetQualityFlyoutKpi-${title}${isLoading ? '--loading' : ''}`}
css={{ minWidth: 152, height: 130, display: 'flex', alignItems: 'stretch' }}
hasBorder
grow={false}
paddingSize="s"
>
<EuiFlexGroup alignItems="stretch" direction="column" wrap={false}>
<EuiFlexItem css={{ gap: euiTheme.size.xs }}>
<EuiTitle data-test-subj={`datasetQualityFlyoutKpiTitle-${title}`} size="xxxs">
<h6>{title}</h6>
</EuiTitle>
{link ? (
<EuiLink
data-test-subj={`datasetQualityFlyoutKpiLink-${title}`}
css={{
display: 'flex',
alignItems: 'center',
width: 'fit-content',
}}
href={link.href}
target="_blank"
>
<EuiText
css={{
fontWeight: euiTheme.font.weight.semiBold,
whiteSpace: 'nowrap',
}}
size="xs"
>
{link.label}
</EuiText>
</EuiLink>
) : null}
</EuiFlexItem>
<EuiFlexItem
css={{ alignItems: isLoading ? 'stretch' : 'flex-end', justifyContent: 'flex-end' }}
>
<EuiSkeletonTitle
style={{ width: '50%', marginLeft: 'auto' }}
size="m"
isLoading={isLoading}
>
<EuiTitle data-test-subj={`datasetQualityFlyoutKpiValue-${title}`} size="s">
<h3 className="eui-textNoWrap">{value}</h3>
</EuiTitle>
</EuiSkeletonTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
export function FlyoutSummaryKpiItemLoading({ title }: { title: string }) {
return (
<EuiSkeletonRectangle
data-test-subj={`datasetQualityFlyoutKpi-${title}--loading`}
css={{ minWidth: 152 }}
width={'100%'}
height={130}
/>
);
}

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 React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { _IGNORED } from '../../../../common/es_fields';
import { DataStreamDetails } from '../../../../common/api_types';
import { useKibanaContextForPlugin } from '../../../utils';
import { useLinkToLogsExplorer } from '../../../hooks';
import { FlyoutDataset, TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
import { FlyoutSummaryKpiItem, FlyoutSummaryKpiItemLoading } from './flyout_summary_kpi_item';
import { getSummaryKpis } from './get_summary_kpis';
export function FlyoutSummaryKpis({
dataStreamStat,
dataStreamDetails,
isLoading,
timeRange,
}: {
dataStreamStat: FlyoutDataset;
dataStreamDetails?: DataStreamDetails;
isLoading: boolean;
timeRange: TimeRangeConfig;
}) {
const {
services: { observabilityShared },
} = useKibanaContextForPlugin();
const hostsLocator = observabilityShared.locators.infra.hostsLocator;
const logsExplorerLinkProps = useLinkToLogsExplorer({
dataStreamStat,
query: { language: 'kuery', query: `${_IGNORED}: *` },
});
const kpis = useMemo(
() =>
getSummaryKpis({
dataStreamDetails,
timeRange,
degradedDocsHref: logsExplorerLinkProps.href,
hostsLocator,
}),
[dataStreamDetails, logsExplorerLinkProps, hostsLocator, timeRange]
);
return (
<EuiFlexGroup direction="column">
<EuiFlexGroup wrap={true} gutterSize="m">
{kpis.map((kpi) => (
<EuiFlexItem key={kpi.title}>
<FlyoutSummaryKpiItem {...kpi} isLoading={isLoading} />
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexGroup>
);
}
export function FlyoutSummaryKpisLoading() {
return (
<EuiFlexGroup direction="column">
<EuiFlexGroup wrap={true} gutterSize="m">
{getSummaryKpis({}).map(({ title }) => (
<EuiFlexItem key={title}>
<FlyoutSummaryKpiItemLoading title={title} />
</EuiFlexItem>
))}
</EuiFlexGroup>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,156 @@
/*
* 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 { formatNumber } from '@elastic/eui';
import type { useKibanaContextForPlugin } from '../../../utils';
import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
import {
BYTE_NUMBER_FORMAT,
DEFAULT_DATEPICKER_REFRESH,
DEFAULT_TIME_RANGE,
MAX_HOSTS_METRIC_VALUE,
} from '../../../../common/constants';
import {
flyoutDegradedDocsText,
flyoutDocsCountTotalText,
flyoutHostsText,
flyoutServicesText,
flyoutShowAllText,
flyoutSizeText,
} from '../../../../common/translations';
import { getSummaryKpis } from './get_summary_kpis';
const dataStreamDetails = {
services: {
service1: ['service1Instance1', 'service1Instance2'],
service2: ['service2Instance1'],
},
docsCount: 1000,
sizeBytes: 5000,
hosts: {
host1: ['host1Instance1', 'host1Instance2'],
host2: ['host2Instance1'],
},
degradedDocsCount: 200,
};
const timeRange: TimeRangeConfig = {
...DEFAULT_TIME_RANGE,
refresh: DEFAULT_DATEPICKER_REFRESH,
from: 'now-15m',
to: 'now',
};
const degradedDocsHref = 'http://exploratory-view/degraded-docs';
const hostsRedirectUrl = 'http://hosts/metric/';
const hostsLocator = {
getRedirectUrl: () => hostsRedirectUrl,
} as unknown as ReturnType<
typeof useKibanaContextForPlugin
>['services']['observabilityShared']['locators']['infra']['hostsLocator'];
describe('getSummaryKpis', () => {
it('should return the correct KPIs', () => {
const result = getSummaryKpis({
dataStreamDetails,
timeRange,
degradedDocsHref,
hostsLocator,
});
expect(result).toEqual([
{
title: flyoutDocsCountTotalText,
value: '1,000',
},
{
title: flyoutSizeText,
value: formatNumber(dataStreamDetails.sizeBytes ?? 0, BYTE_NUMBER_FORMAT),
},
{
title: flyoutServicesText,
value: '3',
link: undefined,
},
{
title: flyoutHostsText,
value: '3',
link: {
label: flyoutShowAllText,
href: hostsRedirectUrl,
},
},
{
title: flyoutDegradedDocsText,
value: '200',
link: {
label: flyoutShowAllText,
href: degradedDocsHref,
},
},
]);
});
it('show X+ if number of hosts or services exceed MAX_HOSTS_METRIC_VALUE', () => {
const services = {
service1: new Array(MAX_HOSTS_METRIC_VALUE + 1)
.fill('service1Instance')
.map((_, i) => `service1Instance${i}`),
};
const host3 = new Array(MAX_HOSTS_METRIC_VALUE + 1)
.fill('host3Instance')
.map((_, i) => `host3Instance${i}`);
const detailsWithMaxPlusHosts = {
...dataStreamDetails,
services,
hosts: { ...dataStreamDetails.hosts, host3 },
};
const result = getSummaryKpis({
dataStreamDetails: detailsWithMaxPlusHosts,
timeRange,
degradedDocsHref,
hostsLocator,
});
expect(result).toEqual([
{
title: flyoutDocsCountTotalText,
value: '1,000',
},
{
title: flyoutSizeText,
value: formatNumber(dataStreamDetails.sizeBytes ?? 0, BYTE_NUMBER_FORMAT),
},
{
title: flyoutServicesText,
value: '50+',
link: undefined,
},
{
title: flyoutHostsText,
value: '54+',
link: {
label: flyoutShowAllText,
href: hostsRedirectUrl,
},
},
{
title: flyoutDegradedDocsText,
value: '200',
link: {
label: flyoutShowAllText,
href: degradedDocsHref,
},
},
]);
});
});

View file

@ -0,0 +1,137 @@
/*
* 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 { formatNumber } from '@elastic/eui';
import {
BYTE_NUMBER_FORMAT,
DEFAULT_DATEPICKER_REFRESH,
DEFAULT_TIME_RANGE,
MAX_HOSTS_METRIC_VALUE,
NUMBER_FORMAT,
} from '../../../../common/constants';
import {
flyoutDegradedDocsText,
flyoutDocsCountTotalText,
flyoutHostsText,
flyoutServicesText,
flyoutShowAllText,
flyoutSizeText,
} from '../../../../common/translations';
import { DataStreamDetails } from '../../../../common/api_types';
import { useKibanaContextForPlugin } from '../../../utils';
import { TimeRangeConfig } from '../../../state_machines/dataset_quality_controller';
export function getSummaryKpis({
dataStreamDetails,
timeRange = { ...DEFAULT_TIME_RANGE, refresh: DEFAULT_DATEPICKER_REFRESH },
degradedDocsHref,
hostsLocator,
}: {
dataStreamDetails?: DataStreamDetails;
timeRange?: TimeRangeConfig;
degradedDocsHref?: string;
hostsLocator?: ReturnType<
typeof useKibanaContextForPlugin
>['services']['observabilityShared']['locators']['infra']['hostsLocator'];
}): Array<{ title: string; value: string; link?: { label: string; href: string } }> {
const services = dataStreamDetails?.services ?? {};
const serviceKeys = Object.keys(services);
const countOfServices = serviceKeys
.map((key: string) => services[key].length)
.reduce((a, b) => a + b, 0);
const servicesLink = undefined; // TODO: Add link to APM services page when possible
const degradedDocsLink = degradedDocsHref
? {
label: flyoutShowAllText,
href: degradedDocsHref,
}
: undefined;
return [
{
title: flyoutDocsCountTotalText,
value: formatNumber(dataStreamDetails?.docsCount ?? 0, NUMBER_FORMAT),
},
// dataStreamDetails.sizeBytes = null indicates it's Serverless where `_stats` API isn't available
...(dataStreamDetails?.sizeBytes !== null // Only show when not in Serverless
? [
{
title: flyoutSizeText,
value: formatNumber(dataStreamDetails?.sizeBytes ?? 0, BYTE_NUMBER_FORMAT),
},
]
: []),
{
title: flyoutServicesText,
value: formatMetricValueForMax(countOfServices, MAX_HOSTS_METRIC_VALUE, NUMBER_FORMAT),
link: servicesLink,
},
getHostsKpi(dataStreamDetails?.hosts, timeRange, hostsLocator),
{
title: flyoutDegradedDocsText,
value: formatNumber(dataStreamDetails?.degradedDocsCount ?? 0, NUMBER_FORMAT),
link: degradedDocsLink,
},
];
}
function getHostsKpi(
dataStreamHosts: DataStreamDetails['hosts'],
timeRange: TimeRangeConfig,
hostsLocator?: ReturnType<
typeof useKibanaContextForPlugin
>['services']['observabilityShared']['locators']['infra']['hostsLocator']
) {
const hosts = dataStreamHosts ?? {};
const hostKeys = Object.keys(hosts);
const countOfHosts = hostKeys
.map((key: string) => hosts[key].length)
.reduce(
({ count, anyHostExceedsMax }, hostCount) => ({
count: count + hostCount,
anyHostExceedsMax: anyHostExceedsMax || hostCount > MAX_HOSTS_METRIC_VALUE,
}),
{ count: 0, anyHostExceedsMax: false }
);
// Create a query so from hostKeys so that (key: value OR key: value2)
const hostsKuery = hostKeys
.filter((key) => hosts[key].length > 0)
.map((key) => hosts[key].map((value) => `${key}: "${value}"`).join(' OR '))
.join(' OR ');
const hostsUrl = hostsLocator?.getRedirectUrl({
query: { language: 'kuery', query: hostsKuery },
dateRange: { from: timeRange.from, to: timeRange.to },
limit: countOfHosts.count,
});
const hostsLink = hostsUrl
? {
label: flyoutShowAllText,
href: hostsUrl,
}
: undefined;
return {
title: flyoutHostsText,
value: formatMetricValueForMax(
countOfHosts.anyHostExceedsMax ? countOfHosts.count + 1 : countOfHosts.count,
countOfHosts.count,
NUMBER_FORMAT
),
link: hostsLink,
};
}
/**
* Formats a metric value to show a '+' sign if it's above a max value e.g. 50+
*/
function formatMetricValueForMax(value: number, max: number, numberFormat: string): string {
const exceedsMax = value > max;
const valueToShow = exceedsMax ? max : value;
return `${formatNumber(valueToShow, numberFormat)}${exceedsMax ? '+' : ''}`;
}

View file

@ -14,12 +14,18 @@ import {
EuiContextMenuPanelDescriptor,
EuiContextMenuPanelItemDescriptor,
EuiPopover,
EuiSkeletonRectangle,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { RouterLinkProps } from '@kbn/router-utils/src/get_router_link_props';
import { Integration } from '../../../common/data_streams_stats/integration';
import { useDatasetQualityFlyout } from '../../hooks';
import { useFlyoutIntegrationActions } from '../../hooks/use_flyout_integration_actions';
const integrationActionsText = i18n.translate('xpack.datasetQuality.flyoutIntegrationActionsText', {
defaultMessage: 'Integration actions',
});
const seeIntegrationText = i18n.translate('xpack.datasetQuality.flyoutSeeIntegrationActionText', {
defaultMessage: 'See integration',
});
@ -32,7 +38,13 @@ const viewDashboardsText = i18n.translate('xpack.datasetQuality.flyoutViewDashbo
defaultMessage: 'View dashboards',
});
export function IntegrationActionsMenu({ integration }: { integration: Integration }) {
export function IntegrationActionsMenu({
integration,
dashboardsLoading,
}: {
integration: Integration;
dashboardsLoading: boolean;
}) {
const { type, name } = useDatasetQualityFlyout().dataStreamStat!;
const { dashboards = [], version, name: integrationName } = integration;
const {
@ -46,6 +58,8 @@ export function IntegrationActionsMenu({ integration }: { integration: Integrati
const actionButton = (
<EuiButtonIcon
title={integrationActionsText}
aria-label={integrationActionsText}
iconType="boxesHorizontal"
onClick={handleToggleMenu}
data-test-subj="datasetQualityFlyoutIntegrationActionsButton"
@ -115,6 +129,13 @@ export function IntegrationActionsMenu({ integration }: { integration: Integrati
name: viewDashboardsText,
'data-test-subj': 'datasetQualityFlyoutIntegrationActionViewDashboards',
});
} else if (dashboardsLoading) {
firstLevelItems.push({
icon: 'dashboardApp',
name: <EuiSkeletonRectangle width={120} title={viewDashboardsText} />,
'data-test-subj': 'datasetQualityFlyoutIntegrationActionDashboardsLoading',
disabled: true,
});
}
const panel: EuiContextMenuPanelDescriptor[] = [
@ -150,6 +171,7 @@ export function IntegrationActionsMenu({ integration }: { integration: Integrati
name,
type,
version,
dashboardsLoading,
]);
return (

View file

@ -18,10 +18,18 @@ import { IntegrationIcon } from '../common';
import { FieldsList } from './fields_list';
import { IntegrationActionsMenu } from './integration_actions_menu';
export function IntegrationSummary({ integration }: { integration: Integration }) {
export function IntegrationSummary({
integration,
dashboardsLoading,
}: {
integration: Integration;
dashboardsLoading: boolean;
}) {
const { name, version } = integration;
const integrationActionsMenu = <IntegrationActionsMenu integration={integration} />;
const integrationActionsMenu = (
<IntegrationActionsMenu integration={integration} dashboardsLoading={dashboardsLoading} />
);
return (
<FieldsList
title={flyoutIntegrationDetailsText}
@ -42,10 +50,12 @@ export function IntegrationSummary({ integration }: { integration: Integration }
</EuiFlexGroup>
</EuiBadge>
),
isLoading: false,
},
{
fieldTitle: flyoutIntegrationVersionText,
fieldValue: version,
isLoading: false,
},
]}
/>

View file

@ -6,6 +6,7 @@
*/
import { useSelector } from '@xstate/react';
import { useMemo } from 'react';
import { useDatasetQualityContext } from '../components/dataset_quality/context';
import { useKibanaContextForPlugin } from '../utils';
@ -18,25 +19,39 @@ export const useDatasetQualityFlyout = () => {
const {
dataset: dataStreamStat,
datasetSettings: dataStreamSettings,
datasetDetails: dataStreamDetails,
insightsTimeRange,
breakdownField,
} = useSelector(service, (state) => state.context.flyout);
const { timeRange } = useSelector(service, (state) => state.context.filters);
const dataStreamDetailsLoading = useSelector(
service,
(state) =>
state.matches('datasets.loaded.flyoutOpen.fetching') ||
state.matches('flyout.initializing.dataStreamDetails.fetching')
const dataStreamDetailsLoading = useSelector(service, (state) =>
state.matches('flyout.initializing.dataStreamDetails.fetching')
);
const dataStreamSettingsLoading = useSelector(service, (state) =>
state.matches('flyout.initializing.dataStreamSettings.fetching')
);
const datasetIntegrationsLoading = useSelector(service, (state) =>
state.matches('flyout.initializing.integrationDashboards.fetching')
);
const loadingState = useMemo(() => {
return {
dataStreamDetailsLoading,
dataStreamSettingsLoading,
datasetIntegrationsLoading,
};
}, [dataStreamDetailsLoading, dataStreamSettingsLoading, datasetIntegrationsLoading]);
return {
dataStreamStat,
dataStreamSettings,
dataStreamDetails,
dataStreamDetailsLoading,
fieldFormats,
timeRange: insightsTimeRange ?? timeRange,
breakdownField,
loadingState,
};
};

View file

@ -61,10 +61,6 @@ export const useDatasetQualityTable = () => {
const datasets = useSelector(service, (state) => state.context.datasets);
const isDatasetQualityPageIdle = useSelector(service, (state) =>
state.matches('datasets.loaded.idle')
);
const toggleInactiveDatasets = useCallback(
() => service.send({ type: 'TOGGLE_INACTIVE_DATASETS' }),
[service]
@ -86,7 +82,7 @@ export const useDatasetQualityTable = () => {
return;
}
if (isDatasetQualityPageIdle) {
if (!flyout?.insightsTimeRange) {
service.send({
type: 'OPEN_FLYOUT',
dataset: selectedDataset,
@ -99,7 +95,7 @@ export const useDatasetQualityTable = () => {
dataset: selectedDataset,
});
},
[flyout?.dataset?.rawName, isDatasetQualityPageIdle, service]
[flyout?.dataset?.rawName, flyout?.insightsTimeRange, service]
);
const isActive = useCallback(

View file

@ -4,12 +4,15 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useState, useMemo, useEffect } from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { fieldSupportsBreakdown } from '@kbn/unified-histogram-plugin/public';
import { i18n } from '@kbn/i18n';
import { useEuiTheme } from '@elastic/eui';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { type DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { useDatasetQualityContext } from '../components/dataset_quality/context';
import { DEFAULT_LOGS_DATA_VIEW } from '../../common/constants';
import { indexNameToDataStreamParts } from '../../common/utils';
import { getLensAttributes } from '../components/flyout/degraded_docs_trend/lens_attributes';
@ -34,37 +37,47 @@ const ACTION_OPEN_IN_LENS = 'ACTION_OPEN_IN_LENS';
interface DegradedDocsChartDeps {
dataStream?: string;
breakdownDataViewField?: DataViewField;
breakdownField?: string;
}
export const useDegradedDocsChart = ({
dataStream,
breakdownDataViewField,
}: DegradedDocsChartDeps) => {
export const useDegradedDocsChart = ({ dataStream }: DegradedDocsChartDeps) => {
const { euiTheme } = useEuiTheme();
const {
services: { lens },
} = useKibanaContextForPlugin();
const { euiTheme } = useEuiTheme();
const { service } = useDatasetQualityContext();
const { dataStreamStat, timeRange } = useDatasetQualityFlyout();
const { dataStreamStat, timeRange, breakdownField } = useDatasetQualityFlyout();
const [isChartLoading, setIsChartLoading] = useState<boolean | undefined>(undefined);
const [attributes, setAttributes] = useState<ReturnType<typeof getLensAttributes> | undefined>(
undefined
);
const datasetTypeIndexPattern = dataStream
? `${indexNameToDataStreamParts(dataStream).type}-*-*`
: undefined;
const { dataView } = useCreateDataView({
indexPatternString: datasetTypeIndexPattern ?? DEFAULT_LOGS_DATA_VIEW,
indexPatternString: getDataViewIndexPattern(dataStream),
});
const breakdownDataViewField = useMemo(
() => getDataViewField(dataView, breakdownField),
[breakdownField, dataView]
);
const filterQuery = `_index: ${dataStream ?? 'match-none'}`;
const handleChartLoading = (isLoading: boolean) => {
setIsChartLoading(isLoading);
};
const handleBreakdownFieldChange = useCallback(
(field: DataViewField | undefined) => {
service.send({
type: 'BREAKDOWN_FIELD_CHANGE',
breakdownField: field?.name ?? null,
});
},
[service]
);
useEffect(() => {
if (dataView) {
const lensAttributes = getLensAttributes({
@ -111,6 +124,7 @@ export const useDegradedDocsChart = ({
dataStreamStat: dataStreamStat!,
query: { language: 'kuery', query: '_ignored:*' },
timeRangeConfig: timeRange,
breakdownField: breakdownDataViewField?.name,
});
const getOpenInLogsExplorerAction = useMemo(() => {
@ -141,11 +155,27 @@ export const useDegradedDocsChart = ({
return {
attributes,
dataView,
filterQuery,
breakdown: {
dataViewField: breakdownDataViewField,
fieldSupportsBreakdown: breakdownDataViewField
? fieldSupportsBreakdown(breakdownDataViewField)
: true,
onChange: handleBreakdownFieldChange,
},
extraActions,
isChartLoading,
handleChartLoading,
onChartLoading: handleChartLoading,
setAttributes,
setIsChartLoading,
};
};
function getDataViewIndexPattern(dataStream: string | undefined) {
return dataStream ? `${indexNameToDataStreamParts(dataStream).type}-*-*` : DEFAULT_LOGS_DATA_VIEW;
}
function getDataViewField(dataView: DataView | undefined, fieldName: string | undefined) {
return fieldName && dataView
? dataView.fields.find((field) => field.name === fieldName)
: undefined;
}

View file

@ -21,10 +21,12 @@ export const useLinkToLogsExplorer = ({
dataStreamStat,
query,
timeRangeConfig,
breakdownField,
}: {
dataStreamStat: DataStreamStat | FlyoutDataset;
query?: Query | AggregateQuery;
timeRangeConfig?: TimeRangeConfig;
breakdownField?: string;
}) => {
const {
services: { share },
@ -48,6 +50,7 @@ export const useLinkToLogsExplorer = ({
values: [dataStreamStat.namespace],
},
},
breakdownField,
};
const singleDatasetLocator =

View file

@ -8,26 +8,50 @@
import { HttpStart } from '@kbn/core/public';
import { decodeOrThrow } from '@kbn/io-ts-utils';
import {
getDataStreamsSettingsResponseRt,
getDataStreamsDetailsResponseRt,
integrationDashboardsRT,
} from '../../../common/api_types';
import {
GetDataStreamsStatsError,
GetDataStreamSettingsParams,
GetDataStreamSettingsResponse,
GetDataStreamDetailsParams,
GetDataStreamDetailsResponse,
GetIntegrationDashboardsParams,
GetIntegrationDashboardsResponse,
} from '../../../common/data_streams_stats';
import { DataStreamDetails } from '../../../common/data_streams_stats';
import { DataStreamDetails, DataStreamSettings } from '../../../common/data_streams_stats';
import { IDataStreamDetailsClient } from './types';
export class DataStreamDetailsClient implements IDataStreamDetailsClient {
constructor(private readonly http: HttpStart) {}
public async getDataStreamDetails({ dataStream }: GetDataStreamDetailsParams) {
public async getDataStreamSettings({ dataStream }: GetDataStreamSettingsParams) {
const response = await this.http
.get<GetDataStreamSettingsResponse>(
`/internal/dataset_quality/data_streams/${dataStream}/settings`
)
.catch((error) => {
throw new GetDataStreamsStatsError(`Failed to fetch data stream settings": ${error}`);
});
const dataStreamSettings = decodeOrThrow(
getDataStreamsSettingsResponseRt,
(message: string) =>
new GetDataStreamsStatsError(`Failed to decode data stream settings response: ${message}"`)
)(response);
return dataStreamSettings as DataStreamSettings;
}
public async getDataStreamDetails({ dataStream, start, end }: GetDataStreamDetailsParams) {
const response = await this.http
.get<GetDataStreamDetailsResponse>(
`/internal/dataset_quality/data_streams/${dataStream}/details`
`/internal/dataset_quality/data_streams/${dataStream}/details`,
{
query: { start, end },
}
)
.catch((error) => {
throw new GetDataStreamsStatsError(`Failed to fetch data stream details": ${error}`);

View file

@ -7,6 +7,8 @@
import { HttpStart } from '@kbn/core/public';
import {
GetDataStreamSettingsParams,
DataStreamSettings,
GetDataStreamDetailsParams,
DataStreamDetails,
GetIntegrationDashboardsParams,
@ -24,6 +26,7 @@ export interface DataStreamDetailsServiceStartDeps {
}
export interface IDataStreamDetailsClient {
getDataStreamSettings(params: GetDataStreamSettingsParams): Promise<DataStreamSettings>;
getDataStreamDetails(params: GetDataStreamDetailsParams): Promise<DataStreamDetails>;
getIntegrationDashboards(
params: GetIntegrationDashboardsParams

View file

@ -26,6 +26,15 @@ export const fetchDatasetDetailsFailedNotifier = (toasts: IToasts, error: Error)
});
};
export const fetchDatasetSettingsFailedNotifier = (toasts: IToasts, error: Error) => {
toasts.addDanger({
title: i18n.translate('xpack.datasetQuality.fetchDatasetSettingsFailed', {
defaultMessage: "Dataset settings couldn't be loaded.",
}),
text: error.message,
});
};
export const fetchDegradedStatsFailedNotifier = (toasts: IToasts, error: Error) => {
toasts.addDanger({
title: i18n.translate('xpack.datasetQuality.fetchDegradedStatsFailed', {

View file

@ -12,6 +12,7 @@ import { Integration } from '../../../../common/data_streams_stats/integration';
import { IDataStreamDetailsClient } from '../../../services/data_stream_details';
import {
DashboardType,
DataStreamSettings,
DataStreamDetails,
DataStreamStat,
GetDataStreamsStatsQuery,
@ -24,6 +25,7 @@ import { IDataStreamsStatsClient } from '../../../services/data_streams_stats';
import { generateDatasets } from '../../../utils';
import { DEFAULT_CONTEXT } from './defaults';
import {
fetchDatasetSettingsFailedNotifier,
fetchDatasetDetailsFailedNotifier,
fetchDatasetStatsFailedNotifier,
fetchDegradedStatsFailedNotifier,
@ -173,6 +175,27 @@ export const createPureDatasetQualityControllerStateMachine = (
initializing: {
type: 'parallel',
states: {
dataStreamSettings: {
initial: 'fetching',
states: {
fetching: {
invoke: {
src: 'loadDataStreamSettings',
onDone: {
target: 'done',
actions: ['storeDataStreamSettings'],
},
onError: {
target: 'done',
actions: ['notifyFetchDatasetSettingsFailed'],
},
},
},
done: {
type: 'final',
},
},
},
dataStreamDetails: {
initial: 'fetching',
states: {
@ -185,12 +208,20 @@ export const createPureDatasetQualityControllerStateMachine = (
},
onError: {
target: 'done',
actions: ['fetchDatasetDetailsFailedNotifier'],
actions: ['notifyFetchDatasetDetailsFailed'],
},
},
},
done: {
type: 'final',
on: {
UPDATE_INSIGHTS_TIME_RANGE: {
target: 'fetching',
actions: ['storeFlyoutOptions'],
},
BREAKDOWN_FIELD_CHANGE: {
actions: ['storeFlyoutOptions'],
},
},
},
},
},
@ -226,12 +257,6 @@ export const createPureDatasetQualityControllerStateMachine = (
target: 'closed',
actions: ['resetFlyoutOptions'],
},
UPDATE_INSIGHTS_TIME_RANGE: {
actions: ['storeFlyoutOptions'],
},
BREAKDOWN_FIELD_CHANGE: {
actions: ['storeFlyoutOptions'],
},
},
},
closed: {
@ -328,28 +353,25 @@ export const createPureDatasetQualityControllerStateMachine = (
: {};
}),
storeFlyoutOptions: assign((context, event) => {
return 'dataset' in event
? {
flyout: {
...context.flyout,
dataset: event.dataset as FlyoutDataset,
},
}
: 'timeRange' in event
? {
flyout: {
...context.flyout,
insightsTimeRange: event.timeRange,
},
}
: 'breakdownField' in event
? {
flyout: {
...context.flyout,
breakdownField: event.breakdownField ?? undefined,
},
}
: {};
const insightsTimeRange =
'timeRange' in event
? event.timeRange
: context.flyout?.insightsTimeRange ?? context.filters?.timeRange;
const dataset =
'dataset' in event ? (event.dataset as FlyoutDataset) : context.flyout?.dataset;
const breakdownField =
'breakdownField' in event
? event.breakdownField ?? undefined
: context.flyout?.breakdownField;
return {
flyout: {
...context.flyout,
dataset,
insightsTimeRange,
breakdownField,
},
};
}),
resetFlyoutOptions: assign((_context, _event) => ({ flyout: undefined })),
storeDataStreamStats: assign((_context, event) => {
@ -366,6 +388,16 @@ export const createPureDatasetQualityControllerStateMachine = (
}
: {};
}),
storeDataStreamSettings: assign((context, event) => {
return 'data' in event
? {
flyout: {
...context.flyout,
datasetSettings: (event.data ?? {}) as DataStreamSettings,
},
}
: {};
}),
storeDatasetDetails: assign((context, event) => {
return 'data' in event
? {
@ -438,6 +470,8 @@ export const createDatasetQualityControllerStateMachine = ({
fetchDatasetStatsFailedNotifier(toasts, event.data),
notifyFetchDegradedStatsFailed: (_context, event: DoneInvokeEvent<Error>) =>
fetchDegradedStatsFailedNotifier(toasts, event.data),
notifyFetchDatasetSettingsFailed: (_context, event: DoneInvokeEvent<Error>) =>
fetchDatasetSettingsFailedNotifier(toasts, event.data),
notifyFetchDatasetDetailsFailed: (_context, event: DoneInvokeEvent<Error>) =>
fetchDatasetDetailsFailedNotifier(toasts, event.data),
notifyFetchIntegrationDashboardsFailed: (_context, event: DoneInvokeEvent<Error>) =>
@ -466,14 +500,34 @@ export const createDatasetQualityControllerStateMachine = ({
type: context.type as GetIntegrationsParams['query']['type'],
});
},
loadDataStreamDetails: (context) => {
loadDataStreamSettings: (context) => {
if (!context.flyout.dataset) {
fetchDatasetSettingsFailedNotifier(toasts, new Error(noDatasetSelected));
return Promise.resolve({});
}
const { type, name: dataset, namespace } = context.flyout.dataset;
return dataStreamDetailsClient.getDataStreamSettings({
dataStream: dataStreamPartsToIndexName({
type: type as DataStreamType,
dataset,
namespace,
}),
});
},
loadDataStreamDetails: (context) => {
if (!context.flyout.dataset || !context.flyout.insightsTimeRange) {
fetchDatasetDetailsFailedNotifier(toasts, new Error(noDatasetSelected));
return Promise.resolve({});
}
const { type, name: dataset, namespace } = context.flyout.dataset;
const { startDate: start, endDate: end } = getDateISORange(
context.flyout.insightsTimeRange
);
return dataStreamDetailsClient.getDataStreamDetails({
dataStream: dataStreamPartsToIndexName({
@ -481,6 +535,8 @@ export const createDatasetQualityControllerStateMachine = ({
dataset,
namespace,
}),
start,
end,
});
},
loadIntegrationDashboards: (context) => {

View file

@ -13,11 +13,12 @@ import { DegradedDocsStat } from '../../../../common/data_streams_stats/malforme
import {
DashboardType,
DataStreamDegradedDocsStatServiceResponse,
DataStreamSettings,
DataStreamDetails,
DataStreamStatServiceResponse,
IntegrationsResponse,
DataStreamStat,
} from '../../../../common/data_streams_stats';
import { DataStreamStat } from '../../../../common/data_streams_stats/data_stream_stat';
export type FlyoutDataset = Omit<
DataStreamStat,
@ -53,6 +54,7 @@ export interface WithTableOptions {
export interface WithFlyoutOptions {
flyout: {
dataset?: FlyoutDataset;
datasetSettings?: DataStreamSettings;
datasetDetails?: DataStreamDetails;
insightsTimeRange?: TimeRangeConfig;
breakdownField?: string;
@ -103,14 +105,6 @@ export type DatasetQualityControllerTypeState =
value: 'datasets.loaded.idle';
context: DefaultDatasetQualityStateContext;
}
| {
value: 'datasets.loaded.flyoutOpen.fetching';
context: DefaultDatasetQualityStateContext;
}
| {
value: 'datasets.loaded.flyoutOpen';
context: DefaultDatasetQualityStateContext;
}
| {
value: 'degradedDocs.fetching';
context: DefaultDatasetQualityStateContext;
@ -123,6 +117,10 @@ export type DatasetQualityControllerTypeState =
value: 'integrations.fetching';
context: DefaultDatasetQualityStateContext;
}
| {
value: 'flyout.initializing.dataStreamSettings.fetching';
context: DefaultDatasetQualityStateContext;
}
| {
value: 'flyout.initializing.dataStreamDetails.fetching';
context: DefaultDatasetQualityStateContext;
@ -185,6 +183,8 @@ export type DatasetQualityControllerEvent =
}
| DoneInvokeEvent<DataStreamDegradedDocsStatServiceResponse>
| DoneInvokeEvent<DashboardType>
| DoneInvokeEvent<DataStreamDetails>
| DoneInvokeEvent<DataStreamSettings>
| DoneInvokeEvent<DataStreamStatServiceResponse>
| DoneInvokeEvent<IntegrationsResponse>
| DoneInvokeEvent<Error>;

View file

@ -12,8 +12,10 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { DatasetQualityProps } from './components/dataset_quality';
import type { ObservabilitySharedPluginSetup } from '@kbn/observability-shared-plugin/public';
import type { CreateDatasetQualityController } from './controller';
import type { DatasetQualityProps } from './components/dataset_quality';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface DatasetQualityPluginSetup {}
@ -30,6 +32,7 @@ export interface DatasetQualityStartDeps {
unifiedSearch: UnifiedSearchPublicPluginStart;
lens: LensPublicStart;
dataViews: DataViewsPublicPluginStart;
observabilityShared: ObservabilitySharedPluginSetup;
}
export interface DatasetQualitySetupDeps {

View file

@ -5,204 +5,176 @@
* 2.0.
*/
import { SearchTotalHitsRelation } from '@elastic/elasticsearch/lib/api/types';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import {
findInventoryFields,
InventoryItemType,
inventoryModels,
} from '@kbn/metrics-data-access-plugin/common';
import { getDataStreamDetails } from '.';
const accessLogsDataStream = 'logs-nginx.access-default';
const errorLogsDataStream = 'logs-nginx.error-default';
const dateStr1 = '1702998651925'; // .ds-logs-nginx.access-default-2023.12.19-000001
const dateStr2 = '1703110671019'; // .ds-logs-nginx.access-default-2023.12.20-000002
const dateStr3 = '1702998866744'; // .ds-logs-nginx.error-default-2023.12.19-000001
const defaultSummaryStats = {
degradedDocsCount: 98841,
docsCount: 617680,
hosts: {
'aws.rds.db_instance.arn': [],
'aws.s3.bucket.name': [],
'aws.sqs.queue.name': [],
'cloud.instance.id': ['0000000000009121', '0000000000009127', '0000000000009133'],
'container.id': [],
'host.name': ['synth-host'],
'kubernetes.pod.uid': [],
},
services: {
'service.name': ['synth-service-0', 'synth-service-1', 'synth-service-2'],
},
sizeBytes: 72596354,
};
const start = Number(new Date('2020-01-01T00:00:00.000Z'));
const end = Number(new Date('2020-01-30T00:00:00.000Z'));
describe('getDataStreamDetails', () => {
afterAll(() => {
jest.clearAllMocks();
});
it('throws error if index is not found', async () => {
it('returns {} if index is not found', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.getSettings.mockRejectedValue(MOCK_INDEX_ERROR);
esClientMock.search.mockRejectedValue(MOCK_INDEX_ERROR);
try {
await getDataStreamDetails({
esClient: esClientMock,
dataStream: 'non-existent',
});
} catch (e) {
expect(e).toBe(MOCK_INDEX_ERROR);
}
const dataStreamDetails = await getDataStreamDetails({
esClient: esClientMock,
dataStream: 'non-existent',
start,
end,
});
expect(dataStreamDetails).toEqual({});
});
it('returns creation date of a data stream', async () => {
it('returns summary of a data stream', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.getSettings.mockReturnValue(
Promise.resolve(MOCK_NGINX_ERROR_INDEX_SETTINGS)
);
esClientMock.indices.stats.mockReturnValue(Promise.resolve(MOCK_STATS_RESPONSE));
esClientMock.search.mockReturnValue(Promise.resolve(MOCK_SEARCH_RESPONSE));
const dataStreamDetails = await getDataStreamDetails({
esClient: esClientMock,
dataStream: errorLogsDataStream,
start,
end,
});
expect(dataStreamDetails).toEqual({ createdOn: Number(dateStr3) });
expect(dataStreamDetails).toEqual(defaultSummaryStats);
});
it('returns the earliest creation date of a data stream with multiple backing indices', async () => {
it('returns the correct service.name list', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.getSettings.mockReturnValue(
Promise.resolve(MOCK_NGINX_ACCESS_INDEX_SETTINGS)
);
esClientMock.indices.stats.mockReturnValue(Promise.resolve(MOCK_STATS_RESPONSE));
const serviceName = 'service.name';
const testServiceName = ['tst-srv-0', 'tst-srv-1'];
const mockSearchResponse = { ...MOCK_SEARCH_RESPONSE };
mockSearchResponse.aggregations[serviceName].buckets = testServiceName.map((name) => ({
key: name,
doc_count: 1,
}));
esClientMock.search.mockReturnValue(Promise.resolve(MOCK_SEARCH_RESPONSE));
const dataStreamDetails = await getDataStreamDetails({
esClient: esClientMock,
dataStream: accessLogsDataStream,
start,
end,
});
expect(dataStreamDetails).toEqual({ createdOn: Number(dateStr1) });
expect(dataStreamDetails.services).toEqual({ [serviceName]: testServiceName });
});
it('returns the correct host.name list', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.stats.mockReturnValue(Promise.resolve(MOCK_STATS_RESPONSE));
const hostName = 'host.name';
const testHostName = ['tst-host-0', 'tst-host-1'];
const hostFields = inventoryModels.map(
(model) => findInventoryFields(model.id as InventoryItemType).id
);
const mockSearchResponse = { ...MOCK_SEARCH_RESPONSE };
// Make all hosts buckets to []
hostFields.forEach((field) => {
mockSearchResponse.aggregations[field as 'host.name'] = { buckets: [] } as any;
});
// Set the host.name buckets to testHostName
mockSearchResponse.aggregations[hostName].buckets = testHostName.map((name) => ({
key: name,
doc_count: 1,
}));
esClientMock.search.mockReturnValue(Promise.resolve(MOCK_SEARCH_RESPONSE));
const dataStreamDetails = await getDataStreamDetails({
esClient: esClientMock,
dataStream: accessLogsDataStream,
start,
end,
});
// Expect all host fields to be empty
const emptyHosts = hostFields.reduce((acc, field) => ({ ...acc, [field]: [] }), {});
expect(dataStreamDetails.hosts).toEqual({ ...emptyHosts, [hostName]: testHostName });
});
it('returns correct size in bytes', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
const docsCount = 536;
const storeDocsCount = 1220;
const storeSizeInBytes = 2048;
const expectedSizeInBytes = Math.ceil((storeSizeInBytes / storeDocsCount) * docsCount);
const testStatsResponse = { ...MOCK_STATS_RESPONSE };
testStatsResponse._all.total.docs.count = storeDocsCount;
testStatsResponse._all.total.store.size_in_bytes = storeSizeInBytes;
esClientMock.indices.stats.mockReturnValue(Promise.resolve(testStatsResponse));
const mockSearchResponse = { ...MOCK_SEARCH_RESPONSE };
mockSearchResponse.aggregations.total_count.value = docsCount;
esClientMock.search.mockReturnValue(Promise.resolve(mockSearchResponse));
const dataStreamDetails = await getDataStreamDetails({
esClient: esClientMock,
dataStream: accessLogsDataStream,
start,
end,
});
expect(dataStreamDetails.sizeBytes).toEqual(expectedSizeInBytes);
});
// This covers https://github.com/elastic/kibana/issues/178954
it('returns size as NaN for when sizeStatsAvailable is false (serverless mode)', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.stats.mockReturnValue(Promise.resolve(MOCK_STATS_RESPONSE));
esClientMock.search.mockReturnValue(Promise.resolve(MOCK_SEARCH_RESPONSE));
const dataStreamDetails = await getDataStreamDetails({
esClient: esClientMock,
dataStream: accessLogsDataStream,
start,
end,
sizeStatsAvailable: false,
});
expect(dataStreamDetails.sizeBytes).toBeNaN();
});
});
const MOCK_NGINX_ACCESS_INDEX_SETTINGS = {
[`.ds-${accessLogsDataStream}-2023.12.19-000001`]: {
settings: {
index: {
mapping: {
total_fields: {
limit: 10000,
},
ignore_malformed: true,
},
hidden: true,
provided_name: '.ds-logs-nginx.access-default-2023.12.19-000001',
final_pipeline: '.fleet_final_pipeline-1',
query: {
default_field: [
'cloud.account.id',
'cloud.availability_zone',
'cloud.instance.id',
'cloud.instance.name',
'cloud.machine.type',
'cloud.provider',
'cloud.region',
],
},
creation_date: dateStr1,
number_of_replicas: '1',
uuid: 'uml9fMQqQUibZi2pKkc5sQ',
version: {
created: '8500007',
},
lifecycle: {
name: 'logs',
indexing_complete: true,
},
codec: 'best_compression',
routing: {
allocation: {
include: {
_tier_preference: 'data_hot',
},
},
},
number_of_shards: '1',
default_pipeline: 'logs-nginx.access-1.17.0',
},
},
},
[`.ds-${accessLogsDataStream}-2023.12.20-000002`]: {
settings: {
index: {
mapping: {
total_fields: {
limit: 10000,
},
ignore_malformed: true,
},
hidden: true,
provided_name: '.ds-logs-nginx.access-default-2023.12.20-000002',
final_pipeline: '.fleet_final_pipeline-1',
query: {
default_field: [
'user.name',
'user_agent.device.name',
'user_agent.name',
'user_agent.original',
'user_agent.os.full',
'user_agent.os.name',
'user_agent.os.version',
'user_agent.version',
'nginx.access.remote_ip_list',
],
},
creation_date: dateStr2,
number_of_replicas: '1',
uuid: 'il9vJlOXRdiv44wU6WNtUQ',
version: {
created: '8500007',
},
lifecycle: {
name: 'logs',
},
codec: 'best_compression',
routing: {
allocation: {
include: {
_tier_preference: 'data_hot',
},
},
},
number_of_shards: '1',
default_pipeline: 'logs-nginx.access-1.17.0',
},
},
},
};
const MOCK_NGINX_ERROR_INDEX_SETTINGS = {
[`.ds-${errorLogsDataStream}-2023.12.19-000001`]: {
settings: {
index: {
mapping: {
total_fields: {
limit: 10000,
},
ignore_malformed: true,
},
hidden: true,
provided_name: '.ds-logs-nginx.error-default-2023.12.19-000001',
final_pipeline: '.fleet_final_pipeline-1',
query: {
default_field: [
'host.type',
'input.type',
'log.file.path',
'log.level',
'ecs.version',
'message',
'tags',
],
},
creation_date: dateStr3,
number_of_replicas: '1',
uuid: 'fGPYUppSRU62MZ3toF0MkQ',
version: {
created: '8500007',
},
lifecycle: {
name: 'logs',
},
codec: 'best_compression',
routing: {
allocation: {
include: {
_tier_preference: 'data_hot',
},
},
},
number_of_shards: '1',
default_pipeline: 'logs-nginx.error-1.17.0',
},
},
},
};
const MOCK_INDEX_ERROR = {
error: {
root_cause: [
@ -222,5 +194,142 @@ const MOCK_INDEX_ERROR = {
index_uuid: '_na_',
index: 'logs-nginx.error-default-01',
},
status: 404,
statusCode: 404,
};
const MOCK_SEARCH_RESPONSE = {
took: 2,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
total: {
value: 10000,
relation: 'gte' as SearchTotalHitsRelation,
},
max_score: null,
hits: [],
},
aggregations: {
total_count: {
value: 617680,
},
degraded_count: {
doc_count: 98841,
},
'service.name': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'synth-service-0',
doc_count: 206116,
},
{
key: 'synth-service-1',
doc_count: 206012,
},
{
key: 'synth-service-2',
doc_count: 205552,
},
],
},
'host.name': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'synth-host',
doc_count: 617680,
},
],
},
'kubernetes.pod.uid': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
'container.id': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
'cloud.instance.id': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 614630,
buckets: [
{
key: '0000000000009121',
doc_count: 61,
},
{
key: '0000000000009127',
doc_count: 61,
},
{
key: '0000000000009133',
doc_count: 61,
},
],
},
'aws.s3.bucket.name': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
'aws.rds.db_instance.arn': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
'aws.sqs.queue.name': {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
},
};
const MOCK_STATS_RESPONSE = {
_shards: {
total: 2,
successful: 2,
failed: 0,
},
_all: {
primaries: {},
total: {
docs: {
count: 1235360,
deleted: 0,
},
shard_stats: {
total_count: 2,
},
store: {
size_in_bytes: 145192707,
total_data_set_size_in_bytes: 145192707,
reserved_in_bytes: 0,
},
indexing: {
index_total: 1235059,
index_time_in_millis: 98509,
index_current: 0,
index_failed: 0,
delete_total: 0,
delete_time_in_millis: 0,
delete_current: 0,
noop_update_total: 0,
is_throttled: false,
throttle_time_in_millis: 0,
write_load: 0.00022633763414114222,
},
},
},
indices: {},
};

View file

@ -0,0 +1,227 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { getDataStreamSettings } from '.';
const accessLogsDataStream = 'logs-nginx.access-default';
const errorLogsDataStream = 'logs-nginx.error-default';
const dateStr1 = '1702998651925'; // .ds-logs-nginx.access-default-2023.12.19-000001
const dateStr2 = '1703110671019'; // .ds-logs-nginx.access-default-2023.12.20-000002
const dateStr3 = '1702998866744'; // .ds-logs-nginx.error-default-2023.12.19-000001
describe('getDataStreamSettings', () => {
afterAll(() => {
jest.clearAllMocks();
});
it('throws error if index is not found', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.getSettings.mockRejectedValue(MOCK_INDEX_ERROR);
try {
await getDataStreamSettings({
esClient: esClientMock,
dataStream: 'non-existent',
});
} catch (e) {
expect(e).toBe(MOCK_INDEX_ERROR);
}
});
it('returns creation date of a data stream', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.getSettings.mockReturnValue(
Promise.resolve(MOCK_NGINX_ERROR_INDEX_SETTINGS)
);
const dataStreamSettings = await getDataStreamSettings({
esClient: esClientMock,
dataStream: errorLogsDataStream,
});
expect(dataStreamSettings).toEqual({ createdOn: Number(dateStr3) });
});
it('returns the earliest creation date of a data stream with multiple backing indices', async () => {
const esClientMock = elasticsearchServiceMock.createElasticsearchClient();
esClientMock.indices.getSettings.mockReturnValue(
Promise.resolve(MOCK_NGINX_ACCESS_INDEX_SETTINGS)
);
const dataStreamSettings = await getDataStreamSettings({
esClient: esClientMock,
dataStream: accessLogsDataStream,
});
expect(dataStreamSettings).toEqual({ createdOn: Number(dateStr1) });
});
});
const MOCK_NGINX_ACCESS_INDEX_SETTINGS = {
[`.ds-${accessLogsDataStream}-2023.12.19-000001`]: {
settings: {
index: {
mapping: {
total_fields: {
limit: 10000,
},
ignore_malformed: true,
},
hidden: true,
provided_name: '.ds-logs-nginx.access-default-2023.12.19-000001',
final_pipeline: '.fleet_final_pipeline-1',
query: {
default_field: [
'cloud.account.id',
'cloud.availability_zone',
'cloud.instance.id',
'cloud.instance.name',
'cloud.machine.type',
'cloud.provider',
'cloud.region',
],
},
creation_date: dateStr1,
number_of_replicas: '1',
uuid: 'uml9fMQqQUibZi2pKkc5sQ',
version: {
created: '8500007',
},
lifecycle: {
name: 'logs',
indexing_complete: true,
},
codec: 'best_compression',
routing: {
allocation: {
include: {
_tier_preference: 'data_hot',
},
},
},
number_of_shards: '1',
default_pipeline: 'logs-nginx.access-1.17.0',
},
},
},
[`.ds-${accessLogsDataStream}-2023.12.20-000002`]: {
settings: {
index: {
mapping: {
total_fields: {
limit: 10000,
},
ignore_malformed: true,
},
hidden: true,
provided_name: '.ds-logs-nginx.access-default-2023.12.20-000002',
final_pipeline: '.fleet_final_pipeline-1',
query: {
default_field: [
'user.name',
'user_agent.device.name',
'user_agent.name',
'user_agent.original',
'user_agent.os.full',
'user_agent.os.name',
'user_agent.os.version',
'user_agent.version',
'nginx.access.remote_ip_list',
],
},
creation_date: dateStr2,
number_of_replicas: '1',
uuid: 'il9vJlOXRdiv44wU6WNtUQ',
version: {
created: '8500007',
},
lifecycle: {
name: 'logs',
},
codec: 'best_compression',
routing: {
allocation: {
include: {
_tier_preference: 'data_hot',
},
},
},
number_of_shards: '1',
default_pipeline: 'logs-nginx.access-1.17.0',
},
},
},
};
const MOCK_NGINX_ERROR_INDEX_SETTINGS = {
[`.ds-${errorLogsDataStream}-2023.12.19-000001`]: {
settings: {
index: {
mapping: {
total_fields: {
limit: 10000,
},
ignore_malformed: true,
},
hidden: true,
provided_name: '.ds-logs-nginx.error-default-2023.12.19-000001',
final_pipeline: '.fleet_final_pipeline-1',
query: {
default_field: [
'host.type',
'input.type',
'log.file.path',
'log.level',
'ecs.version',
'message',
'tags',
],
},
creation_date: dateStr3,
number_of_replicas: '1',
uuid: 'fGPYUppSRU62MZ3toF0MkQ',
version: {
created: '8500007',
},
lifecycle: {
name: 'logs',
},
codec: 'best_compression',
routing: {
allocation: {
include: {
_tier_preference: 'data_hot',
},
},
},
number_of_shards: '1',
default_pipeline: 'logs-nginx.error-1.17.0',
},
},
},
};
const MOCK_INDEX_ERROR = {
error: {
root_cause: [
{
type: 'index_not_found_exception',
reason: 'no such index [logs-nginx.error-default-01]',
'resource.type': 'index_or_alias',
'resource.id': 'logs-nginx.error-default-01',
index_uuid: '_na_',
index: 'logs-nginx.error-default-01',
},
],
type: 'index_not_found_exception',
reason: 'no such index [logs-nginx.error-default-01]',
'resource.type': 'index_or_alias',
'resource.id': 'logs-nginx.error-default-01',
index_uuid: '_na_',
index: 'logs-nginx.error-default-01',
},
status: 404,
};

View file

@ -7,28 +7,163 @@
import { badRequest } from '@hapi/boom';
import type { ElasticsearchClient } from '@kbn/core/server';
import { DataStreamDetails } from '../../../../common/api_types';
import {
findInventoryFields,
InventoryItemType,
inventoryModels,
} from '@kbn/metrics-data-access-plugin/common';
import { rangeQuery } from '@kbn/observability-plugin/server';
import { MAX_HOSTS_METRIC_VALUE } from '../../../../common/constants';
import { _IGNORED } from '../../../../common/es_fields';
import { DataStreamDetails, DataStreamSettings } from '../../../../common/api_types';
import { createDatasetQualityESClient } from '../../../utils';
import { dataStreamService } from '../../../services';
export async function getDataStreamDetails(args: {
export async function getDataStreamSettings({
esClient,
dataStream,
}: {
esClient: ElasticsearchClient;
dataStream: string;
}): Promise<DataStreamSettings> {
throwIfInvalidDataStreamParams(dataStream);
const createdOn = await getDataStreamCreatedOn(esClient, dataStream);
return {
createdOn,
};
}
export async function getDataStreamDetails({
esClient,
dataStream,
start,
end,
sizeStatsAvailable = true,
}: {
esClient: ElasticsearchClient;
dataStream: string;
start: number;
end: number;
sizeStatsAvailable?: boolean; // Only Needed to determine whether `_stats` endpoint is available https://github.com/elastic/kibana/issues/178954
}): Promise<DataStreamDetails> {
const { esClient, dataStream } = args;
throwIfInvalidDataStreamParams(dataStream);
if (!dataStream?.trim()) {
throw badRequest(`Data Stream name cannot be empty. Received value "${dataStream}"`);
try {
const dataStreamSummaryStats = await getDataStreamSummaryStats(
esClient,
dataStream,
start,
end
);
const whenSizeStatsNotAvailable = NaN; // This will indicate size cannot be calculated
const avgDocSizeInBytes = sizeStatsAvailable
? dataStreamSummaryStats.docsCount > 0
? await getAvgDocSizeInBytes(esClient, dataStream)
: 0
: whenSizeStatsNotAvailable;
const sizeBytes = Math.ceil(avgDocSizeInBytes * dataStreamSummaryStats.docsCount);
return {
...dataStreamSummaryStats,
sizeBytes,
};
} catch (e) {
// Respond with empty object if data stream does not exist
if (e.statusCode === 404) {
return {};
}
throw e;
}
}
async function getDataStreamCreatedOn(esClient: ElasticsearchClient, dataStream: string) {
const indexSettings = await dataStreamService.getDataSteamIndexSettings(esClient, dataStream);
const indexesList = Object.values(indexSettings);
const indexCreationDate = indexesList
return indexesList
.map((index) => Number(index.settings?.index?.creation_date))
.sort((a, b) => a - b)[0];
}
type TermAggregation = Record<string, { terms: { field: string; size: number } }>;
const MAX_HOSTS = MAX_HOSTS_METRIC_VALUE + 1; // Adding 1 so that we can show e.g. '50+'
// Gather service.name terms
const serviceNamesAgg: TermAggregation = {
['service.name']: { terms: { field: 'service.name', size: MAX_HOSTS } },
};
// Gather host terms like 'host', 'pod', 'container'
const hostsAgg: TermAggregation = inventoryModels
.map((model) => findInventoryFields(model.id as InventoryItemType))
.reduce(
(acc, fields) => ({ ...acc, [fields.id]: { terms: { field: fields.id, size: MAX_HOSTS } } }),
{} as TermAggregation
);
async function getDataStreamSummaryStats(
esClient: ElasticsearchClient,
dataStream: string,
start: number,
end: number
): Promise<{
docsCount: number;
degradedDocsCount: number;
services: Record<string, string[]>;
hosts: Record<string, string[]>;
}> {
const datasetQualityESClient = createDatasetQualityESClient(esClient);
const response = await datasetQualityESClient.search({
index: dataStream,
query: rangeQuery(start, end)[0],
size: 0,
aggs: {
total_count: {
value_count: { field: '_index' },
},
degraded_count: {
filter: { exists: { field: _IGNORED } },
},
...serviceNamesAgg,
...hostsAgg,
},
});
const docsCount = Number(response.aggregations?.total_count.value ?? 0);
const degradedDocsCount = Number(response.aggregations?.degraded_count.doc_count ?? 0);
return {
createdOn: indexCreationDate,
docsCount,
degradedDocsCount,
services: getTermsFromAgg(serviceNamesAgg, response.aggregations),
hosts: getTermsFromAgg(hostsAgg, response.aggregations),
};
}
async function getAvgDocSizeInBytes(esClient: ElasticsearchClient, index: string) {
const indexStats = await esClient.indices.stats({ index });
const docCount = indexStats._all.total?.docs?.count ?? 0;
const sizeInBytes = indexStats._all.total?.store?.size_in_bytes ?? 0;
return docCount ? sizeInBytes / docCount : 0;
}
function getTermsFromAgg(termAgg: TermAggregation, aggregations: any) {
return Object.entries(termAgg).reduce((acc, [key, _value]) => {
const values = aggregations[key]?.buckets.map((bucket: any) => bucket.key) as string[];
return { ...acc, [key]: values };
}, {});
}
function throwIfInvalidDataStreamParams(dataStream?: string) {
if (!dataStream?.trim()) {
throw badRequest(`Data Stream name cannot be empty. Received value "${dataStream}"`);
}
}

View file

@ -7,16 +7,17 @@
import * as t from 'io-ts';
import { keyBy, merge, values } from 'lodash';
import { DataStreamType } from '../../../common/types';
import {
DataStreamDetails,
DataStreamsEstimatedDataInBytes,
DataStreamSettings,
DataStreamStat,
DegradedDocs,
} from '../../../common/api_types';
import { indexNameToDataStreamParts } from '../../../common/utils';
import { rangeRt, typeRt } from '../../types/default_api_types';
import { createDatasetQualityServerRoute } from '../create_datasets_quality_server_route';
import { getDataStreamDetails } from './get_data_stream_details';
import { getDataStreamDetails, getDataStreamSettings } from './get_data_stream_details';
import { getDataStreams } from './get_data_streams';
import { getDataStreamsStats } from './get_data_streams_stats';
import { getDegradedDocsPaginated } from './get_degraded_docs';
@ -94,8 +95,8 @@ const degradedDocsRoute = createDatasetQualityServerRoute({
},
});
const dataStreamDetailsRoute = createDatasetQualityServerRoute({
endpoint: 'GET /internal/dataset_quality/data_streams/{dataStream}/details',
const dataStreamSettingsRoute = createDatasetQualityServerRoute({
endpoint: 'GET /internal/dataset_quality/data_streams/{dataStream}/settings',
params: t.type({
path: t.type({
dataStream: t.string,
@ -104,7 +105,7 @@ const dataStreamDetailsRoute = createDatasetQualityServerRoute({
options: {
tags: [],
},
async handler(resources): Promise<DataStreamDetails> {
async handler(resources): Promise<DataStreamSettings> {
const { context, params } = resources;
const { dataStream } = params.path;
const coreContext = await context.core;
@ -112,19 +113,53 @@ const dataStreamDetailsRoute = createDatasetQualityServerRoute({
// Query datastreams as the current user as the Kibana internal user may not have all the required permissions
const esClient = coreContext.elasticsearch.client.asCurrentUser;
const [type, ...datasetQuery] = dataStream.split('-');
const dataStreamSettings = await getDataStreamSettings({
esClient,
dataStream,
});
return dataStreamSettings;
},
});
const dataStreamDetailsRoute = createDatasetQualityServerRoute({
endpoint: 'GET /internal/dataset_quality/data_streams/{dataStream}/details',
params: t.type({
path: t.type({
dataStream: t.string,
}),
query: rangeRt,
}),
options: {
tags: [],
},
async handler(resources): Promise<DataStreamDetails> {
const { context, params, getEsCapabilities } = resources;
const { dataStream } = params.path;
const { start, end } = params.query;
const coreContext = await context.core;
// Query datastreams as the current user as the Kibana internal user may not have all the required permissions
const esClient = coreContext.elasticsearch.client.asCurrentUser;
const { type, dataset, namespace } = indexNameToDataStreamParts(dataStream);
const sizeStatsAvailable = !(await getEsCapabilities()).serverless;
const [dataStreamsStats, dataStreamDetails] = await Promise.all([
getDataStreamsStats({
esClient,
type: type as DataStreamType,
datasetQuery: datasetQuery.join('-'),
type,
datasetQuery: `${dataset}-${namespace}`,
}),
getDataStreamDetails({ esClient, dataStream }),
getDataStreamDetails({ esClient, dataStream, start, end, sizeStatsAvailable }),
]);
return {
createdOn: dataStreamDetails?.createdOn,
docsCount: dataStreamDetails?.docsCount,
degradedDocsCount: dataStreamDetails?.degradedDocsCount,
services: dataStreamDetails?.services,
hosts: dataStreamDetails?.hosts,
sizeBytes: dataStreamDetails?.sizeBytes,
lastActivity: dataStreamsStats.items?.[0]?.lastActivity,
};
},
@ -166,5 +201,6 @@ export const dataStreamsRouteRepository = {
...statsRoute,
...degradedDocsRoute,
...dataStreamDetailsRoute,
...dataStreamSettingsRoute,
...estimatedDataInBytesRoute,
};

View file

@ -26,7 +26,6 @@
"@kbn/shared-ux-utility",
"@kbn/ui-theme",
"@kbn/core-notifications-browser",
"@kbn/formatters",
"@kbn/data-service",
"@kbn/observability-shared-plugin",
"@kbn/data-plugin",
@ -42,7 +41,8 @@
"@kbn/deeplinks-management",
"@kbn/deeplinks-analytics",
"@kbn/core-elasticsearch-server",
"@kbn/ui-actions-plugin"
"@kbn/ui-actions-plugin",
"@kbn/metrics-data-access-plugin"
],
"exclude": ["target/**/*"]
}

View file

@ -10,18 +10,19 @@ import expect from '@kbn/expect';
import { DatasetQualityApiClientKey } from '../../common/config';
import { DatasetQualityApiError } from '../../common/dataset_quality_api_supertest';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { expectToReject, getDataStreamSettingsOfFirstIndex } from '../../utils';
import { expectToReject } from '../../utils';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const synthtrace = getService('logSynthtraceEsClient');
const esClient = getService('es');
const datasetQualityApiClient = getService('datasetQualityApiClient');
const start = '2023-12-11T18:00:00.000Z';
const end = '2023-12-11T18:01:00.000Z';
const type = 'logs';
const dataset = 'nginx.access';
const namespace = 'default';
const serviceName = 'my-service';
const hostName = 'synth-host';
async function callApiAs(user: DatasetQualityApiClientKey, dataStream: string) {
return await datasetQualityApiClient[user]({
@ -30,6 +31,10 @@ export default function ApiTest({ getService }: FtrProviderContext) {
path: {
dataStream,
},
query: {
start,
end,
},
},
});
}
@ -50,6 +55,8 @@ export default function ApiTest({ getService }: FtrProviderContext) {
.namespace(namespace)
.defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
})
),
]);
@ -71,13 +78,16 @@ export default function ApiTest({ getService }: FtrProviderContext) {
expect(resp.body).empty();
});
it('returns data stream details correctly', async () => {
const dataStreamSettings = await getDataStreamSettingsOfFirstIndex(
esClient,
`logs-${dataset}-${namespace}`
);
it('returns "sizeBytes" correctly', async () => {
const resp = await callApiAs('datasetQualityLogsUser', `${type}-${dataset}-${namespace}`);
expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date));
expect(isNaN(resp.body.sizeBytes as number)).to.be(false);
expect(resp.body.sizeBytes).to.be.greaterThan(0);
});
it('returns service.name and host.name correctly', async () => {
const resp = await callApiAs('datasetQualityLogsUser', `${type}-${dataset}-${namespace}`);
expect(resp.body.services).to.eql({ ['service.name']: [serviceName] });
expect(resp.body.hosts?.['host.name']).to.eql([hostName]);
});
after(async () => {

View file

@ -0,0 +1,106 @@
/*
* 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 { log, timerange } from '@kbn/apm-synthtrace-client';
import expect from '@kbn/expect';
import { DatasetQualityApiClientKey } from '../../common/config';
import { DatasetQualityApiError } from '../../common/dataset_quality_api_supertest';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
expectToReject,
getDataStreamSettingsOfEarliestIndex,
rolloverDataStream,
} from '../../utils';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const synthtrace = getService('logSynthtraceEsClient');
const esClient = getService('es');
const datasetQualityApiClient = getService('datasetQualityApiClient');
const start = '2023-12-11T18:00:00.000Z';
const end = '2023-12-11T18:01:00.000Z';
const type = 'logs';
const dataset = 'nginx.access';
const namespace = 'default';
const serviceName = 'my-service';
const hostName = 'synth-host';
async function callApiAs(user: DatasetQualityApiClientKey, dataStream: string) {
return await datasetQualityApiClient[user]({
endpoint: 'GET /internal/dataset_quality/data_streams/{dataStream}/settings',
params: {
path: {
dataStream,
},
},
});
}
registry.when('DataStream Settings', { config: 'basic' }, () => {
describe('gets the data stream settings', () => {
before(async () => {
await synthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is a log message')
.timestamp(timestamp)
.dataset(dataset)
.namespace(namespace)
.defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
})
),
]);
});
it('returns error when dataStream param is not provided', async () => {
const expectedMessage = 'Data Stream name cannot be empty';
const err = await expectToReject<DatasetQualityApiError>(() =>
callApiAs('datasetQualityLogsUser', encodeURIComponent(' '))
);
expect(err.res.status).to.be(400);
expect(err.res.body.message.indexOf(expectedMessage)).to.greaterThan(-1);
});
it('returns {} if matching data stream is not available', async () => {
const nonExistentDataSet = 'Non-existent';
const nonExistentDataStream = `${type}-${nonExistentDataSet}-${namespace}`;
const resp = await callApiAs('datasetQualityLogsUser', nonExistentDataStream);
expect(resp.body).empty();
});
it('returns "createdOn" correctly', async () => {
const dataStreamSettings = await getDataStreamSettingsOfEarliestIndex(
esClient,
`${type}-${dataset}-${namespace}`
);
const resp = await callApiAs('datasetQualityLogsUser', `${type}-${dataset}-${namespace}`);
expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date));
});
it('returns "createdOn" correctly for rolled over dataStream', async () => {
await rolloverDataStream(esClient, `${type}-${dataset}-${namespace}`);
const dataStreamSettings = await getDataStreamSettingsOfEarliestIndex(
esClient,
`${type}-${dataset}-${namespace}`
);
const resp = await callApiAs('datasetQualityLogsUser', `${type}-${dataset}-${namespace}`);
expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date));
});
after(async () => {
await synthtrace.clean();
});
});
});
}

View file

@ -7,7 +7,20 @@
import { Client } from '@elastic/elasticsearch';
export async function getDataStreamSettingsOfFirstIndex(es: Client, name: string) {
const matchingIndexesObj = await es.indices.getSettings({ index: name });
return Object.values(matchingIndexesObj ?? {})[0]?.settings;
export async function rolloverDataStream(es: Client, name: string) {
return es.indices.rollover({ alias: name });
}
export async function getDataStreamSettingsOfEarliestIndex(es: Client, name: string) {
const matchingIndexesObj = await es.indices.getSettings({ index: name });
const matchingIndexes = Object.keys(matchingIndexesObj ?? {});
matchingIndexes.sort((a, b) => {
return (
Number(matchingIndexesObj[a].settings?.index?.creation_date) -
Number(matchingIndexesObj[b].settings?.index?.creation_date)
);
});
return matchingIndexesObj[matchingIndexes[0]].settings;
}

View file

@ -28,12 +28,14 @@ export function getLogsForDataset({
count = 1,
isMalformed = false,
namespace = defaultNamespace,
services,
}: {
dataset: string;
to: moment.MomentInput;
count?: number;
isMalformed?: boolean;
namespace?: string;
services?: string[];
}) {
return timerange(moment(to).subtract(count, 'minute'), moment(to))
.interval('1m')
@ -46,7 +48,9 @@ export function getLogsForDataset({
timestamp,
dataset,
MESSAGE_LOG_LEVELS[index % MESSAGE_LOG_LEVELS.length],
SERVICE_NAMES[index % SERVICE_NAMES.length],
services?.[index] ??
services?.[index % services.length] ??
SERVICE_NAMES[index % SERVICE_NAMES.length],
CLUSTER[index % CLUSTER.length],
CLOUD_PROVIDERS[index % CLOUD_PROVIDERS.length],
CLOUD_REGION[index % CLOUD_REGION.length],
@ -108,7 +112,7 @@ export function createLogRecord(
cloudProvider: string,
cloudRegion: string,
isMalformed = false,
namespace = defaultNamespace
namespace: string = defaultNamespace
): ReturnType<typeof log.create> {
return log
.create()

View file

@ -144,6 +144,139 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid
expect(datasetSelectorText).to.eql(testDatasetName);
});
it('shows summary KPIs', async () => {
await PageObjects.datasetQuality.navigateTo();
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const summary = await PageObjects.datasetQuality.parseFlyoutKpis();
expect(summary).to.eql({
docsCountTotal: '0',
size: '0.0 B',
services: '0',
hosts: '0',
degradedDocs: '0',
});
});
it('shows the updated KPIs', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const summaryBefore = await PageObjects.datasetQuality.parseFlyoutKpis();
// Set time range to 3 days ago
const flyoutBodyContainer = await testSubjects.find(
PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutBody
);
await PageObjects.datasetQuality.setDatePickerLastXUnits(flyoutBodyContainer, 3, 'd');
// Index 2 doc 2 days ago
const time2DaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000;
await synthtrace.index(
getLogsForDataset({
to: time2DaysAgo,
count: 2,
dataset: apacheAccessDatasetName,
isMalformed: false,
})
);
// Index 5 degraded docs 2 days ago
await synthtrace.index(
getLogsForDataset({
to: time2DaysAgo,
count: 5,
dataset: apacheAccessDatasetName,
isMalformed: true,
})
);
await PageObjects.datasetQuality.refreshFlyout();
const summaryAfter = await PageObjects.datasetQuality.parseFlyoutKpis();
expect(parseInt(summaryAfter.docsCountTotal, 10)).to.be.greaterThan(
parseInt(summaryBefore.docsCountTotal, 10)
);
expect(parseInt(summaryAfter.degradedDocs, 10)).to.be.greaterThan(
parseInt(summaryBefore.degradedDocs, 10)
);
expect(parseInt(summaryAfter.size, 10)).to.be.greaterThan(parseInt(summaryBefore.size, 10));
expect(parseInt(summaryAfter.services, 10)).to.be.greaterThan(
parseInt(summaryBefore.services, 10)
);
expect(parseInt(summaryAfter.hosts, 10)).to.be.greaterThan(parseInt(summaryBefore.hosts, 10));
});
it('shows the right number of services', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const summaryBefore = await PageObjects.datasetQuality.parseFlyoutKpis();
const testServices = ['test-srv-1', 'test-srv-2'];
// Index 2 docs with different services
const timeNow = Date.now();
await synthtrace.index(
getLogsForDataset({
to: timeNow,
count: 2,
dataset: apacheAccessDatasetName,
isMalformed: false,
services: testServices,
})
);
await PageObjects.datasetQuality.refreshFlyout();
const summaryAfter = await PageObjects.datasetQuality.parseFlyoutKpis();
expect(parseInt(summaryAfter.services, 10)).to.eql(
parseInt(summaryBefore.services, 10) + testServices.length
);
});
it('goes to log explorer for degraded docs when show all is clicked', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const degradedDocsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.degradedDocs}`;
await testSubjects.click(degradedDocsShowAllSelector);
await browser.switchTab(1);
// Confirm dataset selector text in observability logs explorer
const datasetSelectorText =
await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText();
expect(datasetSelectorText).to.contain(apacheAccessDatasetName);
await browser.closeCurrentWindow();
await browser.switchTab(0);
});
it('goes to infra hosts for hosts when show all is clicked', async () => {
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const hostsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.hosts}`;
await testSubjects.click(hostsShowAllSelector);
await browser.switchTab(1);
// Confirm url contains metrics/hosts
await retry.tryForTime(5000, async () => {
const currentUrl = await browser.getCurrentUrl();
const parsedUrl = new URL(currentUrl);
expect(parsedUrl.pathname).to.contain('/app/metrics/hosts');
});
await browser.closeCurrentWindow();
await browser.switchTab(0);
});
it('Integration actions menu is present with correct actions', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';

View file

@ -38,7 +38,7 @@ export default function ({ getService, getPageObjects }: DatasetQualityFtrProvid
datasetHealthDegraded: '0',
datasetHealthGood: '3',
activeDatasets: '0 of 3',
estimatedData: '0 Bytes',
estimatedData: '0.0 B',
});
});

View file

@ -34,6 +34,8 @@ type SummaryPanelKpi = Record<
string
>;
type FlyoutKpi = Record<'docsCountTotal' | 'size' | 'services' | 'hosts' | 'degradedDocs', string>;
export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProviderContext) {
const PageObjects = getPageObjects(['common']);
const testSubjects = getService('testSubjects');
@ -69,6 +71,8 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
datasetQualityNamespacesSelectable: 'datasetQualityNamespacesSelectable',
datasetQualityNamespacesSelectableButton: 'datasetQualityNamespacesSelectableButton',
datasetQualityDatasetHealthKpi: 'datasetQualityDatasetHealthKpi',
datasetQualityFlyoutKpiValue: 'datasetQualityFlyoutKpiValue',
datasetQualityFlyoutKpiLink: 'datasetQualityFlyoutKpiLink',
superDatePickerToggleQuickMenuButton: 'superDatePickerToggleQuickMenuButton',
superDatePickerApplyTimeButton: 'superDatePickerApplyTimeButton',
@ -112,7 +116,7 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
},
async waitUntilTableLoaded() {
await find.waitForDeletedByCssSelector('.euiBasicTable-loading');
await find.waitForDeletedByCssSelector('.euiBasicTable-loading', 20 * 1000);
},
async waitUntilSummaryPanelLoaded() {
@ -235,6 +239,16 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
return testSubjects.click(testSubjectSelectors.euiFlyoutCloseButton);
},
async refreshFlyout() {
const flyoutContainer: WebElementWrapper = await testSubjects.find(
testSubjectSelectors.datasetQualityFlyoutBody
);
const refreshButton = await flyoutContainer.findByTestSubject(
testSubjectSelectors.superDatePickerApplyTimeButton
);
return refreshButton.click();
},
async getFlyoutElementsByText(selector: string, text: string) {
const flyoutContainer: WebElementWrapper = await testSubjects.find(
testSubjectSelectors.datasetQualityFlyout
@ -270,12 +284,46 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
return elements.length > 0;
},
// `excludeKeys` needed to circumvent `_stats` not available in Serverless https://github.com/elastic/kibana/issues/178954
// TODO: Remove `excludeKeys` when `_stats` is available in Serverless
async parseFlyoutKpis(excludeKeys: string[] = []): Promise<FlyoutKpi> {
const kpiTitleAndKeys = [
{ title: texts.docsCountTotal, key: 'docsCountTotal' },
{ title: texts.size, key: 'size' },
{ title: texts.services, key: 'services' },
{ title: texts.hosts, key: 'hosts' },
{ title: texts.degradedDocs, key: 'degradedDocs' },
].filter((item) => !excludeKeys.includes(item.key));
const kpiTexts = await Promise.all(
kpiTitleAndKeys.map(async ({ title, key }) => ({
key,
value: await testSubjects.getVisibleText(
`${testSubjectSelectors.datasetQualityFlyoutKpiValue}-${title}`
),
}))
);
return kpiTexts.reduce(
(acc, { key, value }) => ({
...acc,
[key]: value,
}),
{} as FlyoutKpi
);
},
async setDatePickerLastXUnits(
container: WebElementWrapper,
timeValue: number,
unit: TimeUnitId
) {
await testSubjects.click(testSubjectSelectors.superDatePickerToggleQuickMenuButton);
// Only click the menu button found under the provided container
const datePickerToggleQuickMenuButton = await container.findByTestSubject(
testSubjectSelectors.superDatePickerToggleQuickMenuButton
);
await datePickerToggleQuickMenuButton.click();
const datePickerQuickMenu = await testSubjects.find(
testSubjectSelectors.superDatePickerQuickMenu
);
@ -300,7 +348,9 @@ export function DatasetQualityPageObject({ getPageObjects, getService }: FtrProv
await timeUnitSelect.focus();
await timeUnitSelect.type(unit);
(await datePickerQuickMenu.findByCssSelector(selectors.superDatePickerApplyButton)).click();
await (
await datePickerQuickMenu.findByCssSelector(selectors.superDatePickerApplyButton)
).click();
return testSubjects.missingOrFail(testSubjectSelectors.superDatePickerQuickMenu);
},
@ -433,4 +483,9 @@ const texts = {
datasetHealthGood: 'Good',
activeDatasets: 'Active Datasets',
estimatedData: 'Estimated Data',
docsCountTotal: 'Docs count (total)',
size: 'Size',
services: 'Services',
hosts: 'Hosts',
degradedDocs: 'Degraded docs',
};

View file

@ -11,7 +11,7 @@ import type { JobParamsCSV } from '@kbn/reporting-export-types-csv-common';
import type { Filter } from '@kbn/es-query';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getService }: FtrProviderContext) => {
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const reportingAPI = getService('svlReportingApi');
@ -738,4 +738,4 @@ export default ({ getService }: FtrProviderContext) => {
});
});
});
};
}

View file

@ -6,7 +6,8 @@
*/
import { createTestConfig } from '../../config.base';
import { services } from './apm_api_integration/common/services';
import { services as apmServices } from './apm_api_integration/common/services';
import { services as datasetQualityServices } from './dataset_quality_api_integration/common/services';
export default createTestConfig({
serverlessProject: 'oblt',
@ -15,7 +16,7 @@ export default createTestConfig({
reportName: 'Serverless Observability API Integration Tests',
},
suiteTags: { exclude: ['skipSvlOblt'] },
services,
services: { ...apmServices, ...datasetQualityServices },
// include settings from project controller
// https://github.com/elastic/project-controller/blob/main/internal/project/observability/config/elasticsearch.yml

View file

@ -0,0 +1,129 @@
/*
* 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 { format } from 'url';
import supertest from 'supertest';
import request from 'superagent';
import type { APIClientRequestParamsOf, APIReturnType } from '@kbn/dataset-quality-plugin/common';
import { Config, kbnTestConfig, kibanaTestSuperuserServerless } from '@kbn/test';
import type { APIEndpoint } from '@kbn/dataset-quality-plugin/server/routes';
import { formatRequest } from '@kbn/server-route-repository';
import { InheritedFtrProviderContext } from '../../../../services';
export function createDatasetQualityApiClient(st: supertest.SuperTest<supertest.Test>) {
return async <TEndpoint extends APIEndpoint>(
options: {
type?: 'form-data';
endpoint: TEndpoint;
} & APIClientRequestParamsOf<TEndpoint> & { params?: { query?: { _inspect?: boolean } } }
): Promise<SupertestReturnType<TEndpoint>> => {
const { endpoint, type } = options;
const params = 'params' in options ? (options.params as Record<string, any>) : {};
const { method, pathname, version } = formatRequest(endpoint, params.path);
const url = format({ pathname, query: params?.query });
const headers: Record<string, string> = {
'kbn-xsrf': 'foo',
'x-elastic-internal-origin': 'foo',
};
if (version) {
headers['Elastic-Api-Version'] = version;
}
let res: request.Response;
if (type === 'form-data') {
const fields: Array<[string, any]> = Object.entries(params.body);
const formDataRequest = st[method](url)
.set(headers)
.set('Content-type', 'multipart/form-data');
for (const field of fields) {
formDataRequest.field(field[0], field[1]);
}
res = await formDataRequest;
} else if (params.body) {
res = await st[method](url).send(params.body).set(headers);
} else {
res = await st[method](url).set(headers);
}
// supertest doesn't throw on http errors
if (res?.status !== 200) {
throw new DatasetQualityApiError(res, endpoint);
}
return res;
};
}
type ApiErrorResponse = Omit<request.Response, 'body'> & {
body: {
statusCode: number;
error: string;
message: string;
attributes: object;
};
};
export type DatasetQualityApiSupertest = ReturnType<typeof createDatasetQualityApiClient>;
export class DatasetQualityApiError extends Error {
res: ApiErrorResponse;
constructor(res: request.Response, endpoint: string) {
super(
`Unhandled DatasetQualityApiError.
Status: "${res.status}"
Endpoint: "${endpoint}"
Body: ${JSON.stringify(res.body)}
`
);
this.res = res;
}
}
async function getDatasetQualityApiClient({ svlSharedConfig }: { svlSharedConfig: Config }) {
const kibanaServer = svlSharedConfig.get('servers.kibana');
const cAuthorities = svlSharedConfig.get('servers.kibana.certificateAuthorities');
const username = kbnTestConfig.getUrlParts(kibanaTestSuperuserServerless).username;
const password = kbnTestConfig.getUrlParts(kibanaTestSuperuserServerless).password;
const url = format({
...kibanaServer,
auth: `${username}:${password}`,
});
return createDatasetQualityApiClient(supertest.agent(url, { ca: cAuthorities }));
}
export interface SupertestReturnType<TEndpoint extends APIEndpoint> {
status: number;
body: APIReturnType<TEndpoint>;
}
type DatasetQualityApiClientKey = 'slsUser';
export type DatasetQualityApiClient = Record<
DatasetQualityApiClientKey,
Awaited<ReturnType<typeof getDatasetQualityApiClient>>
>;
export async function getDatasetQualityApiClientService({
getService,
}: InheritedFtrProviderContext): Promise<DatasetQualityApiClient> {
const svlSharedConfig = getService('config');
return {
slsUser: await getDatasetQualityApiClient({
svlSharedConfig,
}),
};
}

View file

@ -0,0 +1,38 @@
/*
* 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 { createLogger, LogLevel, LogsSynthtraceEsClient } from '@kbn/apm-synthtrace';
import { GenericFtrProviderContext } from '@kbn/test';
import {
DatasetQualityApiClient,
getDatasetQualityApiClientService,
} from './dataset_quality_api_supertest';
import {
InheritedServices,
InheritedFtrProviderContext,
services as inheritedServices,
} from '../../../../services';
export type DatasetQualityServices = InheritedServices & {
datasetQualityApiClient: (
context: InheritedFtrProviderContext
) => Promise<DatasetQualityApiClient>;
logSynthtraceEsClient: (context: InheritedFtrProviderContext) => Promise<LogsSynthtraceEsClient>;
};
export const services: DatasetQualityServices = {
...inheritedServices,
datasetQualityApiClient: getDatasetQualityApiClientService,
logSynthtraceEsClient: async (context: InheritedFtrProviderContext) =>
new LogsSynthtraceEsClient({
client: context.getService('es'),
logger: createLogger(LogLevel.info),
refreshAfterIndex: true,
}),
};
export type DatasetQualityFtrContextProvider = GenericFtrProviderContext<typeof services, {}>;

View file

@ -0,0 +1,96 @@
/*
* 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 { log, timerange } from '@kbn/apm-synthtrace-client';
import expect from '@kbn/expect';
import { expectToReject } from './utils';
import {
DatasetQualityApiClient,
DatasetQualityApiError,
} from './common/dataset_quality_api_supertest';
import { DatasetQualityFtrContextProvider } from './common/services';
export default function ({ getService }: DatasetQualityFtrContextProvider) {
const datasetQualityApiClient: DatasetQualityApiClient = getService('datasetQualityApiClient');
const synthtrace = getService('logSynthtraceEsClient');
const start = '2023-12-11T18:00:00.000Z';
const end = '2023-12-11T18:01:00.000Z';
const type = 'logs';
const dataset = 'nginx.access';
const namespace = 'default';
const serviceName = 'my-service';
const hostName = 'synth-host';
async function callApi(dataStream: string) {
return await datasetQualityApiClient.slsUser({
endpoint: 'GET /internal/dataset_quality/data_streams/{dataStream}/details',
params: {
path: {
dataStream,
},
query: {
start,
end,
},
},
});
}
describe('gets the data stream details', () => {
before(async () => {
await synthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is a log message')
.timestamp(timestamp)
.dataset(dataset)
.namespace(namespace)
.defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
})
),
]);
});
it('returns error when dataStream param is not provided', async () => {
const expectedMessage = 'Data Stream name cannot be empty';
const err = await expectToReject<DatasetQualityApiError>(() =>
callApi(encodeURIComponent(' '))
);
expect(err.res.status).to.be(400);
expect(err.res.body.message.indexOf(expectedMessage)).to.greaterThan(-1);
});
it('returns {} if matching data stream is not available', async () => {
const nonExistentDataSet = 'Non-existent';
const nonExistentDataStream = `${type}-${nonExistentDataSet}-${namespace}`;
const resp = await callApi(nonExistentDataStream);
expect(resp.body).empty();
});
it('returns "sizeBytes" as null in serverless', async () => {
const resp = await callApi(`${type}-${dataset}-${namespace}`);
expect(resp.body.sizeBytes).to.be(null);
});
it('returns service.name and host.name correctly', async () => {
const resp = await callApi(`${type}-${dataset}-${namespace}`);
expect(resp.body.services).to.eql({ ['service.name']: [serviceName] });
expect(resp.body.hosts?.['host.name']).to.eql([hostName]);
});
after(async () => {
await synthtrace.clean();
});
});
}

View file

@ -0,0 +1,101 @@
/*
* 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 { log, timerange } from '@kbn/apm-synthtrace-client';
import expect from '@kbn/expect';
import { expectToReject, getDataStreamSettingsOfEarliestIndex, rolloverDataStream } from './utils';
import {
DatasetQualityApiClient,
DatasetQualityApiError,
} from './common/dataset_quality_api_supertest';
import { DatasetQualityFtrContextProvider } from './common/services';
export default function ({ getService }: DatasetQualityFtrContextProvider) {
const datasetQualityApiClient: DatasetQualityApiClient = getService('datasetQualityApiClient');
const synthtrace = getService('logSynthtraceEsClient');
const esClient = getService('es');
const start = '2023-12-11T18:00:00.000Z';
const end = '2023-12-11T18:01:00.000Z';
const type = 'logs';
const dataset = 'nginx.access';
const namespace = 'default';
const serviceName = 'my-service';
const hostName = 'synth-host';
async function callApi(dataStream: string) {
return await datasetQualityApiClient.slsUser({
endpoint: 'GET /internal/dataset_quality/data_streams/{dataStream}/settings',
params: {
path: {
dataStream,
},
},
});
}
describe('gets the data stream settings', () => {
before(async () => {
await synthtrace.index([
timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) =>
log
.create()
.message('This is a log message')
.timestamp(timestamp)
.dataset(dataset)
.namespace(namespace)
.defaults({
'log.file.path': '/my-service.log',
'service.name': serviceName,
'host.name': hostName,
})
),
]);
});
it('returns error when dataStream param is not provided', async () => {
const expectedMessage = 'Data Stream name cannot be empty';
const err = await expectToReject<DatasetQualityApiError>(() =>
callApi(encodeURIComponent(' '))
);
expect(err.res.status).to.be(400);
expect(err.res.body.message.indexOf(expectedMessage)).to.greaterThan(-1);
});
it('returns {} if matching data stream is not available', async () => {
const nonExistentDataSet = 'Non-existent';
const nonExistentDataStream = `${type}-${nonExistentDataSet}-${namespace}`;
const resp = await callApi(nonExistentDataStream);
expect(resp.body).empty();
});
it('returns "createdOn" correctly', async () => {
const dataStreamSettings = await getDataStreamSettingsOfEarliestIndex(
esClient,
`${type}-${dataset}-${namespace}`
);
const resp = await callApi(`${type}-${dataset}-${namespace}`);
expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date));
});
it('returns "createdOn" correctly for rolled over dataStream', async () => {
await rolloverDataStream(esClient, `${type}-${dataset}-${namespace}`);
const dataStreamSettings = await getDataStreamSettingsOfEarliestIndex(
esClient,
`${type}-${dataset}-${namespace}`
);
const resp = await callApi(`${type}-${dataset}-${namespace}`);
expect(resp.body.createdOn).to.be(Number(dataStreamSettings?.index?.creation_date));
});
after(async () => {
await synthtrace.clean();
});
});
}

View file

@ -0,0 +1,14 @@
/*
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Dataset Quality', function () {
loadTestFile(require.resolve('./data_stream_details'));
loadTestFile(require.resolve('./data_stream_settings'));
});
}

View file

@ -0,0 +1,26 @@
/*
* 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 { Client } from '@elastic/elasticsearch';
export async function rolloverDataStream(es: Client, name: string) {
return es.indices.rollover({ alias: name });
}
export async function getDataStreamSettingsOfEarliestIndex(es: Client, name: string) {
const matchingIndexesObj = await es.indices.getSettings({ index: name });
const matchingIndexes = Object.keys(matchingIndexesObj ?? {});
matchingIndexes.sort((a, b) => {
return (
Number(matchingIndexesObj[a].settings?.index?.creation_date) -
Number(matchingIndexesObj[b].settings?.index?.creation_date)
);
});
return matchingIndexesObj[matchingIndexes[0]].settings;
}

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
export async function expectToReject<T extends Error>(fn: () => Promise<any>): Promise<T> {
let res: any;
try {
res = await fn();
} catch (e) {
return e;
}
throw new Error(`expectToReject resolved: "${JSON.stringify(res)}"`);
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { expectToReject } from './expect_to_reject';
export * from './data_stream';

View file

@ -19,5 +19,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./es_query_rule/es_query_rule'));
loadTestFile(require.resolve('./slos'));
loadTestFile(require.resolve('./synthetics'));
loadTestFile(require.resolve('./dataset_quality_api_integration'));
});
}

View file

@ -28,12 +28,14 @@ export function getLogsForDataset({
count = 1,
isMalformed = false,
namespace = defaultNamespace,
services,
}: {
dataset: string;
to: moment.MomentInput;
count?: number;
isMalformed?: boolean;
namespace?: string;
services?: string[];
}) {
return timerange(moment(to).subtract(count, 'minute'), moment(to))
.interval('1m')
@ -46,7 +48,9 @@ export function getLogsForDataset({
timestamp,
dataset,
MESSAGE_LOG_LEVELS[index % MESSAGE_LOG_LEVELS.length],
SERVICE_NAMES[index % SERVICE_NAMES.length],
services?.[index] ??
services?.[index % services.length] ??
SERVICE_NAMES[index % SERVICE_NAMES.length],
CLUSTER[index % CLUSTER.length],
CLOUD_PROVIDERS[index % CLOUD_PROVIDERS.length],
CLOUD_REGION[index % CLOUD_REGION.length],

View file

@ -29,9 +29,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const retry = getService('retry');
const browser = getService('browser');
const to = '2024-01-01T12:00:00.000Z';
const excludeKeysFromServerless = ['size']; // https://github.com/elastic/kibana/issues/178954
// FLAKY: https://github.com/elastic/kibana/issues/180994
describe.skip('Dataset quality flyout', () => {
describe('Dataset quality flyout', () => {
before(async () => {
await PageObjects.svlCommonPage.loginWithRole('admin');
await synthtrace.index(getInitialTestLogs({ to, count: 4 }));
@ -85,7 +85,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(lastActivityTextExists).to.eql(true);
});
it('reflects the breakdown field state in url', async () => {
// FLAKY: https://github.com/elastic/kibana/issues/180994
it.skip('reflects the breakdown field state in url', async () => {
const testDatasetName = datasetNames[0];
await PageObjects.datasetQuality.openDatasetFlyout(testDatasetName);
@ -149,6 +150,149 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(datasetSelectorText).to.eql(testDatasetName);
});
it('shows summary KPIs', async () => {
await PageObjects.datasetQuality.navigateTo();
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const summary = await PageObjects.datasetQuality.parseFlyoutKpis(excludeKeysFromServerless);
expect(summary).to.eql({
docsCountTotal: '0',
// size: '0.0 B', // `_stats` not available on Serverless
services: '0',
hosts: '0',
degradedDocs: '0',
});
});
it('shows the updated KPIs', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const summaryBefore = await PageObjects.datasetQuality.parseFlyoutKpis(
excludeKeysFromServerless
);
// Set time range to 3 days ago
const flyoutBodyContainer = await testSubjects.find(
PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutBody
);
await PageObjects.datasetQuality.setDatePickerLastXUnits(flyoutBodyContainer, 3, 'd');
// Index 2 doc 2 days ago
const time2DaysAgo = Date.now() - 2 * 24 * 60 * 60 * 1000;
await synthtrace.index(
getLogsForDataset({
to: time2DaysAgo,
count: 2,
dataset: apacheAccessDatasetName,
isMalformed: false,
})
);
// Index 5 degraded docs 2 days ago
await synthtrace.index(
getLogsForDataset({
to: time2DaysAgo,
count: 5,
dataset: apacheAccessDatasetName,
isMalformed: true,
})
);
await PageObjects.datasetQuality.refreshFlyout();
const summaryAfter = await PageObjects.datasetQuality.parseFlyoutKpis(
excludeKeysFromServerless
);
expect(parseInt(summaryAfter.docsCountTotal, 10)).to.be.greaterThan(
parseInt(summaryBefore.docsCountTotal, 10)
);
expect(parseInt(summaryAfter.degradedDocs, 10)).to.be.greaterThan(
parseInt(summaryBefore.degradedDocs, 10)
);
// `_stats` not available on Serverless so we can't compare size // https://github.com/elastic/kibana/issues/178954
// expect(parseInt(summaryAfter.size, 10)).to.be.greaterThan(parseInt(summaryBefore.size, 10));
expect(parseInt(summaryAfter.services, 10)).to.be.greaterThan(
parseInt(summaryBefore.services, 10)
);
expect(parseInt(summaryAfter.hosts, 10)).to.be.greaterThan(parseInt(summaryBefore.hosts, 10));
});
it('shows the right number of services', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const summaryBefore = await PageObjects.datasetQuality.parseFlyoutKpis(
excludeKeysFromServerless
);
const testServices = ['test-srv-1', 'test-srv-2'];
// Index 2 docs with different services
const timeNow = Date.now();
await synthtrace.index(
getLogsForDataset({
to: timeNow,
count: 2,
dataset: apacheAccessDatasetName,
isMalformed: false,
services: testServices,
})
);
await PageObjects.datasetQuality.refreshFlyout();
const summaryAfter = await PageObjects.datasetQuality.parseFlyoutKpis(
excludeKeysFromServerless
);
expect(parseInt(summaryAfter.services, 10)).to.eql(
parseInt(summaryBefore.services, 10) + testServices.length
);
});
it('goes to log explorer for degraded docs when show all is clicked', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const degradedDocsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.degradedDocs}`;
await testSubjects.click(degradedDocsShowAllSelector);
await browser.switchTab(1);
// Confirm dataset selector text in observability logs explorer
const datasetSelectorText =
await PageObjects.observabilityLogsExplorer.getDataSourceSelectorButtonText();
expect(datasetSelectorText).to.contain(apacheAccessDatasetName);
await browser.closeCurrentWindow();
await browser.switchTab(0);
});
it('goes to infra hosts for hosts when show all is clicked', async () => {
const apacheAccessDatasetHumanName = 'Apache access logs';
await PageObjects.datasetQuality.openDatasetFlyout(apacheAccessDatasetHumanName);
const hostsShowAllSelector = `${PageObjects.datasetQuality.testSubjectSelectors.datasetQualityFlyoutKpiLink}-${PageObjects.datasetQuality.texts.hosts}`;
await testSubjects.click(hostsShowAllSelector);
await browser.switchTab(1);
// Confirm url contains metrics/hosts
await retry.tryForTime(5000, async () => {
const currentUrl = await browser.getCurrentUrl();
const parsedUrl = new URL(currentUrl);
expect(parsedUrl.pathname).to.contain('/app/metrics/hosts');
});
await browser.closeCurrentWindow();
await browser.switchTab(0);
});
it('Integration actions menu is present with correct actions', async () => {
const apacheAccessDatasetName = 'apache.access';
const apacheAccessDatasetHumanName = 'Apache access logs';

View file

@ -40,7 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
datasetHealthDegraded: '0',
datasetHealthGood: '3',
activeDatasets: '0 of 3',
// estimatedData: '0 Bytes', https://github.com/elastic/kibana/issues/178954
// estimatedData: '0.0 B', https://github.com/elastic/kibana/issues/178954
});
});
@ -131,7 +131,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(updatedActiveDatasets).to.eql('3 of 3');
// TODO: Investigate. This fails on Serverless.
// TODO: `_stats` not available on Serverless. // https://github.com/elastic/kibana/issues/178954
// expect(_updatedEstimatedData).to.not.eql(_existingEstimatedData);
});
});

View file

@ -98,5 +98,6 @@
"@kbn/es-query",
"@kbn/utility-types",
"@kbn/synthetics-plugin",
"@kbn/dataset-quality-plugin"
]
}