[Exploratory View] Mobile experience (#99565)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Bryce Buchanan <bryce.buchanan@elastic.co>
Co-authored-by: Alexander Wert <alexander.wert@elastic.co>
This commit is contained in:
Shahzad 2021-06-18 17:30:53 +02:00 committed by GitHub
parent cee33b004c
commit 303806de65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 822 additions and 205 deletions

View file

@ -53,10 +53,8 @@ export const fetchObservabilityOverviewPageData = async ({
};
export async function getHasData() {
const res = await callApmApi({
return await callApmApi({
endpoint: 'GET /api/apm/observability_overview/has_data',
signal: null,
});
return res.hasData;
}

View file

@ -29,8 +29,14 @@ export async function getHasData({ setup }: { setup: Setup }) {
'observability_overview_has_apm_data',
params
);
return response.hits.total.value > 0;
return {
hasData: response.hits.total.value > 0,
indices: setup.indices,
};
} catch (e) {
return false;
return {
hasData: false,
indices: setup.indices,
};
}
}

View file

@ -16,22 +16,12 @@ import {
import { APMConfig } from '../../..';
import { APMRouteHandlerResources } from '../../../routes/typings';
import { withApmSpan } from '../../../utils/with_apm_span';
import { ApmIndicesConfig } from '../../../../../observability/common/typings';
export { ApmIndicesConfig };
type ISavedObjectsClient = Pick<SavedObjectsClient, 'get'>;
export interface ApmIndicesConfig {
/* eslint-disable @typescript-eslint/naming-convention */
'apm_oss.sourcemapIndices': string;
'apm_oss.errorIndices': string;
'apm_oss.onboardingIndices': string;
'apm_oss.spanIndices': string;
'apm_oss.transactionIndices': string;
'apm_oss.metricsIndices': string;
/* eslint-enable @typescript-eslint/naming-convention */
apmAgentConfigurationIndex: string;
apmCustomLinkIndex: string;
}
export type ApmIndicesName = keyof ApmIndicesConfig;
async function getApmIndicesSavedObject(

View file

@ -21,8 +21,7 @@ const observabilityOverviewHasDataRoute = createApmServerRoute({
options: { tags: ['access:apm'] },
handler: async (resources) => {
const setup = await setupRequest(resources);
const res = await getHasData({ setup });
return { hasData: res };
return await getHasData({ setup });
},
});

View file

@ -10,3 +10,16 @@ export type Maybe<T> = T | null | undefined;
export const alertStatusRt = t.union([t.literal('all'), t.literal('open'), t.literal('closed')]);
export type AlertStatus = t.TypeOf<typeof alertStatusRt>;
export interface ApmIndicesConfig {
/* eslint-disable @typescript-eslint/naming-convention */
'apm_oss.sourcemapIndices': string;
'apm_oss.errorIndices': string;
'apm_oss.onboardingIndices': string;
'apm_oss.spanIndices': string;
'apm_oss.transactionIndices': string;
'apm_oss.metricsIndices': string;
/* eslint-enable @typescript-eslint/naming-convention */
apmAgentConfigurationIndex: string;
apmCustomLinkIndex: string;
}

View file

@ -13,26 +13,24 @@ import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { useHasData } from '../../../hooks/use_has_data';
import { usePluginContext } from '../../../hooks/use_plugin_context';
import { getEmptySections } from '../../../pages/overview/empty_section';
import { UXHasDataResponse } from '../../../typings';
import { EmptySection } from './empty_section';
export function EmptySections() {
const { core } = usePluginContext();
const theme = useContext(ThemeContext);
const { hasData } = useHasData();
const { hasDataMap } = useHasData();
const appEmptySections = getEmptySections({ core }).filter(({ id }) => {
if (id === 'alert') {
const { status, hasData: alerts } = hasData.alert || {};
const { status, hasData: alerts } = hasDataMap.alert || {};
return (
status === FETCH_STATUS.FAILURE ||
(status === FETCH_STATUS.SUCCESS && (alerts as Alert[]).length === 0)
);
} else {
const app = hasData[id];
const app = hasDataMap[id];
if (app) {
const _hasData = id === 'ux' ? (app.hasData as UXHasDataResponse)?.hasData : app.hasData;
return app.status === FETCH_STATUS.FAILURE || !_hasData;
return app.status === FETCH_STATUS.FAILURE || !app.hasData;
}
}
return false;

View file

@ -29,7 +29,7 @@ jest.mock('react-router-dom', () => ({
describe('APMSection', () => {
beforeAll(() => {
jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({
hasData: {
hasDataMap: {
apm: {
status: fetcherHook.FETCH_STATUS.SUCCESS,
hasData: true,

View file

@ -48,7 +48,7 @@ export function APMSection({ bucketSize }: Props) {
const theme = useContext(ThemeContext);
const chartTheme = useChartTheme();
const history = useHistory();
const { forceUpdate, hasData } = useHasData();
const { forceUpdate, hasDataMap } = useHasData();
const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange();
const { data, status } = useFetcher(
@ -66,7 +66,7 @@ export function APMSection({ bucketSize }: Props) {
[bucketSize, relativeStart, relativeEnd, forceUpdate]
);
if (!hasData.apm?.hasData) {
if (!hasDataMap.apm?.hasData) {
return null;
}

View file

@ -47,7 +47,7 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) {
export function LogsSection({ bucketSize }: Props) {
const history = useHistory();
const chartTheme = useChartTheme();
const { forceUpdate, hasData } = useHasData();
const { forceUpdate, hasDataMap } = useHasData();
const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange();
const { data, status } = useFetcher(
@ -65,7 +65,7 @@ export function LogsSection({ bucketSize }: Props) {
[bucketSize, relativeStart, relativeEnd, forceUpdate]
);
if (!hasData.infra_logs?.hasData) {
if (!hasDataMap.infra_logs?.hasData) {
return null;
}

View file

@ -50,7 +50,7 @@ const bytesPerSecondFormatter = (value: NumberOrNull) =>
value === null ? '' : numeral(value).format('0b') + '/s';
export function MetricsSection({ bucketSize }: Props) {
const { forceUpdate, hasData } = useHasData();
const { forceUpdate, hasDataMap } = useHasData();
const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange();
const [sortDirection, setSortDirection] = useState<Direction>('asc');
const [sortField, setSortField] = useState<keyof MetricsFetchDataSeries>('uptime');
@ -88,7 +88,7 @@ export function MetricsSection({ bucketSize }: Props) {
[data, setSortField, setSortDirection]
);
if (!hasData.infra_metrics?.hasData) {
if (!hasDataMap.infra_metrics?.hasData) {
return null;
}

View file

@ -40,7 +40,7 @@ export function UptimeSection({ bucketSize }: Props) {
const theme = useContext(ThemeContext);
const chartTheme = useChartTheme();
const history = useHistory();
const { forceUpdate, hasData } = useHasData();
const { forceUpdate, hasDataMap } = useHasData();
const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange();
const { data, status } = useFetcher(
@ -58,7 +58,7 @@ export function UptimeSection({ bucketSize }: Props) {
[bucketSize, relativeStart, relativeEnd, forceUpdate]
);
if (!hasData.synthetics?.hasData) {
if (!hasDataMap.synthetics?.hasData) {
return null;
}

View file

@ -28,10 +28,11 @@ jest.mock('react-router-dom', () => ({
describe('UXSection', () => {
beforeAll(() => {
jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({
hasData: {
hasDataMap: {
ux: {
status: fetcherHook.FETCH_STATUS.SUCCESS,
hasData: { hasData: true, serviceName: 'elastic-co-frontend' },
hasData: true,
serviceName: 'elastic-co-frontend',
},
},
} as HasDataContextValue);

View file

@ -12,7 +12,6 @@ import { getDataHandler } from '../../../../data_handler';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
import { useHasData } from '../../../../hooks/use_has_data';
import { useTimeRange } from '../../../../hooks/use_time_range';
import { UXHasDataResponse } from '../../../../typings';
import CoreVitals from '../../../shared/core_web_vitals';
interface Props {
@ -20,10 +19,10 @@ interface Props {
}
export function UXSection({ bucketSize }: Props) {
const { forceUpdate, hasData } = useHasData();
const { forceUpdate, hasDataMap } = useHasData();
const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange();
const uxHasDataResponse = (hasData.ux?.hasData as UXHasDataResponse) || {};
const serviceName = uxHasDataResponse.serviceName as string;
const uxHasDataResponse = hasDataMap.ux;
const serviceName = uxHasDataResponse?.serviceName as string;
const { data, status } = useFetcher(
() => {

View file

@ -6,7 +6,11 @@
*/
import { FieldFormat } from '../../types';
import { TRANSACTION_DURATION } from '../constants/elasticsearch_fieldnames';
import {
METRIC_SYSTEM_CPU_USAGE,
METRIC_SYSTEM_MEMORY_USAGE,
TRANSACTION_DURATION,
} from '../constants/elasticsearch_fieldnames';
export const apmFieldFormats: FieldFormat[] = [
{
@ -18,7 +22,16 @@ export const apmFieldFormats: FieldFormat[] = [
outputFormat: 'asMilliseconds',
outputPrecision: 0,
showSuffix: true,
useShortSuffix: true,
},
},
},
{
field: METRIC_SYSTEM_MEMORY_USAGE,
format: { id: 'bytes', params: {} },
},
{
field: METRIC_SYSTEM_CPU_USAGE,
format: { id: 'percent', params: {} },
},
];

View file

@ -13,12 +13,13 @@ import {
BROWSER_VERSION_LABEL,
CLS_LABEL,
CORE_WEB_VITALS_LABEL,
DEVICE_DISTRIBUTION_LABEL,
DEVICE_LABEL,
ENVIRONMENT_LABEL,
FCP_LABEL,
FID_LABEL,
HOST_NAME_LABEL,
KIP_OVER_TIME_LABEL,
KPI_OVER_TIME_LABEL,
KPI_LABEL,
LCP_LABEL,
LOCATION_LABEL,
@ -31,6 +32,7 @@ import {
OS_LABEL,
PERF_DIST_LABEL,
PORT_LABEL,
REQUEST_METHOD,
SERVICE_NAME_LABEL,
TAGS_LABEL,
TBT_LABEL,
@ -72,14 +74,17 @@ export const FieldLabels: Record<string, string> = {
'performance.metric': METRIC_LABEL,
'Business.KPI': KPI_LABEL,
'http.request.method': REQUEST_METHOD,
};
export const DataViewLabels: Record<ReportViewTypeId, string> = {
dist: PERF_DIST_LABEL,
kpi: KIP_OVER_TIME_LABEL,
kpi: KPI_OVER_TIME_LABEL,
cwv: CORE_WEB_VITALS_LABEL,
mdd: DEVICE_DISTRIBUTION_LABEL,
};
export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN';
export const FILTER_RECORDS = 'FILTER_RECORDS';
export const TERMS_COLUMN = 'TERMS_COLUMN';
export const OPERATION_COLUMN = 'operation';

View file

@ -86,6 +86,8 @@ export const ERROR_PAGE_URL = 'error.page.url';
// METRICS
export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free';
export const METRIC_SYSTEM_MEMORY_USAGE = 'system.memory.usage';
export const METRIC_SYSTEM_CPU_USAGE = 'system.cpu.usage';
export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total';
export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct';
export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct';

View file

@ -165,7 +165,7 @@ export const KPI_LABEL = i18n.translate('xpack.observability.expView.fieldLabels
export const PERF_DIST_LABEL = i18n.translate(
'xpack.observability.expView.fieldLabels.performanceDistribution',
{
defaultMessage: 'Performance Distribution',
defaultMessage: 'Performance distribution',
}
);
@ -176,6 +176,20 @@ export const CORE_WEB_VITALS_LABEL = i18n.translate(
}
);
export const DEVICE_DISTRIBUTION_LABEL = i18n.translate(
'xpack.observability.expView.fieldLabels.deviceDistribution',
{
defaultMessage: 'Device distribution',
}
);
export const MOBILE_RESPONSE_LABEL = i18n.translate(
'xpack.observability.expView.fieldLabels.mobileResponse',
{
defaultMessage: 'Mobile response',
}
);
export const MEMORY_USAGE_LABEL = i18n.translate(
'xpack.observability.expView.fieldLabels.memoryUsage',
{
@ -183,7 +197,7 @@ export const MEMORY_USAGE_LABEL = i18n.translate(
}
);
export const KIP_OVER_TIME_LABEL = i18n.translate(
export const KPI_OVER_TIME_LABEL = i18n.translate(
'xpack.observability.expView.fieldLabels.kpiOverTime',
{
defaultMessage: 'KPI over time',
@ -211,3 +225,82 @@ export const UP_LABEL = i18n.translate('xpack.observability.expView.fieldLabels.
export const DOWN_LABEL = i18n.translate('xpack.observability.expView.fieldLabels.downPings', {
defaultMessage: 'Down Pings',
});
export const CARRIER_NAME = i18n.translate('xpack.observability.expView.fieldLabels.carrierName', {
defaultMessage: 'Carrier Name',
});
export const REQUEST_METHOD = i18n.translate(
'xpack.observability.expView.fieldLabels.requestMethod',
{
defaultMessage: 'Request Method',
}
);
export const CONNECTION_TYPE = i18n.translate(
'xpack.observability.expView.fieldLabels.connectionType',
{
defaultMessage: 'Connection Type',
}
);
export const HOST_OS = i18n.translate('xpack.observability.expView.fieldLabels.hostOS', {
defaultMessage: 'Host OS',
});
export const SERVICE_VERSION = i18n.translate(
'xpack.observability.expView.fieldLabels.serviceVersion',
{
defaultMessage: 'Service Version',
}
);
export const OS_PLATFORM = i18n.translate('xpack.observability.expView.fieldLabels.osPlatform', {
defaultMessage: 'OS Platform',
});
export const DEVICE_MODEL = i18n.translate('xpack.observability.expView.fieldLabels.deviceModel', {
defaultMessage: 'Device Model',
});
export const CARRIER_LOCATION = i18n.translate(
'xpack.observability.expView.fieldLabels.carrierLocation',
{
defaultMessage: 'Carrier Location',
}
);
export const RESPONSE_LATENCY = i18n.translate(
'xpack.observability.expView.fieldLabels.responseLatency',
{
defaultMessage: 'Response latency',
}
);
export const MOBILE_APP = i18n.translate('xpack.observability.expView.fieldLabels.mobileApp', {
defaultMessage: 'Mobile App',
});
export const MEMORY_USAGE = i18n.translate(
'xpack.observability.expView.fieldLabels.mobile.memoryUsage',
{
defaultMessage: 'Memory Usage',
}
);
export const CPU_USAGE = i18n.translate('xpack.observability.expView.fieldLabels.cpuUsage', {
defaultMessage: 'CPU Usage',
});
export const TRANSACTIONS_PER_MINUTE = i18n.translate(
'xpack.observability.expView.fieldLabels.transactionPerMinute',
{
defaultMessage: 'Transactions per minute',
}
);
export const NUMBER_OF_DEVICES = i18n.translate(
'xpack.observability.expView.fieldLabels.numberOfDevices',
{
defaultMessage: 'Number of Devices',
}
);

View file

@ -12,6 +12,9 @@ import { getSyntheticsKPIConfig } from './synthetics/kpi_over_time_config';
import { getKPITrendsLensConfig } from './rum/kpi_over_time_config';
import { IndexPattern } from '../../../../../../../../src/plugins/data/common';
import { getCoreWebVitalsConfig } from './rum/core_web_vitals_config';
import { getMobileKPIConfig } from './mobile/kpi_over_time_config';
import { getMobileKPIDistributionConfig } from './mobile/distribution_config';
import { getMobileDeviceDistributionConfig } from './mobile/device_distribution_config';
interface Props {
reportType: keyof typeof ReportViewTypes;
@ -34,7 +37,14 @@ export const getDefaultConfigs = ({ reportType, dataType, indexPattern }: Props)
return getSyntheticsDistributionConfig({ indexPattern });
}
return getSyntheticsKPIConfig({ indexPattern });
case 'mobile':
if (reportType === 'dist') {
return getMobileKPIDistributionConfig({ indexPattern });
}
if (reportType === 'mdd') {
return getMobileDeviceDistributionConfig({ indexPattern });
}
return getMobileKPIConfig({ indexPattern });
default:
return getKPITrendsLensConfig({ indexPattern });
}

View file

@ -25,13 +25,14 @@ import {
FieldBasedIndexPatternColumn,
SumIndexPatternColumn,
TermsIndexPatternColumn,
CardinalityIndexPatternColumn,
} from '../../../../../../lens/public';
import {
buildPhraseFilter,
buildPhrasesFilter,
IndexPattern,
} from '../../../../../../../../src/plugins/data/common';
import { FieldLabels, FILTER_RECORDS, USE_BREAK_DOWN_COLUMN } from './constants';
import { FieldLabels, FILTER_RECORDS, USE_BREAK_DOWN_COLUMN, TERMS_COLUMN } from './constants';
import { ColumnFilter, DataSeries, UrlFilter, URLReportDefinition } from '../types';
function getLayerReferenceName(layerId: string) {
@ -55,6 +56,7 @@ export const parseCustomFieldName = (
let fieldName = sourceField;
let columnType;
let columnFilters;
let timeScale;
let columnLabel;
const rdf = reportViewConfig.reportDefinitions ?? [];
@ -70,17 +72,19 @@ export const parseCustomFieldName = (
);
columnType = currField?.columnType;
columnFilters = currField?.columnFilters;
timeScale = currField?.timeScale;
columnLabel = currField?.label;
}
} else if (customField.options?.[0].field || customField.options?.[0].id) {
fieldName = customField.options?.[0].field || customField.options?.[0].id;
columnType = customField.options?.[0].columnType;
columnFilters = customField.options?.[0].columnFilters;
timeScale = customField.options?.[0].timeScale;
columnLabel = customField.options?.[0].label;
}
}
return { fieldName, columnType, columnFilters, columnLabel };
return { fieldName, columnType, columnFilters, timeScale, columnLabel };
};
export class LensAttributes {
@ -167,10 +171,10 @@ export class LensAttributes {
this.visualization.layers[0].splitAccessor = undefined;
}
getNumberRangeColumn(sourceField: string): RangeIndexPatternColumn {
getNumberRangeColumn(sourceField: string, label?: string): RangeIndexPatternColumn {
return {
sourceField,
label: this.reportViewConfig.labels[sourceField],
label: this.reportViewConfig.labels[sourceField] ?? label,
dataType: 'number',
operationType: 'range',
isBucketed: true,
@ -183,6 +187,10 @@ export class LensAttributes {
};
}
getCardinalityColumn(sourceField: string, label?: string) {
return this.getNumberOperationColumn(sourceField, 'unique_count', label);
}
getNumberColumn(
sourceField: string,
columnType?: string,
@ -190,21 +198,30 @@ export class LensAttributes {
label?: string
) {
if (columnType === 'operation' || operationType) {
if (operationType === 'median' || operationType === 'average' || operationType === 'sum') {
if (
operationType === 'median' ||
operationType === 'average' ||
operationType === 'sum' ||
operationType === 'unique_count'
) {
return this.getNumberOperationColumn(sourceField, operationType, label);
}
if (operationType?.includes('th')) {
return this.getPercentileNumberColumn(sourceField, operationType);
}
}
return this.getNumberRangeColumn(sourceField);
return this.getNumberRangeColumn(sourceField, label);
}
getNumberOperationColumn(
sourceField: string,
operationType: 'average' | 'median' | 'sum',
operationType: 'average' | 'median' | 'sum' | 'unique_count',
label?: string
): AvgIndexPatternColumn | MedianIndexPatternColumn | SumIndexPatternColumn {
):
| AvgIndexPatternColumn
| MedianIndexPatternColumn
| SumIndexPatternColumn
| CardinalityIndexPatternColumn {
return {
...buildNumberColumn(sourceField),
label:
@ -247,6 +264,25 @@ export class LensAttributes {
};
}
getTermsColumn(sourceField: string, label?: string): TermsIndexPatternColumn {
return {
operationType: 'terms',
sourceField,
label: label || 'Top values of ' + sourceField,
dataType: 'string',
isBucketed: true,
scale: 'ordinal',
params: {
size: 10,
orderBy: {
type: 'alphabetical',
fallback: false,
},
orderDirection: 'desc',
},
};
}
getXAxis() {
const { xAxisColumn } = this.reportViewConfig;
@ -263,15 +299,25 @@ export class LensAttributes {
label?: string,
colIndex?: number
) {
const { fieldMeta, columnType, fieldName, columnFilters, columnLabel } = this.getFieldMeta(
sourceField
);
const {
fieldMeta,
columnType,
fieldName,
columnFilters,
timeScale,
columnLabel,
} = this.getFieldMeta(sourceField);
const { type: fieldType } = fieldMeta ?? {};
if (columnType === TERMS_COLUMN) {
return this.getTermsColumn(fieldName, columnLabel || label);
}
if (fieldName === 'Records' || columnType === FILTER_RECORDS) {
return this.getRecordsColumn(
columnLabel || label,
colIndex !== undefined ? columnFilters?.[colIndex] : undefined
colIndex !== undefined ? columnFilters?.[colIndex] : undefined,
timeScale
);
}
@ -281,6 +327,9 @@ export class LensAttributes {
if (fieldType === 'number') {
return this.getNumberColumn(fieldName, columnType, operationType, columnLabel || label);
}
if (operationType === 'unique_count') {
return this.getCardinalityColumn(fieldName, columnLabel || label);
}
// FIXME review my approach again
return this.getDateHistogramColumn(fieldName);
@ -291,13 +340,17 @@ export class LensAttributes {
}
getFieldMeta(sourceField: string) {
const { fieldName, columnType, columnFilters, columnLabel } = this.getCustomFieldName(
sourceField
);
const {
fieldName,
columnType,
columnFilters,
timeScale,
columnLabel,
} = this.getCustomFieldName(sourceField);
const fieldMeta = this.indexPattern.getFieldByName(fieldName);
return { fieldMeta, fieldName, columnType, columnFilters, columnLabel };
return { fieldMeta, fieldName, columnType, columnFilters, timeScale, columnLabel };
}
getMainYAxis() {
@ -330,7 +383,11 @@ export class LensAttributes {
return lensColumns;
}
getRecordsColumn(label?: string, columnFilter?: ColumnFilter): CountIndexPatternColumn {
getRecordsColumn(
label?: string,
columnFilter?: ColumnFilter,
timeScale?: string
): CountIndexPatternColumn {
return {
dataType: 'number',
isBucketed: false,
@ -339,6 +396,7 @@ export class LensAttributes {
scale: 'ratio',
sourceField: 'Records',
filter: columnFilter,
timeScale,
} as CountIndexPatternColumn;
}

View file

@ -0,0 +1,49 @@
/*
* 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 { ConfigProps, DataSeries } from '../../types';
import { FieldLabels, USE_BREAK_DOWN_COLUMN } from '../constants';
import { buildPhraseFilter } from '../utils';
import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames';
import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels';
import { MobileFields } from './mobile_fields';
export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): DataSeries {
return {
reportType: 'mobile-device-distribution',
defaultSeriesType: 'bar',
seriesTypes: ['bar', 'bar_horizontal'],
xAxisColumn: {
sourceField: USE_BREAK_DOWN_COLUMN,
},
yAxisColumns: [
{
sourceField: 'labels.device_id',
operationType: 'unique_count',
label: NUMBER_OF_DEVICES,
},
],
hasOperationType: false,
defaultFilters: Object.keys(MobileFields),
breakdowns: Object.keys(MobileFields),
filters: [
...buildPhraseFilter('agent.name', 'iOS/swift', indexPattern),
...buildPhraseFilter('processor.event', 'transaction', indexPattern),
],
labels: {
...FieldLabels,
...MobileFields,
[SERVICE_NAME]: MOBILE_APP,
},
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
],
};
}

View file

@ -0,0 +1,81 @@
/*
* 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 { ConfigProps, DataSeries } from '../../types';
import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD } from '../constants';
import { buildPhrasesFilter } from '../utils';
import {
METRIC_SYSTEM_CPU_USAGE,
METRIC_SYSTEM_MEMORY_USAGE,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_DURATION,
} from '../constants/elasticsearch_fieldnames';
import { CPU_USAGE, MEMORY_USAGE, MOBILE_APP, RESPONSE_LATENCY } from '../constants/labels';
import { MobileFields } from './mobile_fields';
export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): DataSeries {
return {
reportType: 'data-distribution',
defaultSeriesType: 'bar',
seriesTypes: ['line', 'bar'],
xAxisColumn: {
sourceField: 'performance.metric',
},
yAxisColumns: [
{
sourceField: RECORDS_FIELD,
},
],
hasOperationType: false,
defaultFilters: Object.keys(MobileFields),
breakdowns: Object.keys(MobileFields),
filters: [
...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], indexPattern),
],
labels: {
...FieldLabels,
...MobileFields,
[SERVICE_NAME]: MOBILE_APP,
},
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
{
field: SERVICE_ENVIRONMENT,
required: true,
},
{
field: 'performance.metric',
custom: true,
options: [
{
label: RESPONSE_LATENCY,
field: TRANSACTION_DURATION,
id: TRANSACTION_DURATION,
columnType: OPERATION_COLUMN,
},
{
label: MEMORY_USAGE,
field: METRIC_SYSTEM_MEMORY_USAGE,
id: METRIC_SYSTEM_MEMORY_USAGE,
columnType: OPERATION_COLUMN,
},
{
label: CPU_USAGE,
field: METRIC_SYSTEM_CPU_USAGE,
id: METRIC_SYSTEM_CPU_USAGE,
columnType: OPERATION_COLUMN,
},
],
},
],
};
}

View file

@ -0,0 +1,102 @@
/*
* 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 { ConfigProps, DataSeries } from '../../types';
import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD } from '../constants';
import { buildPhrasesFilter } from '../utils';
import {
METRIC_SYSTEM_CPU_USAGE,
METRIC_SYSTEM_MEMORY_USAGE,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_DURATION,
} from '../constants/elasticsearch_fieldnames';
import {
CPU_USAGE,
MEMORY_USAGE,
MOBILE_APP,
RESPONSE_LATENCY,
TRANSACTIONS_PER_MINUTE,
} from '../constants/labels';
import { MobileFields } from './mobile_fields';
export function getMobileKPIConfig({ indexPattern }: ConfigProps): DataSeries {
return {
reportType: 'kpi-over-time',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar', 'area'],
xAxisColumn: {
sourceField: '@timestamp',
},
yAxisColumns: [
{
sourceField: 'business.kpi',
operationType: 'median',
},
],
hasOperationType: true,
defaultFilters: Object.keys(MobileFields),
breakdowns: Object.keys(MobileFields),
filters: [
...buildPhrasesFilter('agent.name', ['iOS/swift', 'open-telemetry/swift'], indexPattern),
],
labels: {
...FieldLabels,
...MobileFields,
[TRANSACTION_DURATION]: RESPONSE_LATENCY,
[SERVICE_NAME]: MOBILE_APP,
[METRIC_SYSTEM_MEMORY_USAGE]: MEMORY_USAGE,
[METRIC_SYSTEM_CPU_USAGE]: CPU_USAGE,
},
reportDefinitions: [
{
field: SERVICE_NAME,
required: true,
},
{
field: SERVICE_ENVIRONMENT,
required: true,
},
{
field: 'business.kpi',
custom: true,
options: [
{
label: RESPONSE_LATENCY,
field: TRANSACTION_DURATION,
id: TRANSACTION_DURATION,
columnType: OPERATION_COLUMN,
},
{
label: MEMORY_USAGE,
field: METRIC_SYSTEM_MEMORY_USAGE,
id: METRIC_SYSTEM_MEMORY_USAGE,
columnType: OPERATION_COLUMN,
},
{
label: CPU_USAGE,
field: METRIC_SYSTEM_CPU_USAGE,
id: METRIC_SYSTEM_CPU_USAGE,
columnType: OPERATION_COLUMN,
},
{
field: RECORDS_FIELD,
id: RECORDS_FIELD,
label: TRANSACTIONS_PER_MINUTE,
columnFilters: [
{
language: 'kuery',
query: `processor.event: transaction`,
},
],
timeScale: 'm',
},
],
},
],
};
}

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
CARRIER_LOCATION,
CARRIER_NAME,
CONNECTION_TYPE,
DEVICE_MODEL,
HOST_OS,
OS_PLATFORM,
SERVICE_VERSION,
} from '../constants/labels';
export const MobileFields: Record<string, string> = {
'host.os.platform': OS_PLATFORM,
'host.os.full': HOST_OS,
'service.version': SERVICE_VERSION,
'network.carrier.icc': CARRIER_LOCATION,
'network.carrier.name': CARRIER_NAME,
'network.connection_type': CONNECTION_TYPE,
'labels.device_model': DEVICE_MODEL,
};

View file

@ -63,7 +63,7 @@ describe('ExploratoryView', () => {
render(<ExploratoryView />, { initSeries });
expect(await screen.findByText(/open in lens/i)).toBeInTheDocument();
expect(await screen.findByText('Performance Distribution')).toBeInTheDocument();
expect((await screen.findAllByText('Performance distribution'))[0]).toBeInTheDocument();
expect(await screen.findByText(/Lens Embeddable Component/i)).toBeInTheDocument();
await waitFor(() => {

View file

@ -12,7 +12,6 @@ import { useKibana } from '../../../../../../../../src/plugins/kibana_react/publ
import { ObservabilityPublicPluginsStart } from '../../../../plugin';
import { ObservabilityIndexPatterns } from '../utils/observability_index_patterns';
import { getDataHandler } from '../../../../data_handler';
import { HasDataResponse } from '../../../../typings/fetch_overview_data';
export interface IIndexPatternContext {
loading: boolean;
@ -41,17 +40,13 @@ export function IndexPatternContextProvider({ children }: ProviderProps) {
synthetics: null,
ux: null,
apm: null,
mobile: null,
} as HasAppDataState);
const {
services: { data },
} = useKibana<ObservabilityPublicPluginsStart>();
const checkIfAppHasData = async (dataType: AppDataType) => {
const handler = getDataHandler(dataType);
return handler?.hasData();
};
const loadIndexPattern: IIndexPatternContext['loadIndexPattern'] = useCallback(
async ({ dataType }) => {
setSelectedApp(dataType);
@ -59,15 +54,27 @@ export function IndexPatternContextProvider({ children }: ProviderProps) {
if (hasAppData[dataType] === null) {
setLoading(true);
try {
const hasDataResponse = (await checkIfAppHasData(dataType)) as HasDataResponse;
const hasDataT = hasDataResponse.hasData;
let hasDataT = false;
let indices: string | undefined = '';
switch (dataType) {
case 'ux':
case 'synthetics':
const resultUx = await getDataHandler(dataType)?.hasData();
hasDataT = Boolean(resultUx?.hasData);
indices = resultUx?.indices;
break;
case 'apm':
case 'mobile':
const resultApm = await getDataHandler('apm')?.hasData();
hasDataT = Boolean(resultApm?.hasData);
indices = resultApm?.indices['apm_oss.transactionIndices'];
break;
}
setHasAppData((prevState) => ({ ...prevState, [dataType]: hasDataT }));
if (hasDataT || hasAppData?.[dataType]) {
if (hasDataT && indices) {
const obsvIndexP = new ObservabilityIndexPatterns(data);
const indPattern = await obsvIndexP.getIndexPattern(dataType, hasDataResponse.indices);
const indPattern = await obsvIndexP.getIndexPattern(dataType, indices);
setIndexPatterns((prevState) => ({ ...prevState, [dataType]: indPattern }));
}

View file

@ -15,6 +15,7 @@ import { useSeriesStorage } from '../../hooks/use_series_storage';
export const dataTypes: Array<{ id: AppDataType; label: string }> = [
{ id: 'synthetics', label: 'Synthetic Monitoring' },
{ id: 'ux', label: 'User Experience (RUM)' },
{ id: 'mobile', label: 'Mobile Experience' },
// { id: 'infra_logs', label: 'Logs' },
// { id: 'infra_metrics', label: 'Metrics' },
// { id: 'apm', label: 'APM' },

View file

@ -20,8 +20,10 @@ export function ReportFilters({
<SeriesFilter
series={dataViewSeries}
defaultFilters={dataViewSeries.defaultFilters}
filters={dataViewSeries.filters}
seriesId={seriesId}
isNew={true}
labels={dataViewSeries.labels}
/>
);
}

View file

@ -18,16 +18,27 @@ import { ReportBreakdowns } from './columns/report_breakdowns';
import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { getDefaultConfigs } from '../configurations/default_configs';
import {
CORE_WEB_VITALS_LABEL,
DEVICE_DISTRIBUTION_LABEL,
KPI_OVER_TIME_LABEL,
PERF_DIST_LABEL,
} from '../configurations/constants/labels';
export const ReportTypes: Record<AppDataType, Array<{ id: ReportViewTypeId; label: string }>> = {
synthetics: [
{ id: 'kpi', label: 'KPI over time' },
{ id: 'dist', label: 'Performance distribution' },
{ id: 'kpi', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', label: PERF_DIST_LABEL },
],
ux: [
{ id: 'kpi', label: 'KPI over time' },
{ id: 'dist', label: 'Performance distribution' },
{ id: 'cwv', label: 'Core Web Vitals' },
{ id: 'kpi', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', label: PERF_DIST_LABEL },
{ id: 'cwv', label: CORE_WEB_VITALS_LABEL },
],
mobile: [
{ id: 'kpi', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', label: PERF_DIST_LABEL },
{ id: 'mdd', label: DEVICE_DISTRIBUTION_LABEL },
],
apm: [],
infra_logs: [],

View file

@ -22,6 +22,7 @@ describe('FilterExpanded', function () {
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
filters={[]}
/>,
{ initSeries }
);
@ -38,6 +39,7 @@ describe('FilterExpanded', function () {
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
filters={[]}
/>,
{ initSeries }
);
@ -64,6 +66,7 @@ describe('FilterExpanded', function () {
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
filters={[]}
/>,
{ initSeries }
);
@ -90,6 +93,7 @@ describe('FilterExpanded', function () {
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
filters={[]}
/>,
{ initSeries }
);

View file

@ -6,17 +6,21 @@
*/
import React, { useState, Fragment } from 'react';
import { EuiFieldSearch, EuiSpacer, EuiButtonEmpty, EuiFilterGroup } from '@elastic/eui';
import { EuiFieldSearch, EuiSpacer, EuiButtonEmpty, EuiFilterGroup, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { rgba } from 'polished';
import { i18n } from '@kbn/i18n';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { map } from 'lodash';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { UrlFilter } from '../../types';
import { DataSeries, UrlFilter } from '../../types';
import { FilterValueButton } from './filter_value_btn';
import { useValuesList } from '../../../../../hooks/use_values_list';
import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
import { ESFilter } from '../../../../../../../../../typings/elasticsearch';
import { PersistableFilter } from '../../../../../../../lens/common';
import { ExistsFilter } from '../../../../../../../../../src/plugins/data/common/es_query/filters';
interface Props {
seriesId: string;
@ -25,9 +29,18 @@ interface Props {
isNegated?: boolean;
goBack: () => void;
nestedField?: string;
filters: DataSeries['filters'];
}
export function FilterExpanded({ seriesId, field, label, goBack, nestedField, isNegated }: Props) {
export function FilterExpanded({
seriesId,
field,
label,
goBack,
nestedField,
isNegated,
filters: defaultFilters,
}: Props) {
const { indexPattern } = useAppIndexPatternContext();
const [value, setValue] = useState('');
@ -38,12 +51,25 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is
const series = getSeries(seriesId);
const queryFilters: ESFilter[] = [];
defaultFilters?.forEach((qFilter: PersistableFilter | ExistsFilter) => {
if (qFilter.query) {
queryFilters.push(qFilter.query);
}
const asExistFilter = qFilter as ExistsFilter;
if (asExistFilter?.exists) {
queryFilters.push(asExistFilter.exists as QueryDslQueryContainer);
}
});
const { values, loading } = useValuesList({
query: value,
indexPatternTitle: indexPattern?.title,
sourceField: field,
time: series.time,
keepHistory: true,
filters: queryFilters,
});
const filters = series?.filters ?? [];
@ -73,6 +99,13 @@ export function FilterExpanded({ seriesId, field, label, goBack, nestedField, is
/>
<EuiSpacer size="s" />
<ListWrapper>
{displayValues.length === 0 && !loading && (
<EuiText>
{i18n.translate('xpack.observability.filters.expanded.noFilter', {
defaultMessage: 'No filters found.',
})}
</EuiText>
)}
{displayValues.map((opt) => (
<Fragment key={opt}>
<EuiFilterGroup fullWidth={true} color="primary">

View file

@ -24,8 +24,10 @@ import { useSeriesStorage } from '../../hooks/use_series_storage';
interface Props {
seriesId: string;
defaultFilters: DataSeries['defaultFilters'];
filters: DataSeries['filters'];
series: DataSeries;
isNew?: boolean;
labels?: Record<string, string>;
}
export interface Field {
@ -35,21 +37,28 @@ export interface Field {
isNegated?: boolean;
}
export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: Props) {
export function SeriesFilter({
series,
isNew,
seriesId,
defaultFilters = [],
filters,
labels,
}: Props) {
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
const [selectedField, setSelectedField] = useState<Field | undefined>();
const options: Field[] = defaultFilters.map((field) => {
if (typeof field === 'string') {
return { label: FieldLabels[field], field };
return { label: labels?.[field] ?? FieldLabels[field], field };
}
return {
field: field.field,
nested: field.nested,
isNegated: field.isNegated,
label: FieldLabels[field.field],
label: labels?.[field.field] ?? FieldLabels[field.field],
};
});
@ -102,6 +111,7 @@ export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: P
goBack={() => {
setSelectedField(undefined);
}}
filters={filters}
/>
) : null;

View file

@ -49,7 +49,12 @@ export function SeriesEditor() {
field: 'defaultFilters',
width: '15%',
render: (defaultFilters: string[], { id, seriesConfig }: EditItem) => (
<SeriesFilter defaultFilters={defaultFilters} seriesId={id} series={seriesConfig} />
<SeriesFilter
defaultFilters={defaultFilters}
seriesId={id}
series={seriesConfig}
filters={seriesConfig.filters}
/>
),
},
{

View file

@ -23,6 +23,7 @@ export const ReportViewTypes = {
dist: 'data-distribution',
kpi: 'kpi-over-time',
cwv: 'core-web-vitals',
mdd: 'mobile-device-distribution',
} as const;
type ValueOf<T> = T[keyof T];
@ -45,8 +46,9 @@ export interface ReportDefinition {
field?: string;
label: string;
description?: string;
columnType?: 'range' | 'operation' | 'FILTER_RECORDS';
columnType?: 'range' | 'operation' | 'FILTER_RECORDS' | 'TERMS_COLUMN';
columnFilters?: ColumnFilter[];
timeScale?: string;
}>;
}
@ -94,15 +96,15 @@ export interface ConfigProps {
indexPattern: IIndexPattern;
}
export type AppDataType = 'synthetics' | 'ux' | 'infra_logs' | 'infra_metrics' | 'apm';
export type AppDataType = 'synthetics' | 'ux' | 'infra_logs' | 'infra_metrics' | 'apm' | 'mobile';
type FormatType = 'duration' | 'number';
type FormatType = 'duration' | 'number' | 'bytes' | 'percent';
type InputFormat = 'microseconds' | 'milliseconds' | 'seconds';
type OutputFormat = 'asSeconds' | 'asMilliseconds' | 'humanize' | 'humanizePrecise';
export interface FieldFormatParams {
inputFormat: InputFormat;
outputFormat: OutputFormat;
inputFormat?: InputFormat;
outputFormat?: OutputFormat;
outputPrecision?: number;
showSuffix?: boolean;
useShortSuffix?: boolean;

View file

@ -23,6 +23,7 @@ const appFieldFormats: Record<AppDataType, FieldFormat[] | null> = {
ux: rumFieldFormats,
apm: apmFieldFormats,
synthetics: syntheticsFieldFormats,
mobile: apmFieldFormats,
};
function getFieldFormatsForApp(app: AppDataType) {
@ -35,6 +36,7 @@ export const indexPatternList: Record<AppDataType, string> = {
ux: 'rum_static_index_pattern_id',
infra_logs: 'infra_logs_static_index_pattern_id',
infra_metrics: 'infra_metrics_static_index_pattern_id',
mobile: 'mobile_static_index_pattern_id',
};
const appToPatternMap: Record<AppDataType, string> = {
@ -43,6 +45,7 @@ const appToPatternMap: Record<AppDataType, string> = {
ux: '(rum-data-view)*',
infra_logs: '',
infra_metrics: '',
mobile: '(mobile-data-view)*',
};
const getAppIndicesWithPattern = (app: AppDataType, indices: string) => {
@ -124,6 +127,7 @@ export class ObservabilityIndexPatterns {
if (!this.data) {
throw new Error('data is not defined');
}
try {
const indexPatternId = getAppIndexPatternId(app, indices);
const indexPatternTitle = getAppIndicesWithPattern(app, indices);

View file

@ -5,7 +5,6 @@
* 2.0.
*/
// import { act, getByText } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { CoreStart } from 'kibana/public';
import React from 'react';
@ -19,10 +18,17 @@ import * as pluginContext from '../hooks/use_plugin_context';
import { PluginContextValue } from './plugin_context';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { ApmIndicesConfig } from '../../common/typings';
import { act } from '@testing-library/react';
const relativeStart = '2020-10-08T06:00:00.000Z';
const relativeEnd = '2020-10-08T07:00:00.000Z';
const sampleAPMIndices = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'apm_oss.transactionIndices': 'apm-*',
} as ApmIndicesConfig;
function wrapper({ children }: { children: React.ReactElement }) {
const history = createMemoryHistory();
return (
@ -76,17 +82,18 @@ describe('HasDataContextProvider', () => {
it('hasAnyData returns false and all apps return undefined', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toMatchObject({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: { hasData: undefined, status: 'success' },
synthetics: { hasData: undefined, status: 'success' },
infra_logs: { hasData: undefined, status: 'success' },
@ -105,16 +112,16 @@ describe('HasDataContextProvider', () => {
describe('all apps return false', () => {
beforeAll(() => {
registerApps([
{ appName: 'apm', hasData: async () => false },
{ appName: 'apm', hasData: async () => ({ hasData: false }) },
{ appName: 'infra_logs', hasData: async () => false },
{ appName: 'infra_metrics', hasData: async () => false },
{
appName: 'synthetics',
hasData: async () => ({ hasData: false, indices: 'heartbeat-*, synthetics-*' }),
hasData: async () => ({ hasData: false }),
},
{
appName: 'ux',
hasData: async () => ({ hasData: false, serviceName: undefined, indices: 'apm-*' }),
hasData: async () => ({ hasData: false }),
},
]);
});
@ -124,29 +131,28 @@ describe('HasDataContextProvider', () => {
it('hasAnyData returns false and all apps return false', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: { hasData: false, status: 'success' },
synthetics: {
hasData: {
hasData: false,
indices: 'heartbeat-*, synthetics-*',
},
hasData: false,
status: 'success',
},
infra_logs: { hasData: false, status: 'success' },
infra_metrics: { hasData: false, status: 'success' },
ux: {
hasData: { hasData: false, serviceName: undefined, indices: 'apm-*' },
hasData: false,
status: 'success',
},
alert: { hasData: [], status: 'success' },
@ -162,7 +168,7 @@ describe('HasDataContextProvider', () => {
describe('at least one app returns true', () => {
beforeAll(() => {
registerApps([
{ appName: 'apm', hasData: async () => true },
{ appName: 'apm', hasData: async () => ({ hasData: true }) },
{ appName: 'infra_logs', hasData: async () => false },
{ appName: 'infra_metrics', hasData: async () => false },
{
@ -181,29 +187,30 @@ describe('HasDataContextProvider', () => {
it('hasAnyData returns true apm returns true and all other apps return false', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: { hasData: true, status: 'success' },
synthetics: {
hasData: {
hasData: false,
indices: 'heartbeat-*, synthetics-*',
},
hasData: false,
indices: 'heartbeat-*, synthetics-*',
status: 'success',
},
infra_logs: { hasData: false, status: 'success' },
infra_metrics: { hasData: false, status: 'success' },
ux: {
hasData: { hasData: false, serviceName: undefined, indices: 'apm-*' },
hasData: false,
indices: 'apm-*',
status: 'success',
},
alert: { hasData: [], status: 'success' },
@ -219,7 +226,7 @@ describe('HasDataContextProvider', () => {
describe('all apps return true', () => {
beforeAll(() => {
registerApps([
{ appName: 'apm', hasData: async () => true },
{ appName: 'apm', hasData: async () => ({ hasData: true }) },
{ appName: 'infra_logs', hasData: async () => true },
{ appName: 'infra_metrics', hasData: async () => true },
{
@ -238,32 +245,34 @@ describe('HasDataContextProvider', () => {
it('hasAnyData returns true and all apps return true', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: {
hasData: true,
status: 'success',
},
synthetics: {
hasData: {
hasData: true,
indices: 'heartbeat-*, synthetics-*',
},
hasData: true,
indices: 'heartbeat-*, synthetics-*',
status: 'success',
},
infra_logs: { hasData: true, status: 'success' },
infra_metrics: { hasData: true, status: 'success' },
ux: {
hasData: { hasData: true, serviceName: 'ux', indices: 'apm-*' },
hasData: true,
serviceName: 'ux',
indices: 'apm-*',
status: 'success',
},
alert: { hasData: [], status: 'success' },
@ -279,7 +288,9 @@ describe('HasDataContextProvider', () => {
describe('only apm is registered', () => {
describe('when apm returns true', () => {
beforeAll(() => {
registerApps([{ appName: 'apm', hasData: async () => true }]);
registerApps([
{ appName: 'apm', hasData: async () => ({ hasData: true, indices: sampleAPMIndices }) },
]);
});
afterAll(unregisterAll);
@ -289,18 +300,20 @@ describe('HasDataContextProvider', () => {
wrapper,
});
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
apm: { hasData: true, status: 'success' },
hasDataMap: {
apm: { hasData: true, indices: sampleAPMIndices, status: 'success' },
synthetics: { hasData: undefined, status: 'success' },
infra_logs: { hasData: undefined, status: 'success' },
infra_metrics: { hasData: undefined, status: 'success' },
@ -317,7 +330,12 @@ describe('HasDataContextProvider', () => {
describe('when apm returns false', () => {
beforeAll(() => {
registerApps([{ appName: 'apm', hasData: async () => false }]);
registerApps([
{
appName: 'apm',
hasData: async () => ({ indices: sampleAPMIndices, hasData: false }),
},
]);
});
afterAll(unregisterAll);
@ -327,18 +345,24 @@ describe('HasDataContextProvider', () => {
wrapper,
});
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
apm: { hasData: false, status: 'success' },
hasDataMap: {
apm: {
hasData: false,
indices: sampleAPMIndices,
status: 'success',
},
synthetics: { hasData: undefined, status: 'success' },
infra_logs: { hasData: undefined, status: 'success' },
infra_metrics: { hasData: undefined, status: 'success' },
@ -381,29 +405,31 @@ describe('HasDataContextProvider', () => {
it('hasAnyData returns true, apm is undefined and all other apps return true', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: { hasData: undefined, status: 'failure' },
synthetics: {
hasData: {
hasData: true,
indices: 'heartbeat-*, synthetics-*',
},
hasData: true,
indices: 'heartbeat-*, synthetics-*',
status: 'success',
},
infra_logs: { hasData: true, status: 'success' },
infra_metrics: { hasData: true, status: 'success' },
ux: {
hasData: { hasData: true, serviceName: 'ux', indices: 'apm-*' },
hasData: true,
serviceName: 'ux',
indices: 'apm-*',
status: 'success',
},
alert: { hasData: [], status: 'success' },
@ -457,17 +483,19 @@ describe('HasDataContextProvider', () => {
it('hasAnyData returns false and all apps return undefined', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: { hasData: undefined, status: 'failure' },
synthetics: { hasData: undefined, status: 'failure' },
infra_logs: { hasData: undefined, status: 'failure' },
@ -505,17 +533,19 @@ describe('HasDataContextProvider', () => {
it('returns all alerts available', async () => {
const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper });
expect(result.current).toEqual({
hasData: {},
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
forceUpdate: expect.any(String),
onRefreshTimeRange: expect.any(Function),
});
await waitForNextUpdate();
await act(async () => {
await waitForNextUpdate();
});
expect(result.current).toEqual({
hasData: {
hasDataMap: {
apm: { hasData: undefined, status: 'success' },
synthetics: { hasData: undefined, status: 'success' },
infra_logs: { hasData: undefined, status: 'success' },

View file

@ -14,17 +14,23 @@ import { FETCH_STATUS } from '../hooks/use_fetcher';
import { usePluginContext } from '../hooks/use_plugin_context';
import { useTimeRange } from '../hooks/use_time_range';
import { getObservabilityAlerts } from '../services/get_observability_alerts';
import { ObservabilityFetchDataPlugins, UXHasDataResponse } from '../typings/fetch_overview_data';
import { ObservabilityFetchDataPlugins } from '../typings/fetch_overview_data';
import { ApmIndicesConfig } from '../../common/typings';
type DataContextApps = ObservabilityFetchDataPlugins | 'alert';
export type HasDataMap = Record<
DataContextApps,
{ status: FETCH_STATUS; hasData?: boolean | UXHasDataResponse | Alert[] }
{
status: FETCH_STATUS;
hasData?: boolean | Alert[];
indices?: string | ApmIndicesConfig;
serviceName?: string;
}
>;
export interface HasDataContextValue {
hasData: Partial<HasDataMap>;
hasDataMap: Partial<HasDataMap>;
hasAnyData: boolean;
isAllRequestsComplete: boolean;
onRefreshTimeRange: () => void;
@ -40,7 +46,7 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
const [forceUpdate, setForceUpdate] = useState('');
const { absoluteStart, absoluteEnd } = useTimeRange();
const [hasData, setHasData] = useState<HasDataContextValue['hasData']>({});
const [hasDataMap, setHasDataMap] = useState<HasDataContextValue['hasDataMap']>({});
const isExploratoryView = useRouteMatch('/exploratory-view');
@ -49,23 +55,53 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
if (!isExploratoryView)
apps.forEach(async (app) => {
try {
if (app !== 'alert') {
const params =
app === 'ux'
? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
: undefined;
const result = await getDataHandler(app)?.hasData(params);
setHasData((prevState) => ({
const updateState = ({
hasData,
indices,
serviceName,
}: {
hasData?: boolean;
serviceName?: string;
indices?: string | ApmIndicesConfig;
}) => {
setHasDataMap((prevState) => ({
...prevState,
[app]: {
hasData: result,
hasData,
...(serviceName ? { serviceName } : {}),
...(indices ? { indices } : {}),
status: FETCH_STATUS.SUCCESS,
},
}));
};
switch (app) {
case 'ux':
const params = { absoluteTime: { start: absoluteStart, end: absoluteEnd } };
const resultUx = await getDataHandler(app)?.hasData(params);
updateState({
hasData: resultUx?.hasData,
indices: resultUx?.indices,
serviceName: resultUx?.serviceName as string,
});
break;
case 'synthetics':
const resultSy = await getDataHandler(app)?.hasData();
updateState({ hasData: resultSy?.hasData, indices: resultSy?.indices });
break;
case 'apm':
const resultApm = await getDataHandler(app)?.hasData();
updateState({ hasData: resultApm?.hasData, indices: resultApm?.indices });
break;
case 'infra_logs':
case 'infra_metrics':
const resultInfra = await getDataHandler(app)?.hasData();
updateState({ hasData: resultInfra });
break;
}
} catch (e) {
setHasData((prevState) => ({
setHasDataMap((prevState) => ({
...prevState,
[app]: {
hasData: undefined,
@ -83,7 +119,7 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
async function fetchAlerts() {
try {
const alerts = await getObservabilityAlerts({ core });
setHasData((prevState) => ({
setHasDataMap((prevState) => ({
...prevState,
alert: {
hasData: alerts,
@ -91,7 +127,7 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
},
}));
} catch (e) {
setHasData((prevState) => ({
setHasDataMap((prevState) => ({
...prevState,
alert: {
hasData: undefined,
@ -105,18 +141,18 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
}, [forceUpdate, core]);
const isAllRequestsComplete = apps.every((app) => {
const appStatus = hasData[app]?.status;
const appStatus = hasDataMap[app]?.status;
return appStatus !== undefined && appStatus !== FETCH_STATUS.LOADING;
});
const hasAnyData = (Object.keys(hasData) as ObservabilityFetchDataPlugins[]).some(
(app) => hasData[app]?.hasData === true
const hasAnyData = (Object.keys(hasDataMap) as ObservabilityFetchDataPlugins[]).some(
(app) => hasDataMap[app]?.hasData === true
);
return (
<HasDataContext.Provider
value={{
hasData,
hasDataMap,
hasAnyData,
isAllRequestsComplete,
forceUpdate,

View file

@ -7,6 +7,12 @@
import { registerDataHandler, getDataHandler } from './data_handler';
import moment from 'moment';
import { ApmIndicesConfig } from '../common/typings';
const sampleAPMIndices = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'apm_oss.transactionIndices': 'apm-*',
} as ApmIndicesConfig;
const params = {
absoluteTime: {
@ -23,7 +29,7 @@ const params = {
describe('registerDataHandler', () => {
const originalConsole = global.console;
beforeAll(() => {
// mocks console to avoid poluting the test output
// mocks console to avoid polluting the test output
global.console = ({ error: jest.fn() } as unknown) as typeof console;
});
@ -58,7 +64,7 @@ describe('registerDataHandler', () => {
},
};
},
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
it('registered data handler', () => {

View file

@ -24,32 +24,38 @@ describe('Home page', () => {
});
it('renders loading component while requests are not returned', () => {
jest
.spyOn(hasData, 'useHasData')
.mockImplementation(
() =>
({ hasData: {}, hasAnyData: false, isAllRequestsComplete: false } as HasDataContextValue)
);
jest.spyOn(hasData, 'useHasData').mockImplementation(
() =>
({
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: false,
} as HasDataContextValue)
);
const { getByText } = render(<HomePage />);
expect(getByText('Loading Observability')).toBeInTheDocument();
});
it('renders landing page', () => {
jest
.spyOn(hasData, 'useHasData')
.mockImplementation(
() =>
({ hasData: {}, hasAnyData: false, isAllRequestsComplete: true } as HasDataContextValue)
);
jest.spyOn(hasData, 'useHasData').mockImplementation(
() =>
({
hasDataMap: {},
hasAnyData: false,
isAllRequestsComplete: true,
} as HasDataContextValue)
);
render(<HomePage />);
expect(mockHistoryPush).toHaveBeenCalledWith({ pathname: '/landing' });
});
it('renders overview page', () => {
jest
.spyOn(hasData, 'useHasData')
.mockImplementation(
() =>
({ hasData: {}, hasAnyData: true, isAllRequestsComplete: false } as HasDataContextValue)
);
jest.spyOn(hasData, 'useHasData').mockImplementation(
() =>
({
hasDataMap: {},
hasAnyData: true,
isAllRequestsComplete: false,
} as HasDataContextValue)
);
render(<HomePage />);
expect(mockHistoryPush).toHaveBeenCalledWith({ pathname: '/overview' });
});

View file

@ -57,13 +57,13 @@ export function OverviewPage({ routeParams }: Props) {
const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]);
const { hasData, hasAnyData } = useHasData();
const { hasDataMap, hasAnyData } = useHasData();
if (hasAnyData === undefined) {
return <LoadingObservability />;
}
const alerts = (hasData.alert?.hasData as Alert[]) || [];
const alerts = (hasDataMap.alert?.hasData as Alert[]) || [];
const { refreshInterval = 10000, refreshPaused = true } = routeParams.query;

View file

@ -25,6 +25,7 @@ import { newsFeedFetchData } from './mock/news_feed.mock';
import { emptyResponse as emptyUptimeResponse, fetchUptimeData } from './mock/uptime.mock';
import { createObservabilityRuleTypeRegistryMock } from '../../rules/observability_rule_type_registry_mock';
import { KibanaPageTemplate } from '../../../../../../src/plugins/kibana_react/public';
import { ApmIndicesConfig } from '../../../common/typings';
function unregisterAll() {
unregisterDataHandler({ appName: 'apm' });
@ -33,6 +34,11 @@ function unregisterAll() {
unregisterDataHandler({ appName: 'synthetics' });
}
const sampleAPMIndices = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'apm_oss.transactionIndices': 'apm-*',
} as ApmIndicesConfig;
const withCore = makeDecorator({
name: 'withCore',
parameterName: 'core',
@ -177,7 +183,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => false,
hasData: async () => ({ hasData: false, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
@ -272,7 +278,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
return (
@ -289,7 +295,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
@ -321,7 +327,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
@ -355,7 +361,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'apm',
fetchData: fetchApmData,
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
@ -386,7 +392,7 @@ storiesOf('app/Overview', module)
registerDataHandler({
appName: 'apm',
fetchData: async () => emptyAPMResponse,
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',
@ -420,7 +426,7 @@ storiesOf('app/Overview', module)
fetchData: async () => {
throw new Error('Error fetching APM data');
},
hasData: async () => true,
hasData: async () => ({ hasData: true, indices: sampleAPMIndices }),
});
registerDataHandler({
appName: 'infra_logs',

View file

@ -7,6 +7,8 @@
import { ObservabilityApp } from '../../../typings/common';
import { UXMetrics } from '../../components/shared/core_web_vitals';
import { ApmIndicesConfig } from '../../../common/typings';
export interface Stat {
type: 'number' | 'percent' | 'bytesPerSecond';
value: number;
@ -34,11 +36,20 @@ export interface HasDataParams {
export interface HasDataResponse {
hasData: boolean;
indices: string;
}
export interface UXHasDataResponse extends HasDataResponse {
serviceName: string | number | undefined;
serviceName?: string | number;
indices?: string;
}
export interface SyntheticsHasDataResponse extends HasDataResponse {
indices: string;
}
export interface APMHasDataResponse {
hasData: boolean;
indices: ApmIndicesConfig;
}
export type FetchData<T extends FetchDataResponse = FetchDataResponse> = (
@ -134,9 +145,9 @@ export interface ObservabilityFetchDataResponse {
}
export interface ObservabilityHasDataResponse {
apm: boolean;
apm: APMHasDataResponse;
infra_metrics: boolean;
infra_logs: boolean;
synthetics: HasDataResponse;
synthetics: SyntheticsHasDataResponse;
ux: UXHasDataResponse;
}