[Infrastructure UI] Improve Hosts View page performance (#160406)

## Summary

This PR focuses on enhancing the performance of the Hosts View page to
improve the user experience. The main performance detractors were
identified as multiple unnecessary calls to the data view's toSpec()
function during chart rendering and excessive re-rendering of charts
whenever there were changes on the page.

To address these issues, the following optimizations were implemented:

### Caching the toSpec() Result

Previously, the toSpec() function was called multiple times for each
chart, resulting in heavy computation. To optimize this, caching
mechanism was introduced that stores the result of the toSpec()
function. This prevents unnecessary and repetitive computations,
significantly improving the chart attributes generation performance.

_Before_
<img width="900" alt="image"
src="c9ed1a0e-c99b-4a09-a091-562739f69abb">

_After_
<img width="900" alt="image"
src="7d390f22-2696-48d5-bb41-df849abd6f1b">

To optimize the chart attributes generation, the `toSpec()` result is
now cached. Preventing this heavy operation from performing
unnecessarily

### Reducing Lens Re-renders

In the previous implementation, metric charts and KPIs were re-rendered
excessively whenever there were changes on the page, impacting
performance. By implementing memoization techniques, unnecessary
re-renders were solved, resulting in smoother and faster rendering of
metric charts and KPIs.

**_Metric charts_**

_Before_
<img width="900" alt="image"
src="fdb3ac05-1c50-4b2d-bde6-68a9d1601444">


_After_
<img width="900" alt="image"
src="c2c5ade9-96df-4c41-be2a-d5323e8fc44d">


**_KPIs_**

_Before_
<img width="900" alt="image"
src="75abe5ae-7c2e-4cee-b816-07f31e542d7f">

_After_
<img width="900" alt="image"
src="bdf32738-b11d-4da8-b0d7-6792de9ed0a7">


### Overall performance

By implementing the above optimizations, the overall performance of the
Hosts View page has been greatly improved. The changes have
significantly reduced delays and improved the responsiveness of buttons,
checkboxes, and tab interactions.

_Before_
<img width="900" alt="image"
src="c390cdc8-4421-46ee-9ea0-dc28ef1efd97">

_After_
<img width="900" alt="image"
src="771ee718-fd58-49e0-a377-ce1e187cec3a">


### How to test

- Start a local Kibana instance
- Navigate to `Infrastructure > Hosts`
- Click through buttons, checkboxes tabs and check if there are delays
for the click actions to be performed

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2023-06-30 10:19:35 +02:00 committed by GitHub
parent efac02dc32
commit 079763bfff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 500 additions and 389 deletions

View file

@ -19,4 +19,4 @@ export type {
export { hostLensFormulas, visualizationTypes } from './constants';
export { buildLensAttributes } from './lens/build_lens_attributes';
export { LensAttributesBuilder } from './lens/lens_attributes_builder';

View file

@ -1,29 +0,0 @@
/*
* 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 type { LensAttributes, TVisualization, VisualizationAttributes } from '../types';
export const buildLensAttributes = <T extends VisualizationAttributes<TVisualization>>(
visualization: T
): LensAttributes => {
return {
title: visualization.getTitle(),
visualizationType: visualization.getVisualizationType(),
references: visualization.getReferences(),
state: {
datasourceStates: {
formBased: {
layers: visualization.getLayers(),
},
},
internalReferences: visualization.getReferences(),
filters: visualization.getFilters(),
query: { language: 'kuery', query: '' },
visualization: visualization.getVisualizationState(),
adHocDataViews: visualization.getAdhocDataView(),
},
};
};

View file

@ -0,0 +1,50 @@
/*
* 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 { DataViewSpec, DataView } from '@kbn/data-plugin/common';
export const DEFAULT_AD_HOC_DATA_VIEW_ID = 'infra_lens_ad_hoc_default';
export class DataViewCache {
private static instance: DataViewCache;
private cache = new Map<string, DataViewSpec>();
private capacity: number;
private constructor(capacity: number) {
this.capacity = capacity;
this.cache = new Map<string, DataViewSpec>();
}
public static getInstance(capacity: number = 10): DataViewCache {
if (!DataViewCache.instance) {
DataViewCache.instance = new DataViewCache(capacity);
}
return DataViewCache.instance;
}
public getSpec(dataView: DataView): DataViewSpec {
const key = dataView.id ?? DEFAULT_AD_HOC_DATA_VIEW_ID;
const spec = this.cache.get(key);
if (!spec) {
const result = dataView.toSpec();
this.setSpec(key, result);
return result;
}
return spec;
}
private setSpec(key: string, value: DataViewSpec): void {
if (this.cache.size >= this.capacity) {
const lruKey = this.cache.keys().next().value;
this.cache.delete(lruKey);
}
this.cache.set(key, value);
}
}

View file

@ -0,0 +1,45 @@
/*
* 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 type {
LensAttributes,
TVisualization,
VisualizationAttributes,
VisualizationAttributesBuilder,
} from '../types';
import { DataViewCache } from './data_view_cache';
import { getAdhocDataView } from './utils';
export class LensAttributesBuilder<T extends VisualizationAttributes<TVisualization>>
implements VisualizationAttributesBuilder
{
private dataViewCache: DataViewCache;
constructor(private visualization: T) {
this.dataViewCache = DataViewCache.getInstance();
}
build(): LensAttributes {
return {
title: this.visualization.getTitle(),
visualizationType: this.visualization.getVisualizationType(),
references: this.visualization.getReferences(),
state: {
datasourceStates: {
formBased: {
layers: this.visualization.getLayers(),
},
},
internalReferences: this.visualization.getReferences(),
filters: this.visualization.getFilters(),
query: { language: 'kuery', query: '' },
visualization: this.visualization.getVisualizationState(),
adHocDataViews: getAdhocDataView(
this.dataViewCache.getSpec(this.visualization.getDataView())
),
},
};
}
}

View file

@ -89,10 +89,10 @@ export const getDefaultReferences = (
];
};
export const getAdhocDataView = (dataView: DataView): Record<string, DataViewSpec> => {
export const getAdhocDataView = (dataViewSpec: DataViewSpec): Record<string, DataViewSpec> => {
return {
[dataView.id ?? DEFAULT_AD_HOC_DATA_VIEW_ID]: {
...dataView.toSpec(),
[dataViewSpec.id ?? DEFAULT_AD_HOC_DATA_VIEW_ID]: {
...dataViewSpec,
},
};
};

View file

@ -12,11 +12,10 @@ import type {
XYState,
} from '@kbn/lens-plugin/public';
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { Filter } from '@kbn/es-query';
import {
DEFAULT_LAYER_ID,
getAdhocDataView,
getBreakdownColumn,
getDefaultReferences,
getHistogramColumn,
@ -101,8 +100,8 @@ export class LineChart implements VisualizationAttributes<XYState> {
];
}
getAdhocDataView(): Record<string, DataViewSpec> {
return getAdhocDataView(this.dataView);
getDataView(): DataView {
return this.dataView;
}
getTitle(): string {

View file

@ -12,14 +12,9 @@ import {
PersistedIndexPatternLayer,
} from '@kbn/lens-plugin/public';
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
import type { DataView, DataViewSpec } from '@kbn/data-views-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Filter } from '@kbn/es-query';
import {
DEFAULT_LAYER_ID,
getAdhocDataView,
getDefaultReferences,
getHistogramColumn,
} from '../utils';
import { DEFAULT_LAYER_ID, getDefaultReferences, getHistogramColumn } from '../utils';
import type {
VisualizationAttributes,
@ -147,8 +142,8 @@ export class MetricChart implements VisualizationAttributes<MetricVisualizationS
];
}
getAdhocDataView(): Record<string, DataViewSpec> {
return getAdhocDataView(this.dataView);
getDataView(): DataView {
return this.dataView;
}
getTitle(): string {

View file

@ -6,7 +6,7 @@
*/
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
import type { DataViewSpec } from '@kbn/data-views-plugin/common';
import type { DataView } from '@kbn/data-views-plugin/common';
import { DataViewBase, Filter } from '@kbn/es-query';
import {
FormBasedPersistedState,
@ -53,7 +53,11 @@ export interface VisualizationAttributes<T extends TVisualization> {
getVisualizationState(): T;
getReferences(): SavedObjectReference[];
getFilters(): Filter[];
getAdhocDataView(): Record<string, DataViewSpec>;
getDataView(): DataView;
}
export interface VisualizationAttributesBuilder {
build(): LensAttributes;
}
export type Formula = Parameters<FormulaPublicApi['insertOrReplaceFormulaColumn']>[1];

View file

@ -209,6 +209,6 @@ describe('useHostTable hook', () => {
],
});
expect(extraActions.openInLens).not.toBeNull();
expect(extraActions).toHaveLength(1);
});
});

View file

@ -5,29 +5,30 @@
* 2.0.
*/
import { useMemo } from 'react';
import { useCallback } from 'react';
import type { DataView } from '@kbn/data-views-plugin/public';
import { Filter, Query, TimeRange } from '@kbn/es-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import type { Action, ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { i18n } from '@kbn/i18n';
import useAsync from 'react-use/lib/useAsync';
import { InfraClientSetupDeps } from '../types';
import {
buildLensAttributes,
HostsLensFormulas,
HostsLensMetricChartFormulas,
HostsLensLineChartFormulas,
LineChartOptions,
MetricChartOptions,
type HostsLensFormulas,
type HostsLensMetricChartFormulas,
type HostsLensLineChartFormulas,
type LineChartOptions,
type MetricChartOptions,
LensAttributesBuilder,
LensAttributes,
hostLensFormulas,
visualizationTypes,
} from '../common/visualizations';
import { useLazyRef } from './use_lazy_ref';
type Options = LineChartOptions | MetricChartOptions;
interface UseLensAttributesBaseParams<T extends HostsLensFormulas, O extends Options> {
dataView: DataView | undefined;
dataView?: DataView;
type: T;
options?: O;
}
@ -58,92 +59,99 @@ export const useLensAttributes = ({
const { navigateToPrefilledEditor } = lens;
const { value, error } = useAsync(lens.stateHelperApi, [lens]);
const { formula: formulaAPI } = value ?? {};
const lensChartConfig = hostLensFormulas[type];
const attributes = useMemo(() => {
const lensChartConfig = hostLensFormulas[type];
const Chart = visualizationTypes[visualizationType];
const attributes = useLazyRef(() => {
if (!dataView || !formulaAPI) {
return null;
}
const VisualizationType = visualizationTypes[visualizationType];
const visualizationAttributes = buildLensAttributes(
new VisualizationType(lensChartConfig, dataView, formulaAPI, options)
const builder = new LensAttributesBuilder(
new Chart(lensChartConfig, dataView, formulaAPI, options)
);
return visualizationAttributes;
}, [dataView, formulaAPI, options, visualizationType, lensChartConfig]);
return builder.build();
});
const injectFilters = ({
filters,
query = { language: 'kuery', query: '' },
}: {
filters: Filter[];
query?: Query;
}): LensAttributes | null => {
if (!attributes) {
return null;
}
return {
...attributes,
state: {
...attributes.state,
query,
filters: [...attributes.state.filters, ...filters],
},
};
};
const injectFilters = useCallback(
({
filters,
query = { language: 'kuery', query: '' },
}: {
filters: Filter[];
query?: Query;
}): LensAttributes | null => {
if (!attributes.current) {
return null;
}
return {
...attributes.current,
state: {
...attributes.current.state,
query,
filters: [...attributes.current.state.filters, ...filters],
},
};
},
[attributes]
);
const getExtraActions = ({
timeRange,
filters,
query,
}: {
timeRange: TimeRange;
filters: Filter[];
query?: Query;
}) => {
return {
openInLens: {
id: 'openInLens',
getDisplayName(_context: ActionExecutionContext): string {
return i18n.translate(
'xpack.infra.hostsViewPage.tabs.metricsCharts.actions.openInLines',
const openInLensAction = useCallback(
({ timeRange, filters, query }: { timeRange: TimeRange; filters: Filter[]; query?: Query }) =>
() => {
const injectedAttributes = injectFilters({ filters, query });
if (injectedAttributes) {
navigateToPrefilledEditor(
{
defaultMessage: 'Open in Lens',
id: '',
timeRange,
attributes: injectedAttributes,
},
{
openInNewTab: true,
}
);
},
getIconType(_context: ActionExecutionContext): string | undefined {
return 'visArea';
},
type: 'actionButton',
async isCompatible(_context: ActionExecutionContext): Promise<boolean> {
return true;
},
async execute(_context: ActionExecutionContext): Promise<void> {
const injectedAttributes = injectFilters({ filters, query });
if (injectedAttributes) {
navigateToPrefilledEditor(
{
id: '',
timeRange,
attributes: injectedAttributes,
},
{
openInNewTab: true,
}
);
}
},
order: 100,
}
},
};
};
[injectFilters, navigateToPrefilledEditor]
);
const getExtraActions = useCallback(
({ timeRange, filters, query }: { timeRange: TimeRange; filters: Filter[]; query?: Query }) => {
const openInLens = getOpenInLensAction(openInLensAction({ timeRange, filters, query }));
return [openInLens];
},
[openInLensAction]
);
const {
formula: { formula },
} = lensChartConfig;
return { formula, attributes, getExtraActions, error };
return { formula, attributes: attributes.current, getExtraActions, error };
};
const getOpenInLensAction = (onExecute: () => void): Action => {
return {
id: 'openInLens',
getDisplayName(_context: ActionExecutionContext): string {
return i18n.translate('xpack.infra.hostsViewPage.tabs.metricsCharts.actions.openInLines', {
defaultMessage: 'Open in Lens',
});
},
getIconType(_context: ActionExecutionContext): string | undefined {
return 'visArea';
},
type: 'actionButton',
async isCompatible(_context: ActionExecutionContext): Promise<boolean> {
return true;
},
async execute(_context: ActionExecutionContext): Promise<void> {
onExecute();
},
order: 100,
};
};

View file

@ -33,12 +33,17 @@ export const ChartLoader = ({
position="absolute"
css={css`
top: ${loadedOnce && hasTitle ? euiTheme.size.l : 0};
z-index: ${Number(euiTheme.levels.header) - 1};
`}
style={{ zIndex: Number(euiTheme.levels.header) - 1 }}
/>
)}
{loading && !loadedOnce ? (
<EuiFlexGroup style={style} justifyContent="center" alignItems="center">
<EuiFlexGroup
style={{ ...style, marginTop: hasTitle ? euiTheme.size.l : 0 }}
justifyContent="center"
alignItems="center"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiLoadingChart mono size="l" />
</EuiFlexItem>

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback, CSSProperties } from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
@ -23,99 +23,103 @@ export interface LensWrapperProps {
filters: Filter[];
extraActions: Action[];
lastReloadRequestTime?: number;
style?: React.CSSProperties;
style?: CSSProperties;
loading?: boolean;
hasTitle?: boolean;
onBrushEnd?: (data: BrushTriggerEvent['data']) => void;
onLoad?: () => void;
}
export const LensWrapper = ({
attributes,
dateRange,
filters,
id,
query,
extraActions,
style,
onBrushEnd,
lastReloadRequestTime,
loading = false,
hasTitle = false,
}: LensWrapperProps) => {
const intersectionRef = useRef(null);
const [loadedOnce, setLoadedOnce] = useState(false);
const [state, setState] = useState({
attributes,
lastReloadRequestTime,
query,
filters,
dateRange,
});
const {
services: { lens },
} = useKibanaContextForPlugin();
const { intersectedOnce, intersection } = useIntersectedOnce(intersectionRef, {
threshold: 1,
});
const EmbeddableComponent = lens.EmbeddableComponent;
useEffect(() => {
if ((intersection?.intersectionRatio ?? 0) === 1) {
setState({
attributes,
lastReloadRequestTime,
query,
filters,
dateRange,
});
}
}, [
export const LensWrapper = React.memo(
({
attributes,
dateRange,
filters,
intersection?.intersectionRatio,
lastReloadRequestTime,
id,
query,
]);
extraActions,
style,
onBrushEnd,
lastReloadRequestTime,
loading = false,
hasTitle = false,
}: LensWrapperProps) => {
const intersectionRef = useRef(null);
const [loadedOnce, setLoadedOnce] = useState(false);
const isReady = state.attributes && intersectedOnce;
const [state, setState] = useState({
attributes,
lastReloadRequestTime,
query,
filters,
dateRange,
});
return (
<div ref={intersectionRef}>
<ChartLoader
loading={loading || !isReady}
loadedOnce={loadedOnce}
style={style}
hasTitle={hasTitle}
>
{state.attributes && (
<EmbeddableComponent
id={id}
style={style}
attributes={state.attributes}
viewMode={ViewMode.VIEW}
timeRange={state.dateRange}
query={state.query}
filters={state.filters}
extraActions={extraActions}
lastReloadRequestTime={state.lastReloadRequestTime}
executionContext={{
type: 'infrastructure_observability_hosts_view',
name: id,
}}
onBrushEnd={onBrushEnd}
onLoad={() => {
if (!loadedOnce) {
setLoadedOnce(true);
}
}}
/>
)}
</ChartLoader>
</div>
);
};
const {
services: { lens },
} = useKibanaContextForPlugin();
const { intersectedOnce, intersection } = useIntersectedOnce(intersectionRef, {
threshold: 1,
});
const EmbeddableComponent = lens.EmbeddableComponent;
useEffect(() => {
if ((intersection?.intersectionRatio ?? 0) === 1) {
setState({
attributes,
lastReloadRequestTime,
query,
filters,
dateRange,
});
}
}, [
attributes,
dateRange,
filters,
intersection?.intersectionRatio,
lastReloadRequestTime,
query,
]);
const isReady = state.attributes && intersectedOnce;
const onLoad = useCallback(() => {
if (!loadedOnce) {
setLoadedOnce(true);
}
}, [loadedOnce]);
return (
<div ref={intersectionRef}>
<ChartLoader
loading={loading || !isReady}
loadedOnce={loadedOnce}
style={style}
hasTitle={hasTitle}
>
{state.attributes && (
<EmbeddableComponent
id={id}
style={style}
attributes={state.attributes}
viewMode={ViewMode.VIEW}
timeRange={state.dateRange}
query={state.query}
filters={state.filters}
extraActions={extraActions}
lastReloadRequestTime={state.lastReloadRequestTime}
executionContext={{
type: 'infrastructure_observability_hosts_view',
name: id,
}}
onBrushEnd={onBrushEnd}
onLoad={onLoad}
/>
)}
</ChartLoader>
</div>
);
}
);

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, CSSProperties } from 'react';
import { Chart, Metric, type MetricWNumber, type MetricWTrend } from '@elastic/charts';
import { EuiPanel, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
@ -15,59 +15,50 @@ export interface Props extends Pick<MetricWTrend, 'title' | 'color' | 'extra' |
loading: boolean;
value: number;
toolTip: React.ReactNode;
style?: CSSProperties;
['data-test-subj']?: string;
}
const MIN_HEIGHT = 150;
export const MetricChartWrapper = React.memo(
({ color, extra, id, loading, value, subtitle, title, toolTip, style, ...props }: Props) => {
const loadedOnce = useRef(false);
export const MetricChartWrapper = ({
color,
extra,
id,
loading,
value,
subtitle,
title,
toolTip,
...props
}: Props) => {
const loadedOnce = useRef(false);
useEffect(() => {
if (!loadedOnce.current && !loading) {
loadedOnce.current = true;
}
return () => {
loadedOnce.current = false;
};
}, [loading]);
useEffect(() => {
if (!loadedOnce.current && !loading) {
loadedOnce.current = true;
}
return () => {
loadedOnce.current = false;
const metricsData: MetricWNumber = {
title,
subtitle,
color,
extra,
value,
valueFormatter: (d: number) => d.toString(),
};
}, [loading]);
const metricsData: MetricWNumber = {
title,
subtitle,
color,
extra,
value,
valueFormatter: (d: number) => d.toString(),
};
return (
<EuiPanel hasShadow={false} paddingSize="none" {...props}>
<ChartLoader loading={loading} loadedOnce={loadedOnce.current} style={{ height: MIN_HEIGHT }}>
<EuiToolTip
className="eui-fullWidth"
delay="regular"
content={toolTip}
anchorClassName="eui-fullWidth"
>
<KPIChartStyled size={{ height: MIN_HEIGHT }}>
<Metric id={id} data={[[metricsData]]} />
</KPIChartStyled>
</EuiToolTip>
</ChartLoader>
</EuiPanel>
);
};
return (
<EuiPanel hasShadow={false} paddingSize="none" {...props}>
<ChartLoader loading={loading} loadedOnce={loadedOnce.current} style={style}>
<EuiToolTip
className="eui-fullWidth"
delay="regular"
content={toolTip}
anchorClassName="eui-fullWidth"
>
<KPIChartStyled size={style}>
<Metric id={id} data={[[metricsData]]} />
</KPIChartStyled>
</EuiToolTip>
</ChartLoader>
</EuiPanel>
);
}
);
const KPIChartStyled = styled(Chart)`
.echMetric {

View file

@ -13,6 +13,7 @@ import { TOOLTIP } from '../../translations';
import { type Props, MetricChartWrapper } from '../chart/metric_chart_wrapper';
import { TooltipContent } from '../metric_explanation/tooltip_content';
import { KPIChartProps } from './tile';
const HOSTS_CHART: Omit<Props, 'loading' | 'value' | 'toolTip'> = {
id: `metric-hostCount`,
@ -23,7 +24,7 @@ const HOSTS_CHART: Omit<Props, 'loading' | 'value' | 'toolTip'> = {
['data-test-subj']: 'hostsViewKPI-hostsCount',
};
export const HostsTile = () => {
export const HostsTile = ({ style }: Pick<KPIChartProps, 'style'>) => {
const { data: hostCountData, isRequestRunning: hostCountLoading } = useHostCountContext();
const { searchCriteria } = useUnifiedSearchContext();
@ -41,6 +42,7 @@ export const HostsTile = () => {
return (
<MetricChartWrapper
{...HOSTS_CHART}
style={style}
value={hostCountData?.count.value ?? 0}
subtitle={getSubtitle()}
toolTip={

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { CSSProperties } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -14,8 +14,13 @@ import { HostCountProvider } from '../../hooks/use_host_count';
import { TOOLTIP } from '../../translations';
import { HostsTile } from './hosts_tile';
import { HostMetricsDocsLink } from '../metric_explanation/host_metrics_docs_link';
import { KPI_CHART_MIN_HEIGHT } from '../../constants';
const KPI_CHARTS: Array<Omit<KPIChartProps, 'loading' | 'subtitle'>> = [
const lensStyle: CSSProperties = {
height: KPI_CHART_MIN_HEIGHT,
};
const KPI_CHARTS: Array<Omit<KPIChartProps, 'loading' | 'subtitle' | 'style'>> = [
{
type: 'cpuUsage',
trendLine: true,
@ -68,11 +73,11 @@ export const KPIGrid = () => {
data-test-subj="hostsViewKPIGrid"
>
<EuiFlexItem>
<HostsTile />
<HostsTile style={lensStyle} />
</EuiFlexItem>
{KPI_CHARTS.map(({ ...chartProp }) => (
<EuiFlexItem key={chartProp.type}>
<Tile {...chartProp} />
<Tile {...chartProp} style={lensStyle} />
</EuiFlexItem>
))}
</EuiFlexGroup>

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { CSSProperties, useMemo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { BrushTriggerEvent } from '@kbn/charts-plugin/public';
@ -18,6 +18,7 @@ import {
EuiToolTip,
} from '@elastic/eui';
import styled from 'styled-components';
import { Action } from '@kbn/ui-actions-plugin/public';
import { useLensAttributes } from '../../../../../hooks/use_lens_attributes';
import { useMetricsDataViewContext } from '../../hooks/use_data_view';
import { useUnifiedSearchContext } from '../../hooks/use_unified_search';
@ -28,6 +29,7 @@ import { buildCombinedHostsFilter } from '../../utils';
import { useHostCountContext } from '../../hooks/use_host_count';
import { useAfterLoadedState } from '../../hooks/use_after_loaded_state';
import { TooltipContent } from '../metric_explanation/tooltip_content';
import { KPI_CHART_MIN_HEIGHT } from '../../constants';
export interface KPIChartProps {
title: string;
@ -37,15 +39,15 @@ export interface KPIChartProps {
type: HostsLensMetricChartFormulas;
decimals?: number;
toolTip: string;
style?: CSSProperties;
}
const MIN_HEIGHT = 150;
export const Tile = ({
title,
type,
backgroundColor,
toolTip,
style,
decimals = 1,
trendLine = false,
}: KPIChartProps) => {
@ -91,23 +93,24 @@ export const Tile = ({
];
}, [hostNodes, dataView]);
const extraActionOptions = getExtraActions({
timeRange: searchCriteria.dateRange,
filters,
});
const handleBrushEnd = ({ range }: BrushTriggerEvent['data']) => {
const [min, max] = range;
onSubmit({
dateRange: {
from: new Date(min).toISOString(),
to: new Date(max).toISOString(),
mode: 'absolute',
},
});
};
const handleBrushEnd = useCallback(
({ range }: BrushTriggerEvent['data']) => {
const [min, max] = range;
onSubmit({
dateRange: {
from: new Date(min).toISOString(),
to: new Date(max).toISOString(),
mode: 'absolute',
},
});
},
[onSubmit]
);
const loading = hostsLoading || !attributes || hostCountLoading;
// prevents requestTs and serchCriteria states from reloading the chart
// we want it to reload only once the table has finished loading
const { afterLoadedState } = useAfterLoadedState(loading, {
attributes,
lastReloadRequestTime: requestTs,
@ -115,16 +118,24 @@ export const Tile = ({
filters,
});
const extraActions: Action[] = useMemo(
() =>
getExtraActions({
timeRange: afterLoadedState.dateRange,
filters,
}),
[afterLoadedState.dateRange, filters, getExtraActions]
);
return (
<EuiPanelStyled
hasShadow={false}
paddingSize={error ? 'm' : 'none'}
style={{ minHeight: MIN_HEIGHT }}
data-test-subj={`hostsViewKPI-${type}`}
>
{error ? (
<EuiFlexGroup
style={{ height: MIN_HEIGHT, alignContent: 'center' }}
style={{ minHeight: '100%', alignContent: 'center' }}
gutterSize="xs"
justifyContent="center"
alignItems="center"
@ -148,17 +159,19 @@ export const Tile = ({
content={<TooltipContent formula={formula} description={toolTip} />}
anchorClassName="eui-fullWidth"
>
<LensWrapper
id={`hostsViewKPIGrid${type}Tile`}
attributes={afterLoadedState.attributes}
style={{ height: MIN_HEIGHT }}
extraActions={[extraActionOptions.openInLens]}
lastReloadRequestTime={afterLoadedState.lastReloadRequestTime}
dateRange={afterLoadedState.dateRange}
filters={afterLoadedState.filters}
onBrushEnd={handleBrushEnd}
loading={loading}
/>
<div>
<LensWrapper
id={`hostsViewKPIGrid${type}Tile`}
attributes={afterLoadedState.attributes}
style={style}
extraActions={extraActions}
lastReloadRequestTime={afterLoadedState.lastReloadRequestTime}
dateRange={afterLoadedState.dateRange}
filters={afterLoadedState.filters}
onBrushEnd={handleBrushEnd}
loading={loading}
/>
</div>
</EuiToolTip>
)}
</EuiPanelStyled>
@ -166,6 +179,7 @@ export const Tile = ({
};
const EuiPanelStyled = styled(EuiPanel)`
min-height: ${KPI_CHART_MIN_HEIGHT};
.echMetric {
border-radius: ${({ theme }) => theme.eui.euiBorderRadius};
pointer-events: none;

View file

@ -17,55 +17,52 @@ interface Props extends Pick<HTMLAttributes<HTMLDivElement>, 'style'> {
showDocumentationLink?: boolean;
}
export const TooltipContent = ({
description,
formula,
showDocumentationLink = false,
style,
}: Props) => {
return (
<EuiText size="xs" style={style}>
<p>{description}</p>
{formula && (
<p>
<strong>
export const TooltipContent = React.memo(
({ description, formula, showDocumentationLink = false, style }: Props) => {
return (
<EuiText size="xs" style={style}>
<p>{description}</p>
{formula && (
<p>
<strong>
<FormattedMessage
id="xpack.infra.hostsViewPage.table.tooltip.formula"
defaultMessage="Formula Calculation:"
/>
</strong>
<br />
<code
css={css`
word-break: break-word;
`}
>
{formula}
</code>
</p>
)}
{showDocumentationLink && (
<p>
<FormattedMessage
id="xpack.infra.hostsViewPage.table.tooltip.formula"
defaultMessage="Formula Calculation:"
id="xpack.infra.hostsViewPage.table.tooltip.documentationLabel"
defaultMessage="See {documentation} for more information"
values={{
documentation: (
<EuiLink
data-test-subj="hostsViewTooltipDocumentationLink"
href={HOST_METRICS_DOC_HREF}
target="_blank"
>
<FormattedMessage
id="xpack.infra.hostsViewPage.table.tooltip.documentationLink"
defaultMessage="documentation"
/>
</EuiLink>
),
}}
/>
</strong>
<br />
<code
css={css`
word-break: break-word;
`}
>
{formula}
</code>
</p>
)}
{showDocumentationLink && (
<p>
<FormattedMessage
id="xpack.infra.hostsViewPage.table.tooltip.documentationLabel"
defaultMessage="See {documentation} for more information"
values={{
documentation: (
<EuiLink
data-test-subj="hostsViewTooltipDocumentationLink"
href={HOST_METRICS_DOC_HREF}
target="_blank"
>
<FormattedMessage
id="xpack.infra.hostsViewPage.table.tooltip.documentationLink"
defaultMessage="documentation"
/>
</EuiLink>
),
}}
/>
</p>
)}
</EuiText>
);
};
</p>
)}
</EuiText>
);
}
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import {
EuiButtonGroup,
EuiButtonGroupOptionProps,
@ -27,10 +27,13 @@ interface Props {
export const LimitOptions = ({ limit, onChange }: Props) => {
const [idSelected, setIdSelected] = useState(limit as number);
const onSelected = (_id: string, value: number) => {
setIdSelected(value);
onChange(value);
};
const onSelected = useCallback(
(_id: string, value: number) => {
setIdSelected(value);
onChange(value);
},
[onChange]
);
return (
<EuiFlexGroup
direction="row"

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { CSSProperties, useCallback, useMemo } from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { BrushTriggerEvent } from '@kbn/charts-plugin/public';
import {
@ -26,6 +26,7 @@ import { buildCombinedHostsFilter } from '../../../utils';
import { useHostsTableContext } from '../../../hooks/use_hosts_table';
import { LensWrapper } from '../../chart/lens_wrapper';
import { useAfterLoadedState } from '../../../hooks/use_after_loaded_state';
import { METRIC_CHART_MIN_HEIGHT } from '../../../constants';
export interface MetricChartProps {
title: string;
@ -34,7 +35,9 @@ export interface MetricChartProps {
render?: boolean;
}
const MIN_HEIGHT = 300;
const lensStyle: CSSProperties = {
height: METRIC_CHART_MIN_HEIGHT,
};
export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) => {
const { euiTheme } = useEuiTheme();
@ -43,7 +46,7 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) =>
const { requestTs, loading } = useHostsViewContext();
const { currentPage } = useHostsTableContext();
// prevents updates on requestTs and serchCriteria states from relaoding the chart
// prevents requestTs and serchCriteria states from reloading the chart
// we want it to reload only once the table has finished loading
const { afterLoadedState } = useAfterLoadedState(loading, {
lastReloadRequestTime: requestTs,
@ -70,23 +73,28 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) =>
];
}, [currentPage, dataView]);
const extraActionOptions = getExtraActions({
timeRange: afterLoadedState.dateRange,
filters,
});
const extraActions: Action[] = useMemo(
() =>
getExtraActions({
timeRange: afterLoadedState.dateRange,
filters,
}),
[afterLoadedState.dateRange, filters, getExtraActions]
);
const extraActions: Action[] = [extraActionOptions.openInLens];
const handleBrushEnd = ({ range }: BrushTriggerEvent['data']) => {
const [min, max] = range;
onSubmit({
dateRange: {
from: new Date(min).toISOString(),
to: new Date(max).toISOString(),
mode: 'absolute',
},
});
};
const handleBrushEnd = useCallback(
({ range }: BrushTriggerEvent['data']) => {
const [min, max] = range;
onSubmit({
dateRange: {
from: new Date(min).toISOString(),
to: new Date(max).toISOString(),
mode: 'absolute',
},
});
},
[onSubmit]
);
return (
<EuiPanel
@ -95,7 +103,7 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) =>
hasBorder
paddingSize={error ? 'm' : 'none'}
css={css`
min-height: calc(${MIN_HEIGHT}px + ${euiTheme.size.l});
min-height: calc(${METRIC_CHART_MIN_HEIGHT}px + ${euiTheme.size.l});
position: relative;
`}
data-test-subj={`hostsView-metricChart-${type}`}
@ -124,7 +132,7 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) =>
<LensWrapper
id={`hostsViewsmetricsChart-${type}`}
attributes={attributes}
style={{ height: MIN_HEIGHT }}
style={lensStyle}
extraActions={extraActions}
lastReloadRequestTime={afterLoadedState.lastReloadRequestTime}
dateRange={afterLoadedState.dateRange}

View file

@ -18,6 +18,9 @@ export const DEFAULT_PAGE_SIZE = 10;
export const LOCAL_STORAGE_HOST_LIMIT_KEY = 'hostsView:hostLimitSelection';
export const LOCAL_STORAGE_PAGE_SIZE_KEY = 'hostsView:pageSizeSelection';
export const KPI_CHART_MIN_HEIGHT = 150;
export const METRIC_CHART_MIN_HEIGHT = 300;
export const ALL_ALERTS: AlertStatusFilter = {
status: ALERT_STATUS_ALL,
label: i18n.translate('xpack.infra.hostsViewPage.tabs.alerts.alertStatusFilter.showAll', {

View file

@ -6,7 +6,7 @@
*/
import * as rt from 'io-ts';
import _ from 'lodash';
import { pick } from 'lodash';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { constant, identity } from 'fp-ts/lib/function';
@ -90,19 +90,26 @@ const getVisibleControlPanelsConfig = (dataView: DataView | undefined) => {
};
const addDataViewIdToControlPanels = (controlPanels: ControlPanels, dataViewId: string = '') => {
return _.mapValues(controlPanels, (controlPanelConfig) => {
const controlsClone = _.cloneDeep(controlPanelConfig);
controlsClone.explicitInput.dataViewId = dataViewId;
return controlsClone;
});
return Object.entries(controlPanels).reduce((acc, [key, controlPanelConfig]) => {
return {
...acc,
[key]: {
...controlPanelConfig,
explicitInput: { ...controlPanelConfig.explicitInput, dataViewId },
},
};
}, {});
};
const cleanControlPanels = (controlPanels: ControlPanels) => {
return _.mapValues(controlPanels, (controlPanelConfig) => {
const controlsClone = _.cloneDeep(controlPanelConfig);
delete controlsClone.explicitInput.dataViewId;
return controlsClone;
});
return Object.entries(controlPanels).reduce((acc, [key, controlPanelConfig]) => {
const { explicitInput } = controlPanelConfig;
const { dataViewId, ...rest } = explicitInput;
return {
...acc,
[key]: { ...controlPanelConfig, explicitInput: rest },
};
}, {});
};
const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlPanels = {}) => {
@ -111,7 +118,7 @@ const mergeDefaultPanelsWithUrlConfig = (dataView: DataView, urlPanels: ControlP
// Get list of panel which can be overridden to avoid merging additional config from url
const existingKeys = Object.keys(visiblePanels);
const controlPanelsToOverride = _.pick(urlPanels, existingKeys);
const controlPanelsToOverride = pick(urlPanels, existingKeys);
// Merge default and existing configs and add dataView.id to each of them
return addDataViewIdToControlPanels(

View file

@ -29,11 +29,11 @@ export const useDataView = ({ metricAlias }: { metricAlias: string }) => {
});
}, [metricAlias]);
const { value, loading, error, retry } = state;
const { value: dataView, loading, error, retry } = state;
return {
metricAlias,
dataView: value,
dataView,
loading,
loadDataView: retry,
error,