mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
67a2eb54c9
commit
e1ec9ee17e
59 changed files with 2731 additions and 472 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -242,7 +242,7 @@ function getChartColumns(breakdownField?: string): Record<string, GenericIndexPa
|
|||
},
|
||||
orderDirection: 'desc',
|
||||
otherBucket: true,
|
||||
missingBucket: false,
|
||||
missingBucket: true,
|
||||
parentFormat: {
|
||||
id: 'terms',
|
||||
},
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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."
|
||||
/>
|
||||
);
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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 ? '+' : ''}`;
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {},
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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}"`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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) => {
|
|||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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, {}>;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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)}"`);
|
||||
}
|
|
@ -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';
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -98,5 +98,6 @@
|
|||
"@kbn/es-query",
|
||||
"@kbn/utility-types",
|
||||
"@kbn/synthetics-plugin",
|
||||
"@kbn/dataset-quality-plugin"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue