[Metrics UI] Show descriptive loading, empty and error states in the metrics table (#133947)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Felix Stürmer 2022-06-22 11:23:55 +02:00 committed by GitHub
parent fabe146739
commit 87e72207bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1773 additions and 396 deletions

View file

@ -5,5 +5,6 @@
* 2.0.
*/
export { NoIndices } from './no_indices';
export * from './no_metric_indices';
export { NoData } from './no_data';
export { NoIndices } from './no_indices';

View file

@ -0,0 +1,28 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const noMetricIndicesPromptPrimaryActionTitle = i18n.translate(
'xpack.infra.metrics.noDataConfig.beatsCard.title',
{
defaultMessage: 'Add a metrics integration',
}
);
export const noMetricIndicesPromptDescription = i18n.translate(
'xpack.infra.metrics.noDataConfig.beatsCard.description',
{
defaultMessage:
'Use Beats to send metrics data to Elasticsearch. We make it easy with modules for many popular systems and apps.',
}
);
export const noMetricIndicesPromptTitle = i18n.translate(
'xpack.infra.metrics.noDataConfig.promptTitle',
{ defaultMessage: 'Add metrics data' }
);

View file

@ -7,12 +7,13 @@
import { EuiCard } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import type { Meta } from '@storybook/react/types-6-0';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { Meta, Story } from '@storybook/react/types-6-0';
import React from 'react';
import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme';
import { ContainerMetricsTable } from './container_metrics_table';
import type { ContainerMetricsTableProps } from './container_metrics_table';
import { ContainerMetricsTable } from './container_metrics_table';
import { ContainerNodeMetricsRow } from './use_container_metrics_table';
const mockServices = {
application: {
@ -32,6 +33,20 @@ export default {
decorateWithGlobalStorybookThemeProviders,
],
component: ContainerMetricsTable,
args: {
data: {
state: 'empty-indices',
},
isLoading: false,
sortState: {
direction: 'desc',
field: 'averageCpuUsagePercent',
},
timerange: {
from: 'now-15m',
to: 'now',
},
},
argTypes: {
setSortState: {
action: 'Sort field or direction changed',
@ -42,53 +57,95 @@ export default {
},
} as Meta;
const storyArgs: Omit<ContainerMetricsTableProps, 'setSortState' | 'setCurrentPageIndex'> = {
isLoading: false,
containers: [
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg1',
uptime: 23000000,
averageCpuUsagePercent: 99,
averageMemoryUsageMegabytes: 34,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg2',
uptime: 43000000,
averageCpuUsagePercent: 72,
averageMemoryUsageMegabytes: 68,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg3',
uptime: 53000000,
averageCpuUsagePercent: 54,
averageMemoryUsageMegabytes: 132,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg4',
uptime: 63000000,
averageCpuUsagePercent: 34,
averageMemoryUsageMegabytes: 264,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg5',
uptime: 83000000,
averageCpuUsagePercent: 13,
averageMemoryUsageMegabytes: 512,
},
],
currentPageIndex: 0,
pageCount: 10,
sortState: {
direction: 'desc',
field: 'averageCpuUsagePercent',
const loadedContainers: ContainerNodeMetricsRow[] = [
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg1',
uptime: 23000000,
averageCpuUsagePercent: 99,
averageMemoryUsageMegabytes: 34,
},
timerange: {
from: 'now-15m',
to: 'now',
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg2',
uptime: 43000000,
averageCpuUsagePercent: 72,
averageMemoryUsageMegabytes: 68,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg3',
uptime: 53000000,
averageCpuUsagePercent: 54,
averageMemoryUsageMegabytes: 132,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg4',
uptime: 63000000,
averageCpuUsagePercent: 34,
averageMemoryUsageMegabytes: 264,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg5',
uptime: 83000000,
averageCpuUsagePercent: 13,
averageMemoryUsageMegabytes: 512,
},
];
const Template: Story<ContainerMetricsTableProps> = (args) => {
return <ContainerMetricsTable {...args} />;
};
export const Basic = Template.bind({});
Basic.args = {
data: {
state: 'data',
currentPageIndex: 1,
pageCount: 10,
rows: loadedContainers,
},
};
export const Demo = (args: ContainerMetricsTableProps) => {
return <ContainerMetricsTable {...args} />;
export const Loading = Template.bind({});
Loading.args = {
isLoading: true,
};
export const Reloading = Template.bind({});
Reloading.args = {
data: {
state: 'data',
currentPageIndex: 1,
pageCount: 10,
rows: loadedContainers,
},
isLoading: true,
};
export const MissingIndices = Template.bind({});
MissingIndices.args = {
data: {
state: 'no-indices',
},
};
export const EmptyIndices = Template.bind({});
EmptyIndices.args = {
data: {
state: 'empty-indices',
},
};
export const FailedToLoadSource = Template.bind({});
FailedToLoadSource.args = {
data: {
state: 'error',
errors: [new Error('Failed to load source configuration')],
},
};
export const FailedToLoadMetrics = Template.bind({});
FailedToLoadMetrics.args = {
data: {
state: 'error',
errors: [new Error('Failed to load metrics')],
},
};
Demo.args = storyArgs;

View file

@ -5,19 +5,21 @@
* 2.0.
*/
import type { HttpFetchOptions } from '@kbn/core/public';
import { MetricsExplorerSeries } from '../../../../common/http_api';
import { CoreProviders } from '../../../apps/common_providers';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import type { HttpFetchOptions } from '@kbn/core/public';
import type {
DataResponseMock,
NodeMetricsTableFetchMock,
SourceResponseMock,
} from '../test_helpers';
import { createStartServicesAccessorMock } from '../test_helpers';
import { ContainerMetricsTable } from './container_metrics_table';
import { createLazyContainerMetricsTable } from './create_lazy_container_metrics_table';
import IntegratedContainerMetricsTable from './integrated_container_metrics_table';
import { metricByField } from './use_container_metrics_table';
import type { MetricsExplorerSeries } from '../../../../common/http_api';
describe('ContainerMetricsTable', () => {
const timerange = {
@ -40,6 +42,8 @@ describe('ContainerMetricsTable', () => {
const fetchMock = createFetchMock();
const loadingIndicatorTestId = 'metricsTableLoadingContent';
describe('createLazyContainerMetricsTable', () => {
it('should lazily load and render the table', async () => {
const { fetch, getStartServices } = createStartServicesAccessorMock(fetchMock);
@ -47,7 +51,7 @@ describe('ContainerMetricsTable', () => {
render(<LazyContainerMetricsTable timerange={timerange} filterClauseDsl={filterClauseDsl} />);
expect(screen.queryByTestId('containerMetricsTableLoader')).not.toBeInTheDocument();
expect(screen.queryByTestId(loadingIndicatorTestId)).not.toBeInTheDocument();
expect(screen.queryByTestId('containerMetricsTable')).not.toBeInTheDocument();
// Using longer time out since resolving dynamic import can be slow
@ -56,7 +60,7 @@ describe('ContainerMetricsTable', () => {
timeout: 10000,
});
expect(screen.queryByTestId('containerMetricsTableLoader')).not.toBeInTheDocument();
expect(screen.queryByTestId(loadingIndicatorTestId)).not.toBeInTheDocument();
expect(screen.queryByTestId('containerMetricsTable')).toBeInTheDocument();
}, 10000);
});
@ -79,6 +83,44 @@ describe('ContainerMetricsTable', () => {
expect(await findByText(/some-container/)).toBeInTheDocument();
});
});
it('should render a loading indicator on first load', () => {
const { coreProvidersPropsMock } = createStartServicesAccessorMock(jest.fn());
const { queryByTestId } = render(
<CoreProviders {...coreProvidersPropsMock}>
<ContainerMetricsTable
data={{ state: 'unknown' }}
isLoading={true}
setCurrentPageIndex={jest.fn()}
setSortState={jest.fn()}
sortState={{ field: 'name', direction: 'asc' }}
timerange={{ from: new Date().toISOString(), to: new Date().toISOString() }}
/>
</CoreProviders>
);
expect(queryByTestId(loadingIndicatorTestId)).toBeInTheDocument();
});
it('should render a prompt when indices are missing', () => {
const { coreProvidersPropsMock } = createStartServicesAccessorMock(jest.fn());
const { queryByTestId } = render(
<CoreProviders {...coreProvidersPropsMock}>
<ContainerMetricsTable
data={{ state: 'no-indices' }}
isLoading={false}
setCurrentPageIndex={jest.fn()}
setSortState={jest.fn()}
sortState={{ field: 'name', direction: 'asc' }}
timerange={{ from: new Date().toISOString(), to: new Date().toISOString() }}
/>
</CoreProviders>
);
expect(queryByTestId('metricsTableLoadingContent')).toBeInTheDocument();
});
});
function createFetchMock(): NodeMetricsTableFetchMock {
@ -87,6 +129,9 @@ function createFetchMock(): NodeMetricsTableFetchMock {
configuration: {
metricAlias: 'some-index-pattern',
},
status: {
metricIndicesExist: true,
},
},
};
@ -97,7 +142,7 @@ function createFetchMock(): NodeMetricsTableFetchMock {
],
};
return (path: string, options: HttpFetchOptions) => {
return (path: string, _options: HttpFetchOptions) => {
// options can be used to read body for filter clause
if (path === '/api/metrics/source/default') {
return Promise.resolve(sourceMock);

View file

@ -10,44 +10,37 @@ import type {
EuiBasicTableColumn,
EuiTableSortingType,
} from '@elastic/eui';
import {
EuiBasicTable,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSpacer,
} from '@elastic/eui';
import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import type { SortState } from '../shared';
import { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from '../shared';
import {
MetricsNodeDetailsLink,
MetricsTableEmptyIndicesContent,
MetricsTableErrorContent,
MetricsTableLoadingContent,
MetricsTableNoIndicesContent,
NodeMetricsTableData,
NumberCell,
StepwisePagination,
UptimeCell,
} from '../shared';
import type { ContainerNodeMetricsRow } from './use_container_metrics_table';
export interface ContainerMetricsTableProps {
data: NodeMetricsTableData<ContainerNodeMetricsRow>;
isLoading: boolean;
setCurrentPageIndex: (value: number) => void;
setSortState: (state: SortState<ContainerNodeMetricsRow>) => void;
sortState: SortState<ContainerNodeMetricsRow>;
timerange: {
from: string;
to: string;
};
isLoading: boolean;
containers: ContainerNodeMetricsRow[];
pageCount: number;
currentPageIndex: number;
setCurrentPageIndex: (value: number) => void;
sortState: SortState<ContainerNodeMetricsRow>;
setSortState: (state: SortState<ContainerNodeMetricsRow>) => void;
}
export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => {
const {
timerange,
isLoading,
containers,
pageCount,
currentPageIndex,
setCurrentPageIndex,
sortState,
setSortState,
} = props;
const { data, isLoading, setCurrentPageIndex, setSortState, sortState, timerange } = props;
const columns = useMemo(() => containerNodeColumns(timerange), [timerange]);
@ -68,42 +61,54 @@ export const ContainerMetricsTable = (props: ContainerMetricsTableProps) => {
[setSortState, setCurrentPageIndex]
);
if (isLoading) {
if (data.state === 'error') {
return (
<EuiFlexGroup alignItems="center" justifyContent="center" direction="column">
<EuiLoadingSpinner size="xl" data-test-subj="containerMetricsTableLoader" />
</EuiFlexGroup>
<>
{data.errors.map((error) => (
<MetricsTableErrorContent error={error} />
))}
</>
);
} else if (isLoading && data.state !== 'data') {
return <MetricsTableLoadingContent />;
} else if (data.state === 'no-indices') {
return <MetricsTableNoIndicesContent />;
} else if (data.state === 'empty-indices') {
return <MetricsTableEmptyIndicesContent />;
} else if (data.state === 'data') {
return (
<>
<EuiBasicTable
tableCaption={i18n.translate('xpack.infra.metricsTable.container.tableCaption', {
defaultMessage: 'Infrastructure metrics for containers',
})}
items={data.rows}
columns={columns}
sorting={sortSettings}
onChange={onTableSortChange}
loading={isLoading}
noItemsMessage={<MetricsTableLoadingContent />}
data-test-subj="containerMetricsTable"
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<StepwisePagination
ariaLabel={i18n.translate('xpack.infra.metricsTable.container.paginationAriaLabel', {
defaultMessage: 'Container metrics pagination',
})}
pageCount={data.pageCount}
currentPageIndex={data.currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
data-test-subj="containerMetricsTablePagination"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
} else {
return null;
}
return (
<>
<EuiBasicTable
tableCaption={i18n.translate('xpack.infra.metricsTable.container.tableCaption', {
defaultMessage: 'Infrastructure metrics for containers',
})}
items={containers}
columns={columns}
sorting={sortSettings}
onChange={onTableSortChange}
data-test-subj="containerMetricsTable"
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<StepwisePagination
ariaLabel={i18n.translate('xpack.infra.metricsTable.container.paginationAriaLabel', {
defaultMessage: 'Container metrics pagination',
})}
pageCount={pageCount}
currentPageIndex={currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
data-test-subj="containerMetricsTablePagination"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
function containerNodeColumns(

View file

@ -73,11 +73,7 @@ export function useContainerMetricsTable({
[filterClauseDsl]
);
const {
isLoading,
nodes: containers,
pageCount,
} = useInfrastructureNodeMetrics<ContainerNodeMetricsRow>({
const { data, isLoading } = useInfrastructureNodeMetrics<ContainerNodeMetricsRow>({
metricsExplorerOptions: containerMetricsOptions,
timerange,
transform: seriesToContainerNodeMetricsRow,
@ -86,14 +82,12 @@ export function useContainerMetricsTable({
});
return {
timerange,
data,
isLoading,
containers,
pageCount,
currentPageIndex,
setCurrentPageIndex,
sortState,
setSortState,
sortState,
timerange,
};
}

View file

@ -7,12 +7,13 @@
import { EuiCard } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import type { Meta } from '@storybook/react/types-6-0';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { Meta, Story } from '@storybook/react/types-6-0';
import React from 'react';
import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme';
import { HostMetricsTable } from './host_metrics_table';
import type { HostMetricsTableProps } from './host_metrics_table';
import { HostMetricsTable } from './host_metrics_table';
import { HostNodeMetricsRow } from './use_host_metrics_table';
const mockServices = {
application: {
@ -32,6 +33,20 @@ export default {
decorateWithGlobalStorybookThemeProviders,
],
component: HostMetricsTable,
args: {
data: {
state: 'empty-indices',
},
isLoading: false,
sortState: {
direction: 'desc',
field: 'averageCpuUsagePercent',
},
timerange: {
from: 'now-15m',
to: 'now',
},
},
argTypes: {
setSortState: {
action: 'Sort field or direction changed',
@ -40,60 +55,102 @@ export default {
action: 'Page changed',
},
},
} as Meta;
} as Meta<HostMetricsTableProps>;
const storyArgs: Omit<HostMetricsTableProps, 'setSortState' | 'setCurrentPageIndex'> = {
isLoading: false,
hosts: [
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg1',
cpuCount: 2,
averageCpuUsagePercent: 99,
totalMemoryMegabytes: 1024,
averageMemoryUsagePercent: 34,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg2',
cpuCount: 4,
averageCpuUsagePercent: 74,
totalMemoryMegabytes: 2450,
averageMemoryUsagePercent: 13,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg3',
cpuCount: 8,
averageCpuUsagePercent: 56,
totalMemoryMegabytes: 4810,
averageMemoryUsagePercent: 74,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg4',
cpuCount: 16,
averageCpuUsagePercent: 34,
totalMemoryMegabytes: 8123,
averageMemoryUsagePercent: 56,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg5',
cpuCount: 32,
averageCpuUsagePercent: 13,
totalMemoryMegabytes: 16792,
averageMemoryUsagePercent: 99,
},
],
currentPageIndex: 0,
pageCount: 10,
sortState: {
direction: 'desc',
field: 'averageCpuUsagePercent',
const loadedHosts: HostNodeMetricsRow[] = [
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg1',
cpuCount: 2,
averageCpuUsagePercent: 99,
totalMemoryMegabytes: 1024,
averageMemoryUsagePercent: 34,
},
timerange: {
from: 'now-15m',
to: 'now',
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg2',
cpuCount: 4,
averageCpuUsagePercent: 74,
totalMemoryMegabytes: 2450,
averageMemoryUsagePercent: 13,
},
};
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg3',
cpuCount: 8,
averageCpuUsagePercent: 56,
totalMemoryMegabytes: 4810,
averageMemoryUsagePercent: 74,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg4',
cpuCount: 16,
averageCpuUsagePercent: 34,
totalMemoryMegabytes: 8123,
averageMemoryUsagePercent: 56,
},
{
name: 'gke-edge-oblt-pool-1-9a60016d-lgg5',
cpuCount: 32,
averageCpuUsagePercent: 13,
totalMemoryMegabytes: 16792,
averageMemoryUsagePercent: 99,
},
];
export const Demo = (args: HostMetricsTableProps) => {
const Template: Story<HostMetricsTableProps> = (args) => {
return <HostMetricsTable {...args} />;
};
Demo.args = storyArgs;
export const Basic = Template.bind({});
Basic.args = {
data: {
state: 'data',
currentPageIndex: 1,
pageCount: 10,
rows: loadedHosts,
},
};
export const Loading = Template.bind({});
Loading.args = {
isLoading: true,
};
export const Reloading = Template.bind({});
Reloading.args = {
data: {
state: 'data',
currentPageIndex: 1,
pageCount: 10,
rows: loadedHosts,
},
isLoading: true,
};
export const MissingIndices = Template.bind({});
MissingIndices.args = {
data: {
state: 'no-indices',
},
};
export const EmptyIndices = Template.bind({});
EmptyIndices.args = {
data: {
state: 'empty-indices',
},
};
export const FailedToLoadSource = Template.bind({});
FailedToLoadSource.args = {
data: {
state: 'error',
errors: [new Error('Failed to load source configuration')],
},
};
export const FailedToLoadMetrics = Template.bind({});
FailedToLoadMetrics.args = {
data: {
state: 'error',
errors: [new Error('Failed to load metrics')],
},
};

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import type { HttpFetchOptions } from '@kbn/core/public';
import { CoreProviders } from '../../../apps/common_providers';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import type { HttpFetchOptions } from '@kbn/core/public';
import type { MetricsExplorerSeries } from '../../../../common/http_api';
import type {
DataResponseMock,
NodeMetricsTableFetchMock,
@ -15,9 +17,9 @@ import type {
} from '../test_helpers';
import { createStartServicesAccessorMock } from '../test_helpers';
import { createLazyHostMetricsTable } from './create_lazy_host_metrics_table';
import { HostMetricsTable } from './host_metrics_table';
import IntegratedHostMetricsTable from './integrated_host_metrics_table';
import { metricByField } from './use_host_metrics_table';
import type { MetricsExplorerSeries } from '../../../../common/http_api';
describe('HostMetricsTable', () => {
const timerange = {
@ -40,6 +42,8 @@ describe('HostMetricsTable', () => {
const fetchMock = createFetchMock();
const loadingIndicatorTestId = 'metricsTableLoadingContent';
describe('createLazyHostMetricsTable', () => {
it('should lazily load and render the table', async () => {
const { fetch, getStartServices } = createStartServicesAccessorMock(fetchMock);
@ -47,7 +51,7 @@ describe('HostMetricsTable', () => {
render(<LazyHostMetricsTable timerange={timerange} filterClauseDsl={filterClauseDsl} />);
expect(screen.queryByTestId('hostMetricsTableLoader')).not.toBeInTheDocument();
expect(screen.queryByTestId(loadingIndicatorTestId)).not.toBeInTheDocument();
expect(screen.queryByTestId('hostMetricsTable')).not.toBeInTheDocument();
// Using longer time out since resolving dynamic import can be slow
@ -56,7 +60,7 @@ describe('HostMetricsTable', () => {
timeout: 10000,
});
expect(screen.queryByTestId('hostMetricsTableLoader')).not.toBeInTheDocument();
expect(screen.queryByTestId(loadingIndicatorTestId)).not.toBeInTheDocument();
expect(screen.queryByTestId('hostMetricsTable')).toBeInTheDocument();
}, 10000);
});
@ -79,6 +83,44 @@ describe('HostMetricsTable', () => {
expect(await findByText(/some-host/)).toBeInTheDocument();
});
});
it('should render a loading indicator on first load', () => {
const { coreProvidersPropsMock } = createStartServicesAccessorMock(jest.fn());
const { queryByTestId } = render(
<CoreProviders {...coreProvidersPropsMock}>
<HostMetricsTable
data={{ state: 'unknown' }}
isLoading={true}
setCurrentPageIndex={jest.fn()}
setSortState={jest.fn()}
sortState={{ field: 'name', direction: 'asc' }}
timerange={{ from: new Date().toISOString(), to: new Date().toISOString() }}
/>
</CoreProviders>
);
expect(queryByTestId(loadingIndicatorTestId)).toBeInTheDocument();
});
it('should render a prompt when indices are missing', () => {
const { coreProvidersPropsMock } = createStartServicesAccessorMock(jest.fn());
const { queryByTestId } = render(
<CoreProviders {...coreProvidersPropsMock}>
<HostMetricsTable
data={{ state: 'no-indices' }}
isLoading={false}
setCurrentPageIndex={jest.fn()}
setSortState={jest.fn()}
sortState={{ field: 'name', direction: 'asc' }}
timerange={{ from: new Date().toISOString(), to: new Date().toISOString() }}
/>
</CoreProviders>
);
expect(queryByTestId('metricsTableLoadingContent')).toBeInTheDocument();
});
});
function createFetchMock(): NodeMetricsTableFetchMock {
@ -87,6 +129,9 @@ function createFetchMock(): NodeMetricsTableFetchMock {
configuration: {
metricAlias: 'some-index-pattern',
},
status: {
metricIndicesExist: true,
},
},
};
@ -97,7 +142,7 @@ function createFetchMock(): NodeMetricsTableFetchMock {
],
};
return (path: string, options: HttpFetchOptions) => {
return (path: string, _options: HttpFetchOptions) => {
// options can be used to read body for filter clause
if (path === '/api/metrics/source/default') {
return Promise.resolve(sourceMock);

View file

@ -10,44 +10,36 @@ import type {
EuiBasicTableColumn,
EuiTableSortingType,
} from '@elastic/eui';
import {
EuiBasicTable,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSpacer,
} from '@elastic/eui';
import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import type { SortState } from '../shared';
import { MetricsNodeDetailsLink, NumberCell, StepwisePagination } from '../shared';
import {
MetricsNodeDetailsLink,
MetricsTableEmptyIndicesContent,
MetricsTableErrorContent,
MetricsTableLoadingContent,
MetricsTableNoIndicesContent,
NodeMetricsTableData,
NumberCell,
StepwisePagination,
} from '../shared';
import type { HostNodeMetricsRow } from './use_host_metrics_table';
export interface HostMetricsTableProps {
data: NodeMetricsTableData<HostNodeMetricsRow>;
isLoading: boolean;
setCurrentPageIndex: (value: number) => void;
setSortState: (state: SortState<HostNodeMetricsRow>) => void;
sortState: SortState<HostNodeMetricsRow>;
timerange: {
from: string;
to: string;
};
isLoading: boolean;
hosts: HostNodeMetricsRow[];
pageCount: number;
currentPageIndex: number;
setCurrentPageIndex: (value: number) => void;
sortState: SortState<HostNodeMetricsRow>;
setSortState: (state: SortState<HostNodeMetricsRow>) => void;
}
export const HostMetricsTable = (props: HostMetricsTableProps) => {
const {
timerange,
isLoading,
hosts,
pageCount,
currentPageIndex,
setCurrentPageIndex,
sortState,
setSortState,
} = props;
const { data, isLoading, setCurrentPageIndex, setSortState, sortState, timerange } = props;
const columns = useMemo(() => hostMetricsColumns(timerange), [timerange]);
@ -68,42 +60,54 @@ export const HostMetricsTable = (props: HostMetricsTableProps) => {
[setSortState, setCurrentPageIndex]
);
if (isLoading) {
if (data.state === 'error') {
return (
<EuiFlexGroup alignItems="center" justifyContent="center" direction="column">
<EuiLoadingSpinner size="xl" data-test-subj="hostMetricsTableLoader" />
</EuiFlexGroup>
<>
{data.errors.map((error) => (
<MetricsTableErrorContent error={error} />
))}
</>
);
} else if (isLoading && data.state !== 'data') {
return <MetricsTableLoadingContent />;
} else if (data.state === 'no-indices') {
return <MetricsTableNoIndicesContent />;
} else if (data.state === 'empty-indices') {
return <MetricsTableEmptyIndicesContent />;
} else if (data.state === 'data') {
return (
<>
<EuiBasicTable
tableCaption={i18n.translate('xpack.infra.metricsTable.host.tableCaption', {
defaultMessage: 'Infrastructure metrics for hosts',
})}
items={data.rows}
columns={columns}
sorting={sortSettings}
onChange={onTableSortChange}
loading={isLoading}
noItemsMessage={<MetricsTableLoadingContent />}
data-test-subj="hostMetricsTable"
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<StepwisePagination
ariaLabel={i18n.translate('xpack.infra.metricsTable.host.paginationAriaLabel', {
defaultMessage: 'Host metrics pagination',
})}
pageCount={data.pageCount}
currentPageIndex={data.currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
data-test-subj="hostMetricsTablePagination"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
} else {
return null;
}
return (
<>
<EuiBasicTable
tableCaption={i18n.translate('xpack.infra.metricsTable.host.tableCaption', {
defaultMessage: 'Infrastructure metrics for hosts',
})}
items={hosts}
columns={columns}
sorting={sortSettings}
onChange={onTableSortChange}
data-test-subj="hostMetricsTable"
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<StepwisePagination
ariaLabel={i18n.translate('xpack.infra.metricsTable.host.paginationAriaLabel', {
defaultMessage: 'Host metrics pagination',
})}
pageCount={pageCount}
currentPageIndex={currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
data-test-subj="hostMetricsTablePagination"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
function hostMetricsColumns(

View file

@ -70,11 +70,7 @@ export function useHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetri
[filterClauseDsl]
);
const {
isLoading,
nodes: hosts,
pageCount,
} = useInfrastructureNodeMetrics<HostNodeMetricsRow>({
const { data, isLoading } = useInfrastructureNodeMetrics<HostNodeMetricsRow>({
metricsExplorerOptions: hostMetricsOptions,
timerange,
transform: seriesToHostNodeMetricsRow,
@ -83,14 +79,12 @@ export function useHostMetricsTable({ timerange, filterClauseDsl }: UseNodeMetri
});
return {
timerange,
data,
isLoading,
hosts,
pageCount,
currentPageIndex,
setCurrentPageIndex,
sortState,
setSortState,
sortState,
timerange,
};
}

View file

@ -7,12 +7,13 @@
import { EuiCard } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n-react';
import type { Meta } from '@storybook/react/types-6-0';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { Meta, Story } from '@storybook/react/types-6-0';
import React from 'react';
import { decorateWithGlobalStorybookThemeProviders } from '../../../test_utils/use_global_storybook_theme';
import { PodMetricsTable } from './pod_metrics_table';
import type { PodMetricsTableProps } from './pod_metrics_table';
import { PodMetricsTable } from './pod_metrics_table';
import { PodNodeMetricsRow } from './use_pod_metrics_table';
const mockServices = {
application: {
@ -32,6 +33,20 @@ export default {
decorateWithGlobalStorybookThemeProviders,
],
component: PodMetricsTable,
args: {
data: {
state: 'empty-indices',
},
isLoading: false,
sortState: {
direction: 'desc',
field: 'averageCpuUsagePercent',
},
timerange: {
from: 'now-15m',
to: 'now',
},
},
argTypes: {
setSortState: {
action: 'Sort field or direction changed',
@ -42,58 +57,100 @@ export default {
},
} as Meta;
const storyArgs: Omit<PodMetricsTableProps, 'setSortState' | 'setCurrentPageIndex'> = {
isLoading: false,
pods: [
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg1',
uptime: 23000000,
averageCpuUsagePercent: 99,
averageMemoryUsageMegabytes: 34,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c1',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg2',
uptime: 43000000,
averageCpuUsagePercent: 72,
averageMemoryUsageMegabytes: 68,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg3',
uptime: 53000000,
averageCpuUsagePercent: 54,
averageMemoryUsageMegabytes: 132,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg4',
uptime: 63000000,
averageCpuUsagePercent: 34,
averageMemoryUsageMegabytes: 264,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg5',
uptime: 83000000,
averageCpuUsagePercent: 13,
averageMemoryUsageMegabytes: 512,
},
],
currentPageIndex: 0,
pageCount: 10,
sortState: {
direction: 'desc',
field: 'averageCpuUsagePercent',
const loadedPods: PodNodeMetricsRow[] = [
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg1',
uptime: 23000000,
averageCpuUsagePercent: 99,
averageMemoryUsageMegabytes: 34,
},
timerange: {
from: 'now-15m',
to: 'now',
{
id: '358d96e3-026f-4440-a487-f6c2301884c1',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg2',
uptime: 43000000,
averageCpuUsagePercent: 72,
averageMemoryUsageMegabytes: 68,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg3',
uptime: 53000000,
averageCpuUsagePercent: 54,
averageMemoryUsageMegabytes: 132,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg4',
uptime: 63000000,
averageCpuUsagePercent: 34,
averageMemoryUsageMegabytes: 264,
},
{
id: '358d96e3-026f-4440-a487-f6c2301884c0',
name: 'gke-edge-oblt-pool-1-9a60016d-lgg5',
uptime: 83000000,
averageCpuUsagePercent: 13,
averageMemoryUsageMegabytes: 512,
},
];
const Template: Story<PodMetricsTableProps> = (args) => {
return <PodMetricsTable {...args} />;
};
export const Basic = Template.bind({});
Basic.args = {
data: {
state: 'data',
currentPageIndex: 1,
pageCount: 10,
rows: loadedPods,
},
};
export const Demo = (args: PodMetricsTableProps) => {
return <PodMetricsTable {...args} />;
export const Loading = Template.bind({});
Loading.args = {
isLoading: true,
};
export const Reloading = Template.bind({});
Reloading.args = {
data: {
state: 'data',
currentPageIndex: 1,
pageCount: 10,
rows: loadedPods,
},
isLoading: true,
};
export const MissingIndices = Template.bind({});
MissingIndices.args = {
data: {
state: 'no-indices',
},
};
export const EmptyIndices = Template.bind({});
EmptyIndices.args = {
data: {
state: 'empty-indices',
},
};
export const FailedToLoadSource = Template.bind({});
FailedToLoadSource.args = {
data: {
state: 'error',
errors: [new Error('Failed to load source configuration')],
},
};
export const FailedToLoadMetrics = Template.bind({});
FailedToLoadMetrics.args = {
data: {
state: 'error',
errors: [new Error('Failed to load metrics')],
},
};
Demo.args = storyArgs;

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import type { HttpFetchOptions } from '@kbn/core/public';
import { CoreProviders } from '../../../apps/common_providers';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import type { HttpFetchOptions } from '@kbn/core/public';
import type { MetricsExplorerSeries } from '../../../../common/http_api';
import type {
DataResponseMock,
NodeMetricsTableFetchMock,
@ -16,8 +18,8 @@ import type {
import { createStartServicesAccessorMock } from '../test_helpers';
import { createLazyPodMetricsTable } from './create_lazy_pod_metrics_table';
import IntegratedPodMetricsTable from './integrated_pod_metrics_table';
import { PodMetricsTable } from './pod_metrics_table';
import { metricByField } from './use_pod_metrics_table';
import type { MetricsExplorerSeries } from '../../../../common/http_api';
describe('PodMetricsTable', () => {
const timerange = {
@ -40,6 +42,8 @@ describe('PodMetricsTable', () => {
const fetchMock = createFetchMock();
const loadingIndicatorTestId = 'metricsTableLoadingContent';
describe('createLazyPodMetricsTable', () => {
it('should lazily load and render the table', async () => {
const { fetch, getStartServices } = createStartServicesAccessorMock(fetchMock);
@ -47,7 +51,7 @@ describe('PodMetricsTable', () => {
render(<LazyPodMetricsTable timerange={timerange} filterClauseDsl={filterClauseDsl} />);
expect(screen.queryByTestId('podMetricsTableLoader')).not.toBeInTheDocument();
expect(screen.queryByTestId(loadingIndicatorTestId)).not.toBeInTheDocument();
expect(screen.queryByTestId('podMetricsTable')).not.toBeInTheDocument();
// Using longer time out since resolving dynamic import can be slow
@ -56,7 +60,7 @@ describe('PodMetricsTable', () => {
timeout: 10000,
});
expect(screen.queryByTestId('podMetricsTableLoader')).not.toBeInTheDocument();
expect(screen.queryByTestId(loadingIndicatorTestId)).not.toBeInTheDocument();
expect(screen.queryByTestId('podMetricsTable')).toBeInTheDocument();
}, 10000);
});
@ -79,6 +83,44 @@ describe('PodMetricsTable', () => {
expect(await findByText(/some-pod/)).toBeInTheDocument();
});
});
it('should render a loading indicator on first load', () => {
const { coreProvidersPropsMock } = createStartServicesAccessorMock(jest.fn());
const { queryByTestId } = render(
<CoreProviders {...coreProvidersPropsMock}>
<PodMetricsTable
data={{ state: 'unknown' }}
isLoading={true}
setCurrentPageIndex={jest.fn()}
setSortState={jest.fn()}
sortState={{ field: 'id', direction: 'asc' }}
timerange={{ from: new Date().toISOString(), to: new Date().toISOString() }}
/>
</CoreProviders>
);
expect(queryByTestId(loadingIndicatorTestId)).toBeInTheDocument();
});
it('should render a prompt when indices are missing', () => {
const { coreProvidersPropsMock } = createStartServicesAccessorMock(jest.fn());
const { queryByTestId } = render(
<CoreProviders {...coreProvidersPropsMock}>
<PodMetricsTable
data={{ state: 'no-indices' }}
isLoading={false}
setCurrentPageIndex={jest.fn()}
setSortState={jest.fn()}
sortState={{ field: 'id', direction: 'asc' }}
timerange={{ from: new Date().toISOString(), to: new Date().toISOString() }}
/>
</CoreProviders>
);
expect(queryByTestId('metricsTableLoadingContent')).toBeInTheDocument();
});
});
function createFetchMock(): NodeMetricsTableFetchMock {
@ -87,6 +129,9 @@ function createFetchMock(): NodeMetricsTableFetchMock {
configuration: {
metricAlias: 'some-index-pattern',
},
status: {
metricIndicesExist: true,
},
},
};
@ -97,7 +142,7 @@ function createFetchMock(): NodeMetricsTableFetchMock {
],
};
return (path: string, options: HttpFetchOptions) => {
return (path: string, _options: HttpFetchOptions) => {
// options can be used to read body for filter clause
if (path === '/api/metrics/source/default') {
return Promise.resolve(sourceMock);

View file

@ -10,44 +10,37 @@ import type {
EuiBasicTableColumn,
EuiTableSortingType,
} from '@elastic/eui';
import {
EuiBasicTable,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSpacer,
} from '@elastic/eui';
import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import type { SortState } from '../shared';
import { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from '../shared';
import {
MetricsNodeDetailsLink,
MetricsTableEmptyIndicesContent,
MetricsTableErrorContent,
MetricsTableLoadingContent,
MetricsTableNoIndicesContent,
NodeMetricsTableData,
NumberCell,
StepwisePagination,
UptimeCell,
} from '../shared';
import type { PodNodeMetricsRow } from './use_pod_metrics_table';
export interface PodMetricsTableProps {
data: NodeMetricsTableData<PodNodeMetricsRow>;
isLoading: boolean;
setCurrentPageIndex: (value: number) => void;
setSortState: (state: SortState<PodNodeMetricsRow>) => void;
sortState: SortState<PodNodeMetricsRow>;
timerange: {
from: string;
to: string;
};
isLoading: boolean;
pods: PodNodeMetricsRow[];
pageCount: number;
currentPageIndex: number;
setCurrentPageIndex: (value: number) => void;
sortState: SortState<PodNodeMetricsRow>;
setSortState: (state: SortState<PodNodeMetricsRow>) => void;
}
export const PodMetricsTable = (props: PodMetricsTableProps) => {
const {
timerange,
isLoading,
pods,
pageCount,
currentPageIndex,
setCurrentPageIndex,
sortState,
setSortState,
} = props;
const { data, isLoading, setCurrentPageIndex, setSortState, sortState, timerange } = props;
const columns = useMemo(() => podNodeColumns(timerange), [timerange]);
@ -66,42 +59,54 @@ export const PodMetricsTable = (props: PodMetricsTableProps) => {
setCurrentPageIndex(0);
};
if (isLoading) {
if (data.state === 'error') {
return (
<EuiFlexGroup alignItems="center" justifyContent="center" direction="column">
<EuiLoadingSpinner size="xl" data-test-subj="podMetricsTableLoader" />
</EuiFlexGroup>
<>
{data.errors.map((error) => (
<MetricsTableErrorContent error={error} />
))}
</>
);
} else if (isLoading && data.state !== 'data') {
return <MetricsTableLoadingContent />;
} else if (data.state === 'no-indices') {
return <MetricsTableNoIndicesContent />;
} else if (data.state === 'empty-indices') {
return <MetricsTableEmptyIndicesContent />;
} else if (data.state === 'data') {
return (
<>
<EuiBasicTable
tableCaption={i18n.translate('xpack.infra.metricsTable.pod.tableCaption', {
defaultMessage: 'Infrastructure metrics for pods',
})}
items={data.rows}
columns={columns}
sorting={sorting}
onChange={onTableSortChange}
loading={isLoading}
noItemsMessage={<MetricsTableLoadingContent />}
data-test-subj="podMetricsTable"
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<StepwisePagination
ariaLabel={i18n.translate('xpack.infra.metricsTable.pod.paginationAriaLabel', {
defaultMessage: 'Pod metrics pagination',
})}
pageCount={data.pageCount}
currentPageIndex={data.currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
data-test-subj="podMetricsTablePagination"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
} else {
return null;
}
return (
<>
<EuiBasicTable
tableCaption={i18n.translate('xpack.infra.metricsTable.pod.tableCaption', {
defaultMessage: 'Infrastructure metrics for pods',
})}
items={pods}
columns={columns}
sorting={sorting}
onChange={onTableSortChange}
data-test-subj="podMetricsTable"
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="flexEnd" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<StepwisePagination
ariaLabel={i18n.translate('xpack.infra.metricsTable.pod.paginationAriaLabel', {
defaultMessage: 'Pod metrics pagination',
})}
pageCount={pageCount}
currentPageIndex={currentPageIndex}
setCurrentPageIndex={setCurrentPageIndex}
data-test-subj="podMetricsTablePagination"
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
function podNodeColumns(

View file

@ -71,11 +71,7 @@ export function usePodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetric
[filterClauseDsl]
);
const {
isLoading,
nodes: pods,
pageCount,
} = useInfrastructureNodeMetrics<PodNodeMetricsRow>({
const { data, isLoading } = useInfrastructureNodeMetrics<PodNodeMetricsRow>({
metricsExplorerOptions: podMetricsOptions,
timerange,
transform: seriesToPodNodeMetricsRow,
@ -84,14 +80,13 @@ export function usePodMetricsTable({ timerange, filterClauseDsl }: UseNodeMetric
});
return {
timerange,
isLoading,
pods,
pageCount,
currentPageIndex,
data,
isLoading,
setCurrentPageIndex,
sortState,
setSortState,
sortState,
timerange,
};
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 206 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 206 KiB

View file

@ -0,0 +1,24 @@
/*
* 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 { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui';
import React from 'react';
export const MetricsTableErrorContent = ({ error }: { error: Error }) => (
<EuiEmptyPrompt
body={
<EuiCodeBlock className="eui-textLeft" isCopyable language="jsstacktrace">
{error.stack ?? `${error}`}
</EuiCodeBlock>
}
color="danger"
data-test-subj="metricsTableErrorContent"
iconType="alert"
title={<h2>{error.message}</h2>}
titleSize="s"
/>
);

View file

@ -5,7 +5,13 @@
* 2.0.
*/
export { MetricsTableErrorContent } from './error_content';
export { MetricsNodeDetailsLink } from './metrics_node_details_link';
export {
MetricsTableEmptyIndicesContent,
MetricsTableLoadingContent,
MetricsTableNoIndicesContent,
} from './no_data_content';
export { NumberCell } from './number_cell';
export { StepwisePagination } from './stepwise_pagination';
export { UptimeCell } from './uptime_cell';

View file

@ -0,0 +1,128 @@
/*
* 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 {
COLOR_MODES_STANDARD,
EuiButton,
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiEmptyPrompt,
EuiImage,
EuiLoadingLogo,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useLinkProps } from '@kbn/observability-plugin/public';
import React from 'react';
import {
noMetricIndicesPromptDescription,
noMetricIndicesPromptPrimaryActionTitle,
noMetricIndicesPromptTitle,
} from '../../../empty_states';
import noResultsIllustrationDark from './assets/no_results_dark.svg';
import noResultsIllustrationLight from './assets/no_results_light.svg';
export const MetricsTableLoadingContent = () => (
<EuiEmptyPrompt
data-test-subj="metricsTableLoadingContent"
icon={<EuiLoadingLogo logo="logoMetrics" size="xl" />}
title={
<h2>
<FormattedMessage
id="xpack.infra.metricsTable.loadingContentTitle"
defaultMessage="Loading metrics"
/>
</h2>
}
/>
);
export const MetricsTableNoIndicesContent = () => {
const integrationsLinkProps = useLinkProps({ app: 'integrations', pathname: 'browse' });
return (
<EuiEmptyPrompt
data-test-subj="metricsTableLoadingContent"
iconType="logoMetrics"
title={<h2>{noMetricIndicesPromptTitle}</h2>}
body={<p>{noMetricIndicesPromptDescription}</p>}
actions={
<EuiButton color="primary" fill {...integrationsLinkProps}>
{noMetricIndicesPromptPrimaryActionTitle}
</EuiButton>
}
/>
);
};
export const MetricsTableEmptyIndicesContent = () => {
return (
<EuiEmptyPrompt
body={
<EuiDescriptionList compressed>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.infra.metricsTable.emptyIndicesPromptTimeRangeHintTitle"
defaultMessage="Expand your time range"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedMessage
id="xpack.infra.metricsTable.emptyIndicesPromptTimeRangeHintDescription"
defaultMessage="Try searching over a longer period of time."
/>
</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.infra.metricsTable.emptyIndicesPromptQueryHintTitle"
defaultMessage="Adjust your query"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedMessage
id="xpack.infra.metricsTable.emptyIndicesPromptQueryHintDescription"
defaultMessage="Try searching for a different combination of terms."
/>
</EuiDescriptionListDescription>
</EuiDescriptionList>
}
color="subdued"
data-test-subj="metricsTableEmptyIndicesContent"
icon={<NoResultsIllustration />}
layout="horizontal"
title={
<h2>
<FormattedMessage
id="xpack.infra.metricsTable.emptyIndicesPromptTitle"
defaultMessage="No results match your search criteria"
/>
</h2>
}
titleSize="m"
/>
);
};
const NoResultsIllustration = () => {
const { colorMode } = useEuiTheme();
const illustration =
colorMode === COLOR_MODES_STANDARD.dark
? noResultsIllustrationDark
: noResultsIllustrationLight;
return (
<EuiImage alt={noResultsIllustrationAlternativeText} size="fullWidth" src={illustration} />
);
};
const noResultsIllustrationAlternativeText = i18n.translate(
'xpack.infra.metricsTable.noResultsIllustrationAlternativeText',
{ defaultMessage: 'A magnifying glass with an exclamation mark' }
);

View file

@ -19,6 +19,7 @@ import type {
MetricsExplorerTimeOptions,
} from '../../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options';
import { useTrackedPromise } from '../../../../utils/use_tracked_promise';
import { NodeMetricsTableData } from '../types';
export interface SortState<T> {
field: keyof T;
@ -51,10 +52,10 @@ export const useInfrastructureNodeMetrics = <T>(
const [transformedNodes, setTransformedNodes] = useState<T[]>([]);
const fetch = useKibanaHttpFetch();
const { source, isLoadingSource } = useSourceContext();
const { source, isLoadingSource, loadSourceRequest, metricIndicesExist } = useSourceContext();
const timerangeWithInterval = useTimerangeWithInterval(timerange);
const [{ state: promiseState }, fetchNodes] = useTrackedPromise(
const [fetchNodesRequest, fetchNodes] = useTrackedPromise(
{
createPromise: (): Promise<MetricsExplorerResponse> => {
if (!source) {
@ -78,16 +79,22 @@ export const useInfrastructureNodeMetrics = <T>(
onResolve: (response: MetricsExplorerResponse) => {
setTransformedNodes(response.series.map(transform));
},
onReject: (error) => {
// What to do about this?
// eslint-disable-next-line no-console
console.log(error);
},
cancelPreviousOn: 'creation',
},
[source, metricsExplorerOptions, timerangeWithInterval]
);
const isLoadingNodes = promiseState === 'pending' || promiseState === 'uninitialized';
const isLoadingNodes =
fetchNodesRequest.state === 'pending' || fetchNodesRequest.state === 'uninitialized';
const isLoading = isLoadingSource || isLoadingNodes;
const errors = useMemo<Error[]>(
() => [
...(loadSourceRequest.state === 'rejected' ? [wrapAsError(loadSourceRequest.value)] : []),
...(fetchNodesRequest.state === 'rejected' ? [wrapAsError(fetchNodesRequest.value)] : []),
],
[fetchNodesRequest, loadSourceRequest]
);
useEffect(() => {
fetchNodes();
@ -109,10 +116,23 @@ export const useInfrastructureNodeMetrics = <T>(
const pageCount = useMemo(() => Math.ceil(top100Nodes.length / TABLE_PAGE_SIZE), [top100Nodes]);
const data = useMemo<NodeMetricsTableData<T>>(
() =>
errors.length > 0
? { state: 'error', errors }
: metricIndicesExist == null
? { state: 'unknown' }
: !metricIndicesExist
? { state: 'no-indices' }
: nodes.length <= 0
? { state: 'empty-indices' }
: { state: 'data', currentPageIndex, pageCount, rows: nodes },
[currentPageIndex, errors, metricIndicesExist, nodes, pageCount]
);
return {
isLoading: isLoadingSource || isLoadingNodes,
nodes,
pageCount,
isLoading,
data,
};
};
@ -188,3 +208,5 @@ function sortDescending(nodeAValue: unknown, nodeBValue: unknown) {
return 0;
}
const wrapAsError = (value: any): Error => (value instanceof Error ? value : new Error(`${value}`));

View file

@ -5,7 +5,16 @@
* 2.0.
*/
export { MetricsNodeDetailsLink, NumberCell, StepwisePagination, UptimeCell } from './components';
export {
MetricsNodeDetailsLink,
MetricsTableEmptyIndicesContent,
MetricsTableErrorContent,
MetricsTableLoadingContent,
MetricsTableNoIndicesContent,
NumberCell,
StepwisePagination,
UptimeCell,
} from './components';
export {
averageOfValues,
createMetricByFieldLookup,
@ -17,6 +26,7 @@ export {
export type { MetricsMap, MetricsQueryOptions, SortState } from './hooks';
export type {
IntegratedNodeMetricsTableProps,
NodeMetricsTableData,
SourceProviderProps,
UseNodeMetricsTableOptions,
} from './types';

View file

@ -21,3 +21,24 @@ export interface SourceProviderProps {
export type IntegratedNodeMetricsTableProps = UseNodeMetricsTableOptions &
SourceProviderProps &
CoreProvidersProps;
export type NodeMetricsTableData<NodeMetricsRow> =
| {
state: 'unknown';
}
| {
state: 'no-indices';
}
| {
state: 'empty-indices';
}
| {
state: 'data';
currentPageIndex: number;
pageCount: number;
rows: NodeMetricsRow[];
}
| {
state: 'error';
errors: Error[];
};

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { DeepPartial } from 'utility-types';
import type { HttpFetchOptions } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { I18nProvider } from '@kbn/i18n-react';
import { DeepPartial } from 'utility-types';
import type { MetricsExplorerResponse } from '../../../common/http_api/metrics_explorer';
import type { MetricsSourceConfigurationResponse } from '../../../common/metrics_sources';
import type { CoreProvidersProps } from '../../apps/common_providers';
@ -28,6 +29,7 @@ export function createStartServicesAccessorMock(fetchMock: NodeMetricsTableFetch
const core = coreMock.createStart();
// @ts-expect-error core.http.fetch has overloads, Jest/TypeScript only picks the first definition when mocking
core.http.fetch.mockImplementation(fetchMock);
core.i18n.Context.mockImplementation(I18nProvider as () => JSX.Element);
const coreProvidersPropsMock: CoreProvidersProps = {
core,

View file

@ -145,6 +145,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => {
isUninitialized,
hasFailedLoadingSource: loadSourceRequest.state === 'rejected',
loadSource,
loadSourceRequest,
loadSourceFailureMessage:
loadSourceRequest.state === 'rejected' ? `${loadSourceRequest.value}` : undefined,
metricIndicesExist,

View file

@ -5,10 +5,14 @@
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-plugin/public';
import { KibanaPageTemplateProps } from '@kbn/shared-ux-components';
import React from 'react';
import {
noMetricIndicesPromptDescription,
noMetricIndicesPromptPrimaryActionTitle,
} from '../../components/empty_states';
import { useKibanaContextForPlugin } from '../../hooks/use_kibana';
interface MetricsPageTemplateProps extends LazyObservabilityPageTemplateProps {
@ -37,13 +41,8 @@ export const MetricsPageTemplate: React.FC<MetricsPageTemplateProps> = ({
}),
action: {
beats: {
title: i18n.translate('xpack.infra.metrics.noDataConfig.beatsCard.title', {
defaultMessage: 'Add a metrics integration',
}),
description: i18n.translate('xpack.infra.metrics.noDataConfig.beatsCard.description', {
defaultMessage:
'Use Beats to send metrics data to Elasticsearch. We make it easy with modules for many popular systems and apps.',
}),
title: noMetricIndicesPromptPrimaryActionTitle,
description: noMetricIndicesPromptDescription,
},
},
docsLink: docLinks.links.observability.guide,