mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[inspector] clusters tab MVP (#166025)
Closes https://github.com/elastic/kibana/issues/166021 Closes https://github.com/elastic/kibana/issues/163381 This PR adds inspector cluster tab MVP This PR does not: 1) include all UI elements in design. These will be added at later dates. 2) show clusters tab when request fails. Somewhere between kibana server elasticsearch request and the client, the raw response is getting removed for failed requests. This will have to be sorted out in a separate PR. 3) Opening clusters tab from "incomplete data" warnings ### Test setup 1. Start remote elasticsearch by running: `yarn es snapshot -E transport.port=9500 -E http.port=9201 -E path.data=../remote1` 2. Install sample data to remote cluster 1. Add `elasticsearch.hosts: ["http://localhost:9201"]` to kibana.dev.yml. **Note** create `config/kibana.dev.yml` if one does not exist. kibana.dev.yml is not managed by git so it has to be created the first time you add values. 2. run `yarn start` to start kibana process 3. install sample web logs data set on home page 4. install sample flight data set on home page 5. stop kibana process 6. remove `elasticsearch.hosts` from kibana.dev.yml 3. Start local elasticsearch by running: `yarn es snapshot -E path.data=../local1` 4. Start kibana 5. Add remote cluster under "Stack management -> Remote clusters" 1. Set **Name** to "remote1" 2. Set **Seed nodes** to "localhost:9500" 3. Enable **Skip if unavailable** 5. install sample web logs data set 6. install sample flights data set 7. Create data view. 1. Set **Index pattern** to `kibana_sample_data*,remote1:kibana_sample_data*` 2. Set **Time field** to `timestamp` ### Local cluster (status=successful) 1) Open discover 2) Select "Kibana sample data logs" data view 3) Open inspector 4) Open clusters tab <img width="300" alt="Screen Shot 2023-09-22 at 9 38 38 AM" src="e4e91555
-8200-43bc-b2fe-7739f7178e43"> ### Remote cluster (status=successful) 1) Open discover 2) Select "kibana_sample_data*,remote1:kibana_sample_data*" data view 3) Open inspector 4) Open clusters tab <img width="300" alt="Screen Shot 2023-09-22 at 9 47 08 AM" src="676897fc
-e7e2-4c0b-8e35-c382c6ac89d6"> ### Remote cluster (status=partial, failed shard) 1) Open discover 2) Select "kibana_sample_data*,remote1:kibana_sample_data*" data view 3) Add filter ``` { "error_query": { "indices": [ { "error_type": "exception", "message": "local shard failure message 123", "name": "remote1:kibana_sample_data_logs", "shard_ids": [ 0 ] } ] } } ``` 3) Open inspector 4) Open clusters tab <img width="300" alt="Screen Shot 2023-09-22 at 9 50 49 AM" src="6935f2b4
-60ad-4704-8ee0-17890ca9d83a"> <img width="300" alt="Screen Shot 2023-09-22 at 9 51 12 AM" src="ec0a6b4a
-177f-40fd-96b3-c56102d5f425"> ### Remote cluster (status=skipped, all shards fail) 1) Open discover 2) Select "kibana_sample_data*,remote1:kibana_sample_data*" data view 3) Add filter ``` { "error_query": { "indices": [ { "error_type": "exception", "message": "local shard failure message 123", "name": "remote1:*", "shard_ids": [ 0 ] } ] } } ``` 3) Open inspector 4) Open clusters tab <img width="300" alt="Screen Shot 2023-09-22 at 9 52 49 AM" src="a1ba947b
-3cd1-4416-9756-29f3960a4ba6"> ### Remote cluster (status=skipped, no remote) 1) Open discover 2) Kill process running remote1 elasticsearch 3) Select "kibana_sample_data*,remote1:kibana_sample_data*" data view 4) Open inspector 5) Open clusters tab <img width="300" alt="Screen Shot 2023-09-22 at 9 55 45 AM" src="f049b617
-96e1-4ecc-bfeb-f75522f70fef"> --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
ed8225f7bc
commit
7b3a8f842f
27 changed files with 1681 additions and 1 deletions
|
@ -0,0 +1,115 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`render should render local and remote cluster details from _clusters 1`] = `
|
||||
<Fragment>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<ClustersHealth
|
||||
clusters={
|
||||
Object {
|
||||
"(local)": Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 2,
|
||||
"total": 2,
|
||||
},
|
||||
"indices": "kibana_sample_data_logs,kibana_sample_data_flights",
|
||||
"status": "successful",
|
||||
"timed_out": false,
|
||||
"took": 0,
|
||||
},
|
||||
"remote1": Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 2,
|
||||
"total": 2,
|
||||
},
|
||||
"indices": "kibana_sample_data_logs,kibana_sample_data_flights",
|
||||
"status": "successful",
|
||||
"timed_out": false,
|
||||
"took": 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<ClustersTable
|
||||
clusters={
|
||||
Object {
|
||||
"(local)": Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 2,
|
||||
"total": 2,
|
||||
},
|
||||
"indices": "kibana_sample_data_logs,kibana_sample_data_flights",
|
||||
"status": "successful",
|
||||
"timed_out": false,
|
||||
"took": 0,
|
||||
},
|
||||
"remote1": Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 2,
|
||||
"total": 2,
|
||||
},
|
||||
"indices": "kibana_sample_data_logs,kibana_sample_data_flights",
|
||||
"status": "successful",
|
||||
"timed_out": false,
|
||||
"took": 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`render should render local cluster details from _shards 1`] = `
|
||||
<Fragment>
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
<ClustersHealth
|
||||
clusters={
|
||||
Object {
|
||||
"(local)": Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 2,
|
||||
"total": 2,
|
||||
},
|
||||
"failures": undefined,
|
||||
"indices": "",
|
||||
"status": "successful",
|
||||
"timed_out": undefined,
|
||||
"took": undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<ClustersTable
|
||||
clusters={
|
||||
Object {
|
||||
"(local)": Object {
|
||||
"_shards": Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 2,
|
||||
"total": 2,
|
||||
},
|
||||
"failures": undefined,
|
||||
"indices": "",
|
||||
"status": "successful",
|
||||
"timed_out": undefined,
|
||||
"took": undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
`;
|
|
@ -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 (
|
||||
<EuiHealth color={color}>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{label}
|
||||
</EuiText>
|
||||
</EuiHealth>
|
||||
);
|
||||
}
|
|
@ -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<string, ClusterDetails>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{i18n.translate('inspector.requests.clusters.totalClustersLabel', {
|
||||
defaultMessage: '{total} {total, plural, one {cluster} other {clusters}}',
|
||||
values: { total: Object.keys(clusters).length },
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
{successful > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ClusterHealth count={successful} status="successful" />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{partial > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ClusterHealth count={partial} status="partial" />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{skipped > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ClusterHealth count={skipped} status="skipped" />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
|
||||
{failed > 0 ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<ClusterHealth count={failed} status="failed" />
|
||||
</EuiFlexItem>
|
||||
) : null}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<div
|
||||
css={css`
|
||||
background: ${getHeathBarLinearGradient(successful, partial, skipped, failed)};
|
||||
border-radius: ${euiThemeVars.euiBorderRadiusSmall};
|
||||
height: ${euiThemeVars.euiSizeS};
|
||||
margin-top: ${euiThemeVars.euiSizeXS};
|
||||
margin-bottom: ${euiThemeVars.euiSizeS};
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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%)`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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})`;
|
||||
}
|
|
@ -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';
|
|
@ -0,0 +1,165 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`render partial should display callout when request timed out 1`] = `
|
||||
<EuiText
|
||||
size="xs"
|
||||
style={
|
||||
Object {
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
title="Request timed out before completion. Results may be incomplete or empty."
|
||||
/>
|
||||
<ShardsView
|
||||
failures={Array []}
|
||||
shardStats={
|
||||
Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 3,
|
||||
"total": 3,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiText>
|
||||
`;
|
||||
|
||||
exports[`render partial should show view shard failure button when there are shard failures 1`] = `
|
||||
<EuiText
|
||||
size="xs"
|
||||
style={
|
||||
Object {
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<ShardsView
|
||||
failures={
|
||||
Array [
|
||||
Object {
|
||||
"index": "remote1:.ds-kibana_sample_data_logs-2023.08.21-000001",
|
||||
"node": "NVzFRd6SS4qT9o0k2vIzlg",
|
||||
"reason": Object {
|
||||
"caused_by": Object {
|
||||
"reason": "runtime_exception: [.ds-kibana_sample_data_logs-2023.08.21-000001][0] local shard failure message 123",
|
||||
"type": "runtime_exception",
|
||||
},
|
||||
"index": "remote1:.ds-kibana_sample_data_logs-2023.08.21-000001",
|
||||
"index_uuid": "z1sPO8E4TdWcijNgsL_BxQ",
|
||||
"reason": "failed to create query: [.ds-kibana_sample_data_logs-2023.08.21-000001][0] local shard failure message 123",
|
||||
"type": "query_shard_exception",
|
||||
},
|
||||
"shard": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
shardStats={
|
||||
Object {
|
||||
"failed": 1,
|
||||
"skipped": 0,
|
||||
"successful": 1,
|
||||
"total": 2,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiText>
|
||||
`;
|
||||
|
||||
exports[`render should display success 1`] = `
|
||||
<EuiText
|
||||
size="xs"
|
||||
style={
|
||||
Object {
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<ShardsView
|
||||
failures={Array []}
|
||||
shardStats={
|
||||
Object {
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"successful": 3,
|
||||
"total": 3,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</EuiText>
|
||||
`;
|
||||
|
||||
exports[`render skipped or failed should display callout when cluster is unavailable 1`] = `
|
||||
<EuiText
|
||||
size="xs"
|
||||
style={
|
||||
Object {
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
title="Search failed"
|
||||
>
|
||||
<p>
|
||||
no_such_remote_cluster_exception: "no such remote cluster: [remote1]"
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<ShardsView
|
||||
failures={Array []}
|
||||
/>
|
||||
</EuiText>
|
||||
`;
|
||||
|
||||
exports[`render skipped or failed should display callout with view failed shards button when all shards fail 1`] = `
|
||||
<EuiText
|
||||
size="xs"
|
||||
style={
|
||||
Object {
|
||||
"width": "100%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
iconType="warning"
|
||||
size="s"
|
||||
title="Search failed"
|
||||
>
|
||||
<p>
|
||||
search_phase_execution_exception: "all shards failed"
|
||||
</p>
|
||||
<OpenShardFailureFlyoutButton
|
||||
failures={
|
||||
Array [
|
||||
Object {
|
||||
"index": "remote1:.ds-kibana_sample_data_logs-2023.09.21-000001",
|
||||
"node": "_JVoOnN5QKidGGXFJAlgpA",
|
||||
"reason": Object {
|
||||
"caused_by": Object {
|
||||
"reason": "runtime_exception: [.ds-kibana_sample_data_logs-2023.09.21-000001][0] local shard failure message 123",
|
||||
"type": "runtime_exception",
|
||||
},
|
||||
"index": "remote1:.ds-kibana_sample_data_logs-2023.09.21-000001",
|
||||
"index_uuid": "PAa7v-dKRIyo4kv6b8dxkQ",
|
||||
"reason": "failed to create query: [.ds-kibana_sample_data_logs-2023.09.21-000001][0] local shard failure message 123",
|
||||
"type": "query_shard_exception",
|
||||
},
|
||||
"shard": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
<ShardsView
|
||||
failures={Array []}
|
||||
/>
|
||||
</EuiText>
|
||||
`;
|
|
@ -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(<ClusterView clusterDetails={clusterDetails} />);
|
||||
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(<ClusterView clusterDetails={clusterDetails} />);
|
||||
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(<ClusterView clusterDetails={clusterDetails} />);
|
||||
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(<ClusterView clusterDetails={clusterDetails} />);
|
||||
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(<ClusterView clusterDetails={clusterDetails} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<EuiText style={{ width: '100%' }} size="xs">
|
||||
{clusterDetails.timed_out ? (
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color="warning"
|
||||
title={i18n.translate('inspector.requests.clusters.timedOutMessage', {
|
||||
defaultMessage:
|
||||
'Request timed out before completion. Results may be incomplete or empty.',
|
||||
})}
|
||||
iconType="warning"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clusterFailure ? (
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color="warning"
|
||||
title={i18n.translate('inspector.requests.clusters.failedClusterMessage', {
|
||||
defaultMessage: 'Search failed',
|
||||
})}
|
||||
iconType="warning"
|
||||
>
|
||||
<p>
|
||||
{clusterFailure.reason.reason
|
||||
? `${clusterFailure.reason.type}: "${clusterFailure.reason.reason}"`
|
||||
: clusterFailure.reason.type}
|
||||
</p>
|
||||
{clusterFailure.reason.failed_shards ? (
|
||||
<OpenShardFailureFlyoutButton failures={clusterFailure.reason.failed_shards} />
|
||||
) : null}
|
||||
</EuiCallOut>
|
||||
) : null}
|
||||
|
||||
<ShardsView failures={shardFailures} shardStats={clusterDetails._shards} />
|
||||
</EuiText>
|
||||
);
|
||||
}
|
|
@ -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<string, ClusterDetails>) {
|
||||
const clusterNames = Object.keys(clusters);
|
||||
return clusterNames.length === 1
|
||||
? { [clusterNames[0]]: <ClusterView clusterDetails={clusters[clusterNames[0]]} /> }
|
||||
: {};
|
||||
}
|
||||
|
||||
interface ClusterColumn {
|
||||
name: string;
|
||||
status: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
clusters: Record<string, ClusterDetails>;
|
||||
}
|
||||
|
||||
export function ClustersTable({ clusters }: Props) {
|
||||
const [expandedRows, setExpandedRows] = useState<Record<string, ReactNode>>(
|
||||
getInitialExpandedRow(clusters)
|
||||
);
|
||||
|
||||
const toggleDetails = (name: string) => {
|
||||
const nextExpandedRows = { ...expandedRows };
|
||||
if (name in nextExpandedRows) {
|
||||
delete nextExpandedRows[name];
|
||||
} else {
|
||||
nextExpandedRows[name] = <ClusterView clusterDetails={clusters[name]} />;
|
||||
}
|
||||
setExpandedRows(nextExpandedRows);
|
||||
};
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<ClusterColumn>> = [
|
||||
{
|
||||
field: 'name',
|
||||
name: i18n.translate('inspector.requests.clusters.table.nameLabel', {
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
render: (name: string) => {
|
||||
return (
|
||||
<>
|
||||
<EuiButtonIcon
|
||||
onClick={() => 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'}
|
||||
/>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{name === LOCAL_CLUSTER_KEY
|
||||
? i18n.translate('inspector.requests.clusters.table.localClusterDisplayName', {
|
||||
defaultMessage: 'Local cluster',
|
||||
})
|
||||
: name}
|
||||
</EuiText>
|
||||
</>
|
||||
);
|
||||
},
|
||||
width: '60%',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
name: i18n.translate('inspector.requests.clusters.table.statusLabel', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
render: (status: string) => {
|
||||
return <ClusterHealth status={status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
align: 'right' as 'right',
|
||||
field: 'responseTime',
|
||||
name: i18n.translate('inspector.requests.clusters.table.responseTimeLabel', {
|
||||
defaultMessage: 'Response time',
|
||||
}),
|
||||
render: (responseTime: number | undefined) => (
|
||||
<EuiText size="xs" color="subdued">
|
||||
{responseTime
|
||||
? i18n.translate('inspector.requests.clusters.table.responseTimeInMilliseconds', {
|
||||
defaultMessage: '{responseTime}ms',
|
||||
values: { responseTime },
|
||||
})
|
||||
: null}
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
items={Object.keys(clusters).map((key) => {
|
||||
return {
|
||||
name: key,
|
||||
status: clusters[key].status,
|
||||
responseTime: clusters[key].took,
|
||||
};
|
||||
})}
|
||||
isExpandable={true}
|
||||
itemIdToExpandedRowMap={expandedRows}
|
||||
itemId="name"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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`] = `
|
||||
<Fragment>
|
||||
<EuiFlexGroup
|
||||
justifyContent="spaceBetween"
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiTitle
|
||||
size="xxxs"
|
||||
>
|
||||
<h4>
|
||||
Shards
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<OpenShardFailureFlyoutButton
|
||||
failures={
|
||||
Array [
|
||||
Object {},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup
|
||||
justifyContent="spaceBetween"
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
2 total shards
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
1 of 2 successful
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</Fragment>
|
||||
`;
|
||||
|
||||
exports[`render should render with no failures 1`] = `
|
||||
<Fragment>
|
||||
<EuiFlexGroup
|
||||
justifyContent="spaceBetween"
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<EuiTitle
|
||||
size="xxxs"
|
||||
>
|
||||
<h4>
|
||||
Shards
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
<OpenShardFailureFlyoutButton
|
||||
failures={Array []}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexGroup
|
||||
justifyContent="spaceBetween"
|
||||
>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
2 total shards
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
>
|
||||
2 of 2 successful
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</Fragment>
|
||||
`;
|
|
@ -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';
|
|
@ -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 ? (
|
||||
<EuiButtonEmpty
|
||||
flush="both"
|
||||
onClick={() => {
|
||||
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 },
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
) : null}
|
||||
|
||||
{showFailures ? (
|
||||
<ShardFailureFlyout
|
||||
failures={failures}
|
||||
onClose={() => {
|
||||
setShowFailures(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<EuiCodeBlock language="java" paddingSize="s" isCopyable>
|
||||
{valueScript}
|
||||
</EuiCodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return <EuiText size="xs">{String(value)}</EuiText>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<EuiText size="xs">
|
||||
<EuiDescriptionList
|
||||
type="responsiveColumn"
|
||||
columnWidths={['30%', '70%']}
|
||||
listItems={items}
|
||||
compressed
|
||||
/>
|
||||
</EuiText>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<EuiFlyout onClose={onClose} ownFocus={false} hideCloseButton={true}>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h1>
|
||||
<EuiButtonIcon iconType="sortLeft" onClick={onClose} />
|
||||
{i18n.translate('inspector.requests.clusters.shards.flyoutTitle', {
|
||||
defaultMessage:
|
||||
'{failedShardCount} failured {failedShardCount, plural, one {shard} other {shards}}',
|
||||
values: { failedShardCount: failures.length },
|
||||
})}
|
||||
</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
<ShardFailureTable failures={failures} />
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiButtonEmpty iconType="sortLeft" onClick={onClose} flush="left">
|
||||
{i18n.translate('inspector.requests.clusters.shards.backButtonLabel', {
|
||||
defaultMessage: 'Back',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
}
|
|
@ -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<Record<string, ReactNode>>({});
|
||||
|
||||
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 ? (
|
||||
<ShardFailureDetails failure={shardFailure} />
|
||||
) : null;
|
||||
}
|
||||
setExpandedRows(nextExpandedRows);
|
||||
};
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<ShardRow>> = [
|
||||
{
|
||||
field: 'shard',
|
||||
name: i18n.translate('inspector.requests.clusters.shards.table.shardLabel', {
|
||||
defaultMessage: 'Shard',
|
||||
}),
|
||||
render: (shard: number, item: ShardRow) => {
|
||||
return (
|
||||
<>
|
||||
<EuiButtonIcon
|
||||
onClick={() => 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'}
|
||||
/>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{shard}
|
||||
</EuiText>
|
||||
</>
|
||||
);
|
||||
},
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
field: 'index',
|
||||
name: i18n.translate('inspector.requests.clusters.shards.table.indexLabel', {
|
||||
defaultMessage: 'Index',
|
||||
}),
|
||||
render: (index?: string) =>
|
||||
index ? (
|
||||
<EuiText size="xs" color="subdued">
|
||||
{index}
|
||||
</EuiText>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
field: 'failureType',
|
||||
name: i18n.translate('inspector.requests.clusters.shards.table.failureTypeLabel', {
|
||||
defaultMessage: 'Failure type',
|
||||
}),
|
||||
render: (failureType: string) => (
|
||||
<EuiText size="xs" color="subdued">
|
||||
{failureType}
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
items={failures.map((failure) => {
|
||||
return {
|
||||
rowId: getRowId(failure),
|
||||
shard: failure.shard,
|
||||
index: failure.index,
|
||||
failureType: failure.reason.type,
|
||||
};
|
||||
})}
|
||||
isExpandable={true}
|
||||
itemIdToExpandedRowMap={expandedRows}
|
||||
itemId="rowId"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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(<ShardsView failures={[]} shardStats={shardStats} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should render with failures', () => {
|
||||
const shardStats = {
|
||||
total: 2,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
};
|
||||
const wrapper = shallow(
|
||||
<ShardsView failures={[{} as unknown as estypes.ShardFailure]} shardStats={shardStats} />
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should not render when no shard details are provided', () => {
|
||||
const wrapper = shallow(<ShardsView failures={[]} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -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 : (
|
||||
<>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xxxs">
|
||||
<h4>
|
||||
{i18n.translate('inspector.requests.clusters.shards.shardsTitle', {
|
||||
defaultMessage: 'Shards',
|
||||
})}
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<OpenShardFailureFlyoutButton failures={failures} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
{shardStats ? (
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('inspector.requests.clusters.shards.totalShardsLabel', {
|
||||
defaultMessage: '{total} total shards',
|
||||
values: { total: shardStats.total },
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('inspector.requests.clusters.shards.successfulShardsLabel', {
|
||||
defaultMessage: '{successful} of {total} successful',
|
||||
values: {
|
||||
successful: shardStats.successful,
|
||||
total: shardStats.total,
|
||||
},
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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(<ClustersView request={request} />);
|
||||
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(<ClustersView request={request} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -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<RequestDetailsProps> {
|
||||
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<string, ClusterDetails>;
|
||||
}
|
||||
).details
|
||||
: {
|
||||
[LOCAL_CLUSTER_KEY]: getLocalClusterDetails(rawResponse),
|
||||
};
|
||||
|
||||
return this.props.request.response?.json ? (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<ClustersHealth clusters={clusters} />
|
||||
<ClustersTable clusters={clusters} />
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue