diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/__snapshots__/clusters_view.test.tsx.snap b/src/plugins/inspector/public/views/requests/components/details/clusters_view/__snapshots__/clusters_view.test.tsx.snap deleted file mode 100644 index 0186e1c713430..0000000000000 --- a/src/plugins/inspector/public/views/requests/components/details/clusters_view/__snapshots__/clusters_view.test.tsx.snap +++ /dev/null @@ -1,96 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`render should render local and remote cluster details from _clusters 1`] = ` - - - - - -`; - -exports[`render should render local cluster details from _shards 1`] = ` - - - - -`; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/cluster_health.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/cluster_health.tsx index 4e4e57f5284c7..af066c3fac713 100644 --- a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/cluster_health.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/cluster_health.tsx @@ -8,15 +8,21 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiHealth, EuiText } from '@elastic/eui'; +import { EuiHealth, EuiText, EuiTextProps } from '@elastic/eui'; import { HEALTH_HEX_CODES } from './gradient'; interface Props { count?: number; status: string; + textProps?: EuiTextProps; } -export function ClusterHealth({ count, status }: Props) { +const defaultTextProps: EuiTextProps = { + size: 'xs', + color: 'subdued', +}; + +export function ClusterHealth({ count, status, textProps = defaultTextProps }: Props) { if (typeof count === 'number' && count === 0) { return null; } @@ -48,9 +54,7 @@ export function ClusterHealth({ count, status }: Props) { const label = typeof count === 'number' ? `${count} ${statusLabel}` : statusLabel; return ( - - {label} - + {label} ); } diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.test.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.test.tsx new file mode 100644 index 0000000000000..19c269886041c --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.test.tsx @@ -0,0 +1,60 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import type { ClusterDetails } from '@kbn/es-types'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ClustersTable } from './clusters_table'; + +describe('ClustersTable', () => { + describe('sorting', () => { + const clusters = { + remote1: { + status: 'successful', + took: 50, + } as unknown as ClusterDetails, + remote2: { + status: 'skipped', + took: 1000, + } as unknown as ClusterDetails, + remote3: { + status: 'failed', + took: 90, + } as unknown as ClusterDetails, + }; + + test('should render rows in native order', () => { + render(); + const tableRows = screen.getAllByRole('row'); + expect(tableRows.length).toBe(4); // 1 header row, 3 data rows + expect(tableRows[1]).toHaveTextContent('Nameremote1StatussuccessfulResponse time50ms'); + expect(tableRows[2]).toHaveTextContent('Nameremote2StatusskippedResponse time1000ms'); + expect(tableRows[3]).toHaveTextContent('Nameremote3StatusfailedResponse time90ms'); + }); + + test('should sort by response time', () => { + render(); + const button = screen.getByRole('button', { + name: 'Response time', + }); + fireEvent.click(button); + const tableRowsAsc = screen.getAllByRole('row'); + expect(tableRowsAsc.length).toBe(4); // 1 header row, 3 data rows + expect(tableRowsAsc[1]).toHaveTextContent('Nameremote1StatussuccessfulResponse time50ms'); + expect(tableRowsAsc[2]).toHaveTextContent('Nameremote3StatusfailedResponse time90ms'); + expect(tableRowsAsc[3]).toHaveTextContent('Nameremote2StatusskippedResponse time1000ms'); + + fireEvent.click(button); + const tableRowsDesc = screen.getAllByRole('row'); + expect(tableRowsDesc.length).toBe(4); // 1 header row, 3 data rows + expect(tableRowsDesc[1]).toHaveTextContent('Nameremote2StatusskippedResponse time1000ms'); + expect(tableRowsDesc[2]).toHaveTextContent('Nameremote3StatusfailedResponse time90ms'); + expect(tableRowsDesc[3]).toHaveTextContent('Nameremote1StatussuccessfulResponse time50ms'); + }); + }); +}); diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.tsx index c9fea1a470f49..03de4a0b999f4 100644 --- a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.tsx @@ -6,10 +6,17 @@ * Side Public License, v 1. */ -import React, { useState, ReactNode } from 'react'; +import React, { useMemo, useState, ReactNode } from 'react'; import type { ClusterDetails } from '@kbn/es-types'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable, type EuiBasicTableColumn, EuiButtonIcon, EuiText } from '@elastic/eui'; +import { + Comparators, + EuiBasicTable, + type EuiBasicTableColumn, + EuiButtonIcon, + EuiText, + Criteria, +} from '@elastic/eui'; import { ClusterView } from './cluster_view'; import { ClusterHealth } from '../clusters_health'; import { LOCAL_CLUSTER_KEY } from '../local_cluster'; @@ -21,7 +28,7 @@ function getInitialExpandedRow(clusters: Record) { : {}; } -interface ClusterColumn { +interface ClusterItem { name: string; status: string; responseTime?: number; @@ -35,6 +42,8 @@ export function ClustersTable({ clusters }: Props) { const [expandedRows, setExpandedRows] = useState>( getInitialExpandedRow(clusters) ); + const [sortField, setSortField] = useState(); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const toggleDetails = (name: string) => { const nextExpandedRows = { ...expandedRows }; @@ -46,7 +55,17 @@ export function ClustersTable({ clusters }: Props) { setExpandedRows(nextExpandedRows); }; - const columns: Array> = [ + const items = useMemo(() => { + return Object.keys(clusters).map((key) => { + return { + name: key, + status: clusters[key].status, + responseTime: clusters[key].took, + }; + }); + }, [clusters]); + + const columns: Array> = [ { field: 'name', name: i18n.translate('inspector.requests.clusters.table.nameLabel', { @@ -78,6 +97,7 @@ export function ClustersTable({ clusters }: Props) { ); }, + sortable: items.length > 1, width: '60%', }, { @@ -88,6 +108,7 @@ export function ClustersTable({ clusters }: Props) { render: (status: string) => { return ; }, + sortable: items.length > 1, }, { align: 'right' as 'right', @@ -105,22 +126,38 @@ export function ClustersTable({ clusters }: Props) { : null} ), + sortable: items.length > 1, }, ]; return ( { - return { - name: key, - status: clusters[key].status, - responseTime: clusters[key].took, - }; - })} + items={ + sortField + ? items.sort(Comparators.property(sortField, Comparators.default(sortDirection))) + : items + } isExpandable={true} itemIdToExpandedRowMap={expandedRows} itemId="name" columns={columns} + sorting={{ + sort: sortField + ? { + field: sortField, + direction: sortDirection, + } + : undefined, + }} + onChange={({ sort }: Criteria) => { + if (sort) { + setSortField(sort.field); + setSortDirection(sort.direction); + } + }} + noItemsMessage={i18n.translate('inspector.requests.clusters.table.noItemsFound', { + defaultMessage: 'No clusters found', + })} /> ); } diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.test.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.test.tsx index 971c3bad5bef8..7fd1edf1ecc0b 100644 --- a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.test.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.test.tsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen, fireEvent } from '@testing-library/react'; import { ClustersView } from './clusters_view'; import { Request } from '../../../../../../common/adapters/request/types'; @@ -51,26 +51,33 @@ describe('shouldShow', () => { }); describe('render', () => { - test('should render local cluster details from _shards', () => { - const request = { - response: { - json: { - rawResponse: { - _shards: { - total: 2, - successful: 2, - skipped: 0, - failed: 0, + describe('single cluster', () => { + test('should display table and not display search bar and health bar', () => { + const request = { + response: { + json: { + rawResponse: { + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, }, }, }, - }, - } as unknown as Request; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + } as unknown as Request; + render(); + const table = screen.getByRole('table'); + expect(table).not.toBeNull(); + const searchbar = screen.queryByRole('searchbox'); + expect(searchbar).toBeNull(); + const healthbar = screen.queryByText('2 clusters'); + expect(healthbar).toBeNull(); + }); }); - test('should render local and remote cluster details from _clusters', () => { + describe('multiple clusters', () => { const request = { response: { json: { @@ -110,7 +117,35 @@ describe('render', () => { }, }, } as unknown as Request; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + + test('should display table, search bar, and health bar', () => { + render(); + const table = screen.getByRole('table'); + expect(table).not.toBeNull(); + const searchbar = screen.getByRole('searchbox'); + expect(searchbar).not.toBeNull(); + const healthbar = screen.getByText('2 clusters'); + expect(healthbar).not.toBeNull(); + }); + + test('should filter table and health bar', () => { + render(); + const searchbar = screen.getByRole('searchbox'); + fireEvent.change(searchbar, { target: { value: 'remot' } }); + const tableRows = screen.getAllByRole('row'); + expect(tableRows.length).toBe(2); // table header and matching table row + const healthbar = screen.getByText('1 cluster'); + expect(healthbar).not.toBeNull(); + }); + + test('should display search bar when there are no matches for search', () => { + render(); + const searchbar = screen.getByRole('searchbox'); + fireEvent.change(searchbar, { target: { value: 'nevergonafindme' } }); + const notFoundRow = screen.getByRole('row', { name: 'No clusters found' }); + expect(notFoundRow).not.toBeNull(); + const searchbarAfterSearch = screen.getByRole('searchbox'); + expect(searchbarAfterSearch).not.toBeNull(); + }); }); }); diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.tsx index 8a96847832dc8..c14822d93561a 100644 --- a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.tsx +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.tsx @@ -8,45 +8,100 @@ import React, { Component } from 'react'; import { estypes } from '@elastic/elasticsearch'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiSearchBar, type EuiSearchBarOnChangeArgs, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import type { ClusterDetails } from '@kbn/es-types'; import { Request } from '../../../../../../common/adapters/request/types'; import type { DetailViewProps } from '../types'; -import { getLocalClusterDetails, LOCAL_CLUSTER_KEY } from './local_cluster'; -import { ClustersHealth } from './clusters_health'; +import { ClusterHealth, ClustersHealth } from './clusters_health'; import { ClustersTable } from './clusters_table'; +import { findClusters } from './find_clusters'; -export class ClustersView extends Component { +interface State { + clusters: Record; + showSearchAndStatusBar: boolean; +} + +export class ClustersView extends Component { static shouldShow = (request: Request) => Boolean( (request.response?.json as { rawResponse?: estypes.SearchResponse })?.rawResponse?._shards || (request.response?.json as { rawResponse?: estypes.SearchResponse })?.rawResponse?._clusters ); - render() { - const rawResponse = ( - this.props.request.response?.json as { rawResponse?: estypes.SearchResponse } - )?.rawResponse; - if (!rawResponse) { - return null; - } + constructor(props: DetailViewProps) { + super(props); + const clusters = findClusters(this.props.request); + this.state = { + clusters, + showSearchAndStatusBar: Object.keys(clusters).length > 1, + }; + } - const clusters = rawResponse._clusters - ? ( - rawResponse._clusters as estypes.ClusterStatistics & { - details: Record; - } - ).details - : { - [LOCAL_CLUSTER_KEY]: getLocalClusterDetails(rawResponse), - }; + _onSearchChange = ({ query, error }: EuiSearchBarOnChangeArgs) => { + if (!error) { + this.setState({ clusters: findClusters(this.props.request, query) }); + } + }; - return this.props.request.response?.json ? ( + render() { + return ( <> - {Object.keys(clusters).length > 1 ? : null} - + {this.state.showSearchAndStatusBar ? ( + <> + + ), + }, + { + value: 'partial', + view: ( + + ), + }, + { + value: 'skipped', + view: ( + + ), + }, + { + value: 'failed', + view: ( + + ), + }, + ], + }, + ]} + onChange={this._onSearchChange} + /> + + + + ) : null} + - ) : null; + ); } } diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/find_clusters.test.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/find_clusters.test.ts new file mode 100644 index 0000000000000..79cc378d89128 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/find_clusters.test.ts @@ -0,0 +1,65 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiSearchBar } from '@elastic/eui'; +import { findClusters } from './find_clusters'; +import { LOCAL_CLUSTER_KEY } from './local_cluster'; +import { Request } from '../../../../../../common/adapters/request/types'; + +const request = { + response: { + json: { + rawResponse: { + _clusters: { + details: { + [LOCAL_CLUSTER_KEY]: { + status: 'successful', + took: 50, + }, + remote1: { + status: 'skipped', + took: 1000, + }, + remote2: { + status: 'failed', + took: 90, + }, + }, + }, + }, + }, + }, +} as unknown as Request; + +describe('findClusters', () => { + test('should return all clusters when query is not provided', () => { + const clusters = findClusters(request); + expect(Object.keys(clusters)).toEqual([LOCAL_CLUSTER_KEY, 'remote1', 'remote2']); + }); + + test('should filter clusters by cluster name', () => { + const clusters = findClusters(request, EuiSearchBar.Query.parse('remo')); + expect(Object.keys(clusters)).toEqual(['remote1', 'remote2']); + }); + + test('should filter clusters by cluster name and status', () => { + const clusters = findClusters( + request, + EuiSearchBar.Query.parse('remo status:(successful or skipped)') + ); + expect(Object.keys(clusters)).toEqual(['remote1']); + }); + + test('should filter by multiple status values', () => { + const clusters = findClusters( + request, + EuiSearchBar.Query.parse('status:(successful or skipped)') + ); + expect(Object.keys(clusters)).toEqual([LOCAL_CLUSTER_KEY, 'remote1']); + }); +}); diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/find_clusters.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/find_clusters.ts new file mode 100644 index 0000000000000..8b62cf20d1b48 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/find_clusters.ts @@ -0,0 +1,52 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { estypes } from '@elastic/elasticsearch'; +import type { ClusterDetails } from '@kbn/es-types'; +import { EuiSearchBar, type Query } from '@elastic/eui'; +import { Request } from '../../../../../../common/adapters/request/types'; +import { getLocalClusterDetails, LOCAL_CLUSTER_KEY } from './local_cluster'; + +export function findClusters(request: Request, query?: Query): Record { + const rawResponse = (request.response?.json as { rawResponse?: estypes.SearchResponse }) + ?.rawResponse; + if (!rawResponse) { + return {}; + } + + const clusters = rawResponse._clusters + ? ( + rawResponse._clusters as estypes.ClusterStatistics & { + details: Record; + } + ).details + : { + [LOCAL_CLUSTER_KEY]: getLocalClusterDetails(rawResponse), + }; + + if (!query) { + return clusters; + } + + const clusterItems = Object.keys(clusters).map((key) => { + return { + name: key, + status: clusters[key].status, + }; + }); + + const narrowedClusterItems = EuiSearchBar.Query.execute(query, clusterItems, { + defaultFields: ['name'], + }); + + const narrowedClusers: Record = {}; + narrowedClusterItems.forEach(({ name }) => { + narrowedClusers[name] = clusters[name]; + }); + return narrowedClusers; +} diff --git a/src/plugins/inspector/public/views/requests/components/request_details.tsx b/src/plugins/inspector/public/views/requests/components/request_details.tsx index 7677cca66b369..cbe7c035afc8b 100644 --- a/src/plugins/inspector/public/views/requests/components/request_details.tsx +++ b/src/plugins/inspector/public/views/requests/components/request_details.tsx @@ -96,7 +96,7 @@ export function RequestDetails(props: Props) { ))} - + ) : null; }