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 new file mode 100644 index 0000000000000..4d4b46507925d --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/__snapshots__/clusters_view.test.tsx.snap @@ -0,0 +1,115 @@ +// 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 new file mode 100644 index 0000000000000..4e4e57f5284c7 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/cluster_health.tsx @@ -0,0 +1,56 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiHealth, EuiText } from '@elastic/eui'; +import { HEALTH_HEX_CODES } from './gradient'; + +interface Props { + count?: number; + status: string; +} + +export function ClusterHealth({ count, status }: Props) { + if (typeof count === 'number' && count === 0) { + return null; + } + + let color = 'subdued'; + let statusLabel = status; + if (status === 'successful') { + color = HEALTH_HEX_CODES.successful; + statusLabel = i18n.translate('inspector.requests.clusters.successfulLabel', { + defaultMessage: 'successful', + }); + } else if (status === 'partial') { + color = HEALTH_HEX_CODES.partial; + statusLabel = i18n.translate('inspector.requests.clusters.partialLabel', { + defaultMessage: 'partial', + }); + } else if (status === 'skipped') { + color = HEALTH_HEX_CODES.skipped; + statusLabel = i18n.translate('inspector.requests.clusters.skippedLabel', { + defaultMessage: 'skipped', + }); + } else if (status === 'failed') { + color = HEALTH_HEX_CODES.failed; + statusLabel = i18n.translate('inspector.requests.clusters.failedLabel', { + defaultMessage: 'failed', + }); + } + + const label = typeof count === 'number' ? `${count} ${statusLabel}` : statusLabel; + return ( + + + {label} + + + ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/clusters_health.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/clusters_health.tsx new file mode 100644 index 0000000000000..088e28c5e5787 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/clusters_health.tsx @@ -0,0 +1,87 @@ +/* + * 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 { css } from '@emotion/react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { i18n } from '@kbn/i18n'; +import type { ClusterDetails } from '@kbn/es-types'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { ClusterHealth } from './cluster_health'; +import { getHeathBarLinearGradient } from './gradient'; + +interface Props { + clusters: Record; +} + +export function ClustersHealth({ clusters }: Props) { + let successful = 0; + let partial = 0; + let skipped = 0; + let failed = 0; + Object.values(clusters).forEach((clusterDetails) => { + if (clusterDetails.status === 'successful') { + successful++; + } else if (clusterDetails.status === 'partial') { + partial++; + } else if (clusterDetails.status === 'skipped') { + skipped++; + } else if (clusterDetails.status === 'failed') { + failed++; + } + }); + + return ( + <> + + + + {i18n.translate('inspector.requests.clusters.totalClustersLabel', { + defaultMessage: '{total} {total, plural, one {cluster} other {clusters}}', + values: { total: Object.keys(clusters).length }, + })} + + + + {successful > 0 ? ( + + + + ) : null} + + {partial > 0 ? ( + + + + ) : null} + + {skipped > 0 ? ( + + + + ) : null} + + {failed > 0 ? ( + + + + ) : null} + + +
+ + ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/gradient.test.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/gradient.test.ts new file mode 100644 index 0000000000000..fba44c72998ea --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/gradient.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { getHeathBarLinearGradient, HEALTH_HEX_CODES } from './gradient'; + +describe('getHeathBarLinearGradient', () => { + test('should return linear-gradient with percentages for each status', () => { + expect(getHeathBarLinearGradient(5, 1, 1, 2)).toBe( + `linear-gradient(to right, ${HEALTH_HEX_CODES.successful} 0% 56%, ${HEALTH_HEX_CODES.partial} 56% 67%, ${HEALTH_HEX_CODES.skipped} 67% 78%, ${HEALTH_HEX_CODES.failed} 78% 100%)` + ); + }); + + test('should return linear-gradient with percentages for each status with count above zero', () => { + expect(getHeathBarLinearGradient(5, 0, 0, 2)).toBe( + `linear-gradient(to right, ${HEALTH_HEX_CODES.successful} 0% 71%, ${HEALTH_HEX_CODES.failed} 71% 100%)` + ); + }); +}); diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/gradient.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/gradient.ts new file mode 100644 index 0000000000000..671819e31b3a7 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/gradient.ts @@ -0,0 +1,51 @@ +/* + * 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 { euiThemeVars } from '@kbn/ui-theme'; + +export const HEALTH_HEX_CODES = { + successful: euiThemeVars.euiColorSuccess, + partial: euiThemeVars.euiColorWarning, + skipped: '#DA8B45', + failed: euiThemeVars.euiColorDanger, +}; + +export function getHeathBarLinearGradient( + successful: number, + partial: number, + skipped: number, + failed: number +) { + const total = successful + partial + skipped + failed; + const stops: string[] = []; + let startPercent: number = 0; + + function addStop(value: number, color: string) { + if (value <= 0) { + return; + } + + const percent = Math.round((value / total) * 100); + const endPercent = startPercent + percent; + stops.push(`${color} ${startPercent}% ${endPercent}%`); + startPercent = endPercent; + } + + addStop(successful, HEALTH_HEX_CODES.successful); + addStop(partial, HEALTH_HEX_CODES.partial); + addStop(skipped, HEALTH_HEX_CODES.skipped); + addStop(failed, HEALTH_HEX_CODES.failed); + + const printedStops = stops + .map((stop, index) => { + return index === stops.length - 1 ? stop : stop + ', '; + }) + .join(''); + + return `linear-gradient(to right, ${printedStops})`; +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/index.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/index.ts new file mode 100644 index 0000000000000..d821f0b9f7b10 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_health/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { ClustersHealth } from './clusters_health'; +export { ClusterHealth } from './cluster_health'; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/__snapshots__/cluster_view.test.tsx.snap b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/__snapshots__/cluster_view.test.tsx.snap new file mode 100644 index 0000000000000..66f1352ce8aab --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/__snapshots__/cluster_view.test.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render partial should display callout when request timed out 1`] = ` + + + + +`; + +exports[`render partial should show view shard failure button when there are shard failures 1`] = ` + + + +`; + +exports[`render should display success 1`] = ` + + + +`; + +exports[`render skipped or failed should display callout when cluster is unavailable 1`] = ` + + +

+ no_such_remote_cluster_exception: "no such remote cluster: [remote1]" +

+
+ +
+`; + +exports[`render skipped or failed should display callout with view failed shards button when all shards fail 1`] = ` + + +

+ search_phase_execution_exception: "all shards failed" +

+ +
+ +
+`; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/cluster_view.test.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/cluster_view.test.tsx new file mode 100644 index 0000000000000..762896bfb20d7 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/cluster_view.test.tsx @@ -0,0 +1,162 @@ +/* + * 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 { shallow } from 'enzyme'; +import type { ClusterDetails } from '@kbn/es-types'; +import { ClusterView } from './cluster_view'; + +describe('render', () => { + test('should display success', () => { + const clusterDetails = { + status: 'successful', + indices: 'kibana_sample_data*', + took: 10005, + timed_out: false, + _shards: { + total: 3, + successful: 3, + skipped: 0, + failed: 0, + }, + } as ClusterDetails; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + describe('partial', () => { + test('should show view shard failure button when there are shard failures', () => { + const clusterDetails = { + status: 'partial', + indices: 'kibana_sample_data_logs,kibana_sample_data_flights', + took: 5, + timed_out: false, + _shards: { + total: 2, + successful: 1, + skipped: 0, + failed: 1, + }, + failures: [ + { + shard: 0, + index: 'remote1:.ds-kibana_sample_data_logs-2023.08.21-000001', + node: 'NVzFRd6SS4qT9o0k2vIzlg', + reason: { + type: 'query_shard_exception', + reason: + 'failed to create query: [.ds-kibana_sample_data_logs-2023.08.21-000001][0] local shard failure message 123', + index_uuid: 'z1sPO8E4TdWcijNgsL_BxQ', + index: 'remote1:.ds-kibana_sample_data_logs-2023.08.21-000001', + caused_by: { + type: 'runtime_exception', + reason: + 'runtime_exception: [.ds-kibana_sample_data_logs-2023.08.21-000001][0] local shard failure message 123', + }, + }, + }, + ], + } as ClusterDetails; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should display callout when request timed out', () => { + const clusterDetails = { + status: 'partial', + indices: 'kibana_sample_data*', + took: 10005, + timed_out: true, + _shards: { + total: 3, + successful: 3, + skipped: 0, + failed: 0, + }, + } as ClusterDetails; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('skipped or failed', () => { + test('should display callout when cluster is unavailable', () => { + const clusterDetails = { + status: 'skipped', + indices: 'kibana_sample_data*', + timed_out: false, + failures: [ + { + shard: -1, + index: null, + reason: { + type: 'no_such_remote_cluster_exception', + reason: 'no such remote cluster: [remote1]', + }, + }, + ], + } as unknown as ClusterDetails; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should display callout with view failed shards button when all shards fail', () => { + const clusterDetails = { + status: 'skipped', + indices: 'kibana_sample_data*', + timed_out: false, + failures: [ + { + shard: -1, + index: null, + reason: { + type: 'search_phase_execution_exception', + reason: 'all shards failed', + phase: 'query', + grouped: true, + failed_shards: [ + { + shard: 0, + index: 'remote1:.ds-kibana_sample_data_logs-2023.09.21-000001', + node: '_JVoOnN5QKidGGXFJAlgpA', + reason: { + type: 'query_shard_exception', + reason: + 'failed to create query: [.ds-kibana_sample_data_logs-2023.09.21-000001][0] local shard failure message 123', + index_uuid: 'PAa7v-dKRIyo4kv6b8dxkQ', + index: 'remote1:.ds-kibana_sample_data_logs-2023.09.21-000001', + caused_by: { + type: 'runtime_exception', + reason: + 'runtime_exception: [.ds-kibana_sample_data_logs-2023.09.21-000001][0] local shard failure message 123', + }, + }, + }, + ], + caused_by: { + type: 'query_shard_exception', + reason: + 'failed to create query: [.ds-kibana_sample_data_logs-2023.09.21-000001][0] local shard failure message 123', + index_uuid: 'PAa7v-dKRIyo4kv6b8dxkQ', + index: 'remote1:.ds-kibana_sample_data_logs-2023.09.21-000001', + caused_by: { + type: 'runtime_exception', + reason: + 'runtime_exception: [.ds-kibana_sample_data_logs-2023.09.21-000001][0] local shard failure message 123', + }, + }, + }, + }, + ], + } as unknown as ClusterDetails; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/cluster_view.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/cluster_view.tsx new file mode 100644 index 0000000000000..ef0509110153e --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/cluster_view.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import type { ClusterDetails } from '@kbn/es-types'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { ShardsView } from './shards_view'; +import { OpenShardFailureFlyoutButton } from './shards_view'; + +interface Props { + clusterDetails: ClusterDetails; +} + +export function ClusterView({ clusterDetails }: Props) { + const clusterFailure = (clusterDetails.failures ?? []).find((failure) => { + return failure.shard < 0; + }); + const shardFailures = (clusterDetails.failures ?? []).filter((failure) => { + return failure.shard >= 0; + }); + + return ( + + {clusterDetails.timed_out ? ( + + ) : null} + + {clusterFailure ? ( + +

+ {clusterFailure.reason.reason + ? `${clusterFailure.reason.type}: "${clusterFailure.reason.reason}"` + : clusterFailure.reason.type} +

+ {clusterFailure.reason.failed_shards ? ( + + ) : null} +
+ ) : null} + + +
+ ); +} 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 new file mode 100644 index 0000000000000..c9fea1a470f49 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/clusters_table.tsx @@ -0,0 +1,126 @@ +/* + * 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, { 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 { ClusterView } from './cluster_view'; +import { ClusterHealth } from '../clusters_health'; +import { LOCAL_CLUSTER_KEY } from '../local_cluster'; + +function getInitialExpandedRow(clusters: Record) { + const clusterNames = Object.keys(clusters); + return clusterNames.length === 1 + ? { [clusterNames[0]]: } + : {}; +} + +interface ClusterColumn { + name: string; + status: string; + responseTime?: number; +} + +interface Props { + clusters: Record; +} + +export function ClustersTable({ clusters }: Props) { + const [expandedRows, setExpandedRows] = useState>( + getInitialExpandedRow(clusters) + ); + + const toggleDetails = (name: string) => { + const nextExpandedRows = { ...expandedRows }; + if (name in nextExpandedRows) { + delete nextExpandedRows[name]; + } else { + nextExpandedRows[name] = ; + } + setExpandedRows(nextExpandedRows); + }; + + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('inspector.requests.clusters.table.nameLabel', { + defaultMessage: 'Name', + }), + render: (name: string) => { + return ( + <> + toggleDetails(name)} + aria-label={ + name in expandedRows + ? i18n.translate('inspector.requests.clusters.table.collapseRow', { + defaultMessage: 'Collapse table row to hide cluster details', + }) + : i18n.translate('inspector.requests.clusters.table.expandRow', { + defaultMessage: 'Expand table row to view cluster details', + }) + } + iconType={name in expandedRows ? 'arrowDown' : 'arrowRight'} + /> + + {name === LOCAL_CLUSTER_KEY + ? i18n.translate('inspector.requests.clusters.table.localClusterDisplayName', { + defaultMessage: 'Local cluster', + }) + : name} + + + ); + }, + width: '60%', + }, + { + field: 'status', + name: i18n.translate('inspector.requests.clusters.table.statusLabel', { + defaultMessage: 'Status', + }), + render: (status: string) => { + return ; + }, + }, + { + align: 'right' as 'right', + field: 'responseTime', + name: i18n.translate('inspector.requests.clusters.table.responseTimeLabel', { + defaultMessage: 'Response time', + }), + render: (responseTime: number | undefined) => ( + + {responseTime + ? i18n.translate('inspector.requests.clusters.table.responseTimeInMilliseconds', { + defaultMessage: '{responseTime}ms', + values: { responseTime }, + }) + : null} + + ), + }, + ]; + + return ( + { + return { + name: key, + status: clusters[key].status, + responseTime: clusters[key].took, + }; + })} + isExpandable={true} + itemIdToExpandedRowMap={expandedRows} + itemId="name" + columns={columns} + /> + ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/index.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/index.ts new file mode 100644 index 0000000000000..33825ff4adda2 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +export { ClustersTable } from './clusters_table'; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/__snapshots__/shards_view.test.tsx.snap b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/__snapshots__/shards_view.test.tsx.snap new file mode 100644 index 0000000000000..d361437fd8e91 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/__snapshots__/shards_view.test.tsx.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render should not render when no shard details are provided 1`] = `""`; + +exports[`render should render with failures 1`] = ` + + + + +

+ Shards +

+
+
+ + + +
+ + + 2 total shards + + + 1 of 2 successful + + +
+`; + +exports[`render should render with no failures 1`] = ` + + + + +

+ Shards +

+
+
+ + + +
+ + + 2 total shards + + + 2 of 2 successful + + +
+`; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/index.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/index.ts new file mode 100644 index 0000000000000..5c3988d6ce091 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export { ShardsView } from './shards_view'; +export { OpenShardFailureFlyoutButton } from './open_shard_failure_flyout_button'; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/open_shard_failure_flyout_button.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/open_shard_failure_flyout_button.tsx new file mode 100644 index 0000000000000..dc90a1873b59b --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/open_shard_failure_flyout_button.tsx @@ -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 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, { useState } from 'react'; +import { estypes } from '@elastic/elasticsearch'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { ShardFailureFlyout } from './shard_failure_flyout'; + +interface Props { + failures: estypes.ShardFailure[]; +} + +export function OpenShardFailureFlyoutButton({ failures }: Props) { + const [showFailures, setShowFailures] = useState(false); + + return ( + <> + {failures.length ? ( + { + setShowFailures(!showFailures); + }} + size="xs" + > + {i18n.translate('inspector.requests.clusters.shards.openShardFailureFlyoutButtonLabel', { + defaultMessage: + 'View {failedShardCount} failed {failedShardCount, plural, one {shard} other {shards}}', + values: { failedShardCount: failures.length }, + })} + + ) : null} + + {showFailures ? ( + { + setShowFailures(false); + }} + /> + ) : null} + + ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_details.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_details.tsx new file mode 100644 index 0000000000000..e6f767f5d9d7a --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_details.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { estypes } from '@elastic/elasticsearch'; +import { i18n } from '@kbn/i18n'; +import { EuiDescriptionList, EuiCodeBlock, EuiText } from '@elastic/eui'; +import { getFlattenedObject } from '@kbn/std'; + +/** + * Provides pretty formatting of a given key string + * e.g. formats "this_key.is_nice" to "This key is nice" + * @param key + */ +export function formatKey(key: string): string { + const nameCapitalized = key.charAt(0).toUpperCase() + key.slice(1); + return nameCapitalized.replace(/[\._]/g, ' '); +} +/** + * Adds a EuiCodeBlock to values of `script` and `script_stack` key + * Values of other keys are handled a strings + * @param value + * @param key + */ +export function formatValueByKey(value: unknown, key: string): JSX.Element { + if (key === 'script' || key === 'script_stack') { + const valueScript = Array.isArray(value) ? value.join('\n') : String(value); + return ( + + {valueScript} + + ); + } + + return {String(value)}; +} + +interface Props { + failure: estypes.ShardFailure; +} + +export function ShardFailureDetails({ failure }: Props) { + const flattendReason = getFlattenedObject(failure.reason); + + const reasonItems = Object.entries(flattendReason) + .filter(([key]) => key !== 'type') + .map(([key, value]) => ({ + title: formatKey(key), + description: formatValueByKey(value, key), + })); + + const items = [ + { + title: i18n.translate('inspector.requests.clusters.shards.details.nodeLabel', { + defaultMessage: 'Node', + }), + description: formatValueByKey(failure.node, 'node'), + }, + ...reasonItems, + ]; + + return ( + + + + ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_flyout.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_flyout.tsx new file mode 100644 index 0000000000000..8749f9764fe74 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_flyout.tsx @@ -0,0 +1,57 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiButtonEmpty, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { ShardFailureTable } from './shard_failure_table'; + +interface Props { + failures: estypes.ShardFailure[]; + onClose: () => void; +} + +export function ShardFailureFlyout({ failures, onClose }: Props) { + return ( + + + +

+ + {i18n.translate('inspector.requests.clusters.shards.flyoutTitle', { + defaultMessage: + '{failedShardCount} failured {failedShardCount, plural, one {shard} other {shards}}', + values: { failedShardCount: failures.length }, + })} +

+
+
+ + + + + + + + {i18n.translate('inspector.requests.clusters.shards.backButtonLabel', { + defaultMessage: 'Back', + })} + + +
+ ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_table.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_table.tsx new file mode 100644 index 0000000000000..344b17b38a741 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shard_failure_table.tsx @@ -0,0 +1,117 @@ +/* + * 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, { useState, ReactNode } from 'react'; +import { estypes } from '@elastic/elasticsearch'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, type EuiBasicTableColumn, EuiButtonIcon, EuiText } from '@elastic/eui'; +import { ShardFailureDetails } from './shard_failure_details'; + +function getRowId(failure: estypes.ShardFailure) { + return `${failure.shard}${failure.index}`; +} + +interface ShardRow { + rowId: string; + shard: number; + index?: string; + failureType: string; +} + +interface Props { + failures: estypes.ShardFailure[]; +} + +export function ShardFailureTable({ failures }: Props) { + const [expandedRows, setExpandedRows] = useState>({}); + + const toggleDetails = (rowId: string) => { + const nextExpandedRows = { ...expandedRows }; + if (rowId in nextExpandedRows) { + delete nextExpandedRows[rowId]; + } else { + const shardFailure = failures.find((failure) => rowId === getRowId(failure)); + nextExpandedRows[rowId] = shardFailure ? ( + + ) : null; + } + setExpandedRows(nextExpandedRows); + }; + + const columns: Array> = [ + { + field: 'shard', + name: i18n.translate('inspector.requests.clusters.shards.table.shardLabel', { + defaultMessage: 'Shard', + }), + render: (shard: number, item: ShardRow) => { + return ( + <> + toggleDetails(item.rowId)} + aria-label={ + item.rowId in expandedRows + ? i18n.translate('inspector.requests.clusters.shards.table.collapseRow', { + defaultMessage: 'Collapse table row to hide shard details', + }) + : i18n.translate('inspector.requests.clusters.shards.table.expandRow', { + defaultMessage: 'Expand table row to view shard details', + }) + } + iconType={item.rowId in expandedRows ? 'arrowDown' : 'arrowRight'} + /> + + {shard} + + + ); + }, + width: '20%', + }, + { + field: 'index', + name: i18n.translate('inspector.requests.clusters.shards.table.indexLabel', { + defaultMessage: 'Index', + }), + render: (index?: string) => + index ? ( + + {index} + + ) : null, + }, + { + field: 'failureType', + name: i18n.translate('inspector.requests.clusters.shards.table.failureTypeLabel', { + defaultMessage: 'Failure type', + }), + render: (failureType: string) => ( + + {failureType} + + ), + }, + ]; + + return ( + { + return { + rowId: getRowId(failure), + shard: failure.shard, + index: failure.index, + failureType: failure.reason.type, + }; + })} + isExpandable={true} + itemIdToExpandedRowMap={expandedRows} + itemId="rowId" + columns={columns} + /> + ); +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shards_view.test.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shards_view.test.tsx new file mode 100644 index 0000000000000..14a69c03269f1 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shards_view.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { shallow } from 'enzyme'; +import { ShardsView } from './shards_view'; + +describe('render', () => { + test('should render with no failures', () => { + const shardStats = { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should render with failures', () => { + const shardStats = { + total: 2, + successful: 1, + skipped: 0, + failed: 1, + }; + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + test('should not render when no shard details are provided', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shards_view.tsx b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shards_view.tsx new file mode 100644 index 0000000000000..479a71817d96c --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_table/shards_view/shards_view.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 { estypes } from '@elastic/elasticsearch'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { OpenShardFailureFlyoutButton } from './open_shard_failure_flyout_button'; + +interface Props { + failures: estypes.ShardFailure[]; + shardStats?: estypes.ShardStatistics; +} + +export function ShardsView({ failures, shardStats }: Props) { + return !shardStats && failures.length === 0 ? null : ( + <> + + + +

+ {i18n.translate('inspector.requests.clusters.shards.shardsTitle', { + defaultMessage: 'Shards', + })} +

+
+
+ + + + +
+ + {shardStats ? ( + + + {i18n.translate('inspector.requests.clusters.shards.totalShardsLabel', { + defaultMessage: '{total} total shards', + values: { total: shardStats.total }, + })} + + + {i18n.translate('inspector.requests.clusters.shards.successfulShardsLabel', { + defaultMessage: '{successful} of {total} successful', + values: { + successful: shardStats.successful, + total: shardStats.total, + }, + })} + + + ) : null} + + ); +} 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 new file mode 100644 index 0000000000000..971c3bad5bef8 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 { shallow } from 'enzyme'; +import { ClustersView } from './clusters_view'; +import { Request } from '../../../../../../common/adapters/request/types'; + +describe('shouldShow', () => { + test('should return true when response contains _shards', () => { + const request = { + response: { + json: { + rawResponse: { + _shards: {}, + }, + }, + }, + } as unknown as Request; + expect(ClustersView.shouldShow(request)).toBe(true); + }); + + test('should return true when response contains _clusters', () => { + const request = { + response: { + json: { + rawResponse: { + _clusters: {}, + }, + }, + }, + } as unknown as Request; + expect(ClustersView.shouldShow(request)).toBe(true); + }); + + test('should return false when response does not contains _shards or _clusters', () => { + const request = { + response: { + json: { + rawResponse: {}, + }, + }, + } as unknown as Request; + expect(ClustersView.shouldShow(request)).toBe(false); + }); +}); + +describe('render', () => { + test('should render local cluster details from _shards', () => { + const request = { + response: { + json: { + rawResponse: { + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + }, + }, + }, + } as unknown as Request; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should render local and remote cluster details from _clusters', () => { + const request = { + response: { + json: { + rawResponse: { + _clusters: { + total: 2, + successful: 2, + skipped: 0, + details: { + '(local)': { + status: 'successful', + indices: 'kibana_sample_data_logs,kibana_sample_data_flights', + took: 0, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + }, + remote1: { + status: 'successful', + indices: 'kibana_sample_data_logs,kibana_sample_data_flights', + took: 1, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + }, + }, + }, + }, + }, + }, + } as unknown as Request; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); +}); 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 new file mode 100644 index 0000000000000..7621bbf842c8d --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/clusters_view.tsx @@ -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 React, { Component } from 'react'; +import { estypes } from '@elastic/elasticsearch'; +import { EuiSpacer } from '@elastic/eui'; +import type { ClusterDetails } from '@kbn/es-types'; +import { Request } from '../../../../../../common/adapters/request/types'; +import type { RequestDetailsProps } from '../../types'; +import { getLocalClusterDetails, LOCAL_CLUSTER_KEY } from './local_cluster'; +import { ClustersHealth } from './clusters_health'; +import { ClustersTable } from './clusters_table'; + +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; + } + + const clusters = rawResponse._clusters + ? ( + rawResponse._clusters as estypes.ClusterStatistics & { + details: Record; + } + ).details + : { + [LOCAL_CLUSTER_KEY]: getLocalClusterDetails(rawResponse), + }; + + return this.props.request.response?.json ? ( + <> + + + + + ) : null; + } +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/index.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/index.ts new file mode 100644 index 0000000000000..f3eb2d611b0d7 --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +export { ClustersView } from './clusters_view'; diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/local_cluster.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/local_cluster.ts new file mode 100644 index 0000000000000..aac2266500bab --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/local_cluster.ts @@ -0,0 +1,39 @@ +/* + * 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'; + +export const LOCAL_CLUSTER_KEY = '(local)'; + +function getLocalClusterStatus(rawResponse: estypes.SearchResponse): ClusterDetails['status'] { + if (rawResponse._shards?.successful === 0) { + return 'failed'; + } + + if (rawResponse.timed_out || rawResponse._shards.failed) { + return 'partial'; + } + + return 'successful'; +} + +export function getLocalClusterDetails(rawResponse: estypes.SearchResponse) { + const shards = { + ...rawResponse._shards, + }; + delete shards.failures; + return { + status: getLocalClusterStatus(rawResponse), + indices: '', + took: rawResponse.took, + timed_out: rawResponse.timed_out, + _shards: shards, + failures: rawResponse._shards.failures, + }; +} diff --git a/src/plugins/inspector/public/views/requests/components/details/clusters_view/local_clusters.test.ts b/src/plugins/inspector/public/views/requests/components/details/clusters_view/local_clusters.test.ts new file mode 100644 index 0000000000000..9ef80260b3a3f --- /dev/null +++ b/src/plugins/inspector/public/views/requests/components/details/clusters_view/local_clusters.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { getLocalClusterDetails } from './local_cluster'; + +describe('getLocalClusterDetails', () => { + test('should convert local cluster SearchResponseBody into ClusterDetails', () => { + expect( + getLocalClusterDetails({ + took: 14, + timed_out: false, + _shards: { + total: 2, + successful: 1, + skipped: 0, + failed: 1, + failures: [ + { + shard: 0, + index: '.ds-kibana_sample_data_logs-2023.09.20-000001', + node: 'tGUEVPsHR4uhEAdL0oANsA', + reason: { + type: 'query_shard_exception', + reason: + 'failed to create query: [.ds-kibana_sample_data_logs-2023.09.20-000001][0] local shard failure message 123', + index_uuid: 'z31al9BiSk2prpzZED-hTA', + index: '.ds-kibana_sample_data_logs-2023.09.20-000001', + caused_by: { + type: 'runtime_exception', + reason: + '[.ds-kibana_sample_data_logs-2023.09.20-000001][0] local shard failure message 123', + }, + }, + }, + ], + }, + } as unknown as estypes.SearchResponse) + ).toEqual({ + _shards: { + failed: 1, + skipped: 0, + successful: 1, + total: 2, + }, + failures: [ + { + index: '.ds-kibana_sample_data_logs-2023.09.20-000001', + node: 'tGUEVPsHR4uhEAdL0oANsA', + reason: { + caused_by: { + reason: + '[.ds-kibana_sample_data_logs-2023.09.20-000001][0] local shard failure message 123', + type: 'runtime_exception', + }, + index: '.ds-kibana_sample_data_logs-2023.09.20-000001', + index_uuid: 'z31al9BiSk2prpzZED-hTA', + reason: + 'failed to create query: [.ds-kibana_sample_data_logs-2023.09.20-000001][0] local shard failure message 123', + type: 'query_shard_exception', + }, + shard: 0, + }, + ], + indices: '', + status: 'partial', + timed_out: false, + took: 14, + }); + }); +}); diff --git a/src/plugins/inspector/public/views/requests/components/details/index.ts b/src/plugins/inspector/public/views/requests/components/details/index.ts index 61bc021873e6a..49356d695f47d 100644 --- a/src/plugins/inspector/public/views/requests/components/details/index.ts +++ b/src/plugins/inspector/public/views/requests/components/details/index.ts @@ -9,3 +9,4 @@ export { RequestDetailsRequest } from './req_details_request'; export { RequestDetailsResponse } from './req_details_response'; export { RequestDetailsStats } from './req_details_stats'; +export { ClustersView } from './clusters_view'; 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 7292456f848df..6130e9e33390a 100644 --- a/src/plugins/inspector/public/views/requests/components/request_details.tsx +++ b/src/plugins/inspector/public/views/requests/components/request_details.tsx @@ -11,7 +11,12 @@ import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { EuiTab, EuiTabs } from '@elastic/eui'; -import { RequestDetailsRequest, RequestDetailsResponse, RequestDetailsStats } from './details'; +import { + ClustersView, + RequestDetailsRequest, + RequestDetailsResponse, + RequestDetailsStats, +} from './details'; import { RequestDetailsProps } from './types'; interface RequestDetailsState { @@ -33,6 +38,13 @@ const DETAILS: DetailViewData[] = [ }), component: RequestDetailsStats, }, + { + name: 'clusters', + label: i18n.translate('inspector.requests.clustersTabLabel', { + defaultMessage: 'Clusters', + }), + component: ClustersView, + }, { name: 'Request', label: i18n.translate('inspector.requests.requestTabLabel', { diff --git a/src/plugins/inspector/tsconfig.json b/src/plugins/inspector/tsconfig.json index 3417096596015..9c5654be26249 100644 --- a/src/plugins/inspector/tsconfig.json +++ b/src/plugins/inspector/tsconfig.json @@ -14,6 +14,9 @@ "@kbn/monaco", "@kbn/core-ui-settings-browser-mocks", "@kbn/core-ui-settings-browser", + "@kbn/std", + "@kbn/es-types", + "@kbn/ui-theme" ], "exclude": [ "target/**/*",