mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Improve the status page user interface (#118512)
* initial improvements * add status expanded row * fix row ordering by status * fix data-test-subj * fix status summary row label * fix load_status unit tests * fix status_table unit tests * fix FTR tests * add server_status tests * add unit test for some of the new components * add unit tests for added libs * i18n for added text * update snapshots * resolve merge conflicts Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
eb81db2ddd
commit
a28419a327
21 changed files with 702 additions and 145 deletions
|
@ -65,3 +65,36 @@ exports[`ServerStatus renders correctly for red state 2`] = `
|
|||
</EuiInnerText>
|
||||
</EuiBadge>
|
||||
`;
|
||||
|
||||
exports[`ServerStatus renders correctly for yellow state 2`] = `
|
||||
<EuiBadge
|
||||
aria-label="Yellow"
|
||||
color="success"
|
||||
data-test-subj="serverStatusTitleBadge"
|
||||
>
|
||||
<EuiInnerText>
|
||||
<span
|
||||
aria-label="Yellow"
|
||||
className="euiBadge euiBadge--iconLeft"
|
||||
data-test-subj="serverStatusTitleBadge"
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "#6dccb1",
|
||||
"color": "#000",
|
||||
}
|
||||
}
|
||||
title="Yellow"
|
||||
>
|
||||
<span
|
||||
className="euiBadge__content"
|
||||
>
|
||||
<span
|
||||
className="euiBadge__text"
|
||||
>
|
||||
Yellow
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</EuiInnerText>
|
||||
</EuiBadge>
|
||||
`;
|
||||
|
|
|
@ -1,31 +1,54 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StatusTable renders when statuses is provided 1`] = `
|
||||
<EuiBasicTable
|
||||
<EuiInMemoryTable
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"align": "center",
|
||||
"field": "state",
|
||||
"name": "",
|
||||
"name": "Status",
|
||||
"render": [Function],
|
||||
"width": "32px",
|
||||
"sortable": [Function],
|
||||
"width": "100px",
|
||||
},
|
||||
Object {
|
||||
"field": "id",
|
||||
"name": "ID",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "state",
|
||||
"name": "Status",
|
||||
"name": "Status summary",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"align": "right",
|
||||
"isExpander": true,
|
||||
"name": <EuiScreenReaderOnly>
|
||||
<FormattedMessage
|
||||
defaultMessage="Expand row"
|
||||
id="core.statusPage.statusTable.columns.expandRowHeader"
|
||||
values={Object {}}
|
||||
/>
|
||||
</EuiScreenReaderOnly>,
|
||||
"render": [Function],
|
||||
"width": "40px",
|
||||
},
|
||||
]
|
||||
}
|
||||
data-test-subj="statusBreakdown"
|
||||
isExpandable={true}
|
||||
itemId={[Function]}
|
||||
itemIdToExpandedRowMap={Object {}}
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "plugin:1",
|
||||
"original": Object {
|
||||
"level": "available",
|
||||
"summary": "Ready",
|
||||
},
|
||||
"state": Object {
|
||||
"id": "available",
|
||||
"message": "Ready",
|
||||
|
@ -35,14 +58,16 @@ exports[`StatusTable renders when statuses is provided 1`] = `
|
|||
},
|
||||
]
|
||||
}
|
||||
noItemsMessage={
|
||||
<EuiI18n
|
||||
default="No items found"
|
||||
token="euiBasicTable.noItemsMessage"
|
||||
/>
|
||||
}
|
||||
responsive={true}
|
||||
rowProps={[Function]}
|
||||
sorting={
|
||||
Object {
|
||||
"sort": Object {
|
||||
"direction": "asc",
|
||||
"field": "state",
|
||||
},
|
||||
}
|
||||
}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
`;
|
||||
|
|
|
@ -9,3 +9,5 @@
|
|||
export { MetricTile, MetricTiles } from './metric_tiles';
|
||||
export { ServerStatus } from './server_status';
|
||||
export { StatusTable } from './status_table';
|
||||
export { StatusSection } from './status_section';
|
||||
export { VersionHeader } from './version_header';
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { ServerStatus } from './server_status';
|
||||
import { FormattedStatus } from '../lib';
|
||||
import { StatusState } from '../lib';
|
||||
|
||||
const getStatus = (parts: Partial<FormattedStatus['state']> = {}): FormattedStatus['state'] => ({
|
||||
const getStatus = (parts: Partial<StatusState> = {}): StatusState => ({
|
||||
id: 'available',
|
||||
title: 'Green',
|
||||
uiColor: 'success',
|
||||
|
@ -27,6 +27,16 @@ describe('ServerStatus', () => {
|
|||
expect(component.find('EuiBadge')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for yellow state', () => {
|
||||
const status = getStatus({
|
||||
id: 'degraded',
|
||||
title: 'Yellow',
|
||||
});
|
||||
const component = mount(<ServerStatus serverState={status} name="My Computer" />);
|
||||
expect(component.find('EuiTitle').text()).toMatchInlineSnapshot(`"Kibana status is Yellow"`);
|
||||
expect(component.find('EuiBadge')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders correctly for red state', () => {
|
||||
const status = getStatus({
|
||||
id: 'unavailable',
|
||||
|
|
|
@ -7,13 +7,14 @@
|
|||
*/
|
||||
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { EuiText, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiBadge } from '@elastic/eui';
|
||||
import { EuiText, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { FormattedStatus } from '../lib';
|
||||
import type { StatusState } from '../lib';
|
||||
import { StatusBadge } from './status_badge';
|
||||
|
||||
interface ServerStateProps {
|
||||
name: string;
|
||||
serverState: FormattedStatus['state'];
|
||||
serverState: StatusState;
|
||||
}
|
||||
|
||||
export const ServerStatus: FunctionComponent<ServerStateProps> = ({ name, serverState }) => (
|
||||
|
@ -26,13 +27,7 @@ export const ServerStatus: FunctionComponent<ServerStateProps> = ({ name, server
|
|||
defaultMessage="Kibana status is {kibanaStatus}"
|
||||
values={{
|
||||
kibanaStatus: (
|
||||
<EuiBadge
|
||||
data-test-subj="serverStatusTitleBadge"
|
||||
color={serverState.uiColor}
|
||||
aria-label={serverState.title}
|
||||
>
|
||||
{serverState.title}
|
||||
</EuiBadge>
|
||||
<StatusBadge status={serverState} data-test-subj="serverStatusTitleBadge" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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 { shallowWithIntl } from '@kbn/test/jest';
|
||||
import { StatusBadge, StatusWithoutMessage } from './status_badge';
|
||||
|
||||
const getStatus = (parts: Partial<StatusWithoutMessage> = {}): StatusWithoutMessage => ({
|
||||
id: 'available',
|
||||
title: 'Green',
|
||||
uiColor: 'secondary',
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it('propagates the correct properties to `EuiBadge`', () => {
|
||||
const status = getStatus();
|
||||
|
||||
const component = shallowWithIntl(<StatusBadge status={status} />);
|
||||
|
||||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiBadge
|
||||
aria-label="Green"
|
||||
color="secondary"
|
||||
>
|
||||
Green
|
||||
</EuiBadge>
|
||||
`);
|
||||
});
|
||||
|
||||
it('propagates `data-test-subj` if provided', () => {
|
||||
const status = getStatus({
|
||||
id: 'critical',
|
||||
title: 'Red',
|
||||
uiColor: 'danger',
|
||||
});
|
||||
|
||||
const component = shallowWithIntl(
|
||||
<StatusBadge status={status} data-test-subj="my-data-test-subj" />
|
||||
);
|
||||
|
||||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiBadge
|
||||
aria-label="Red"
|
||||
color="danger"
|
||||
data-test-subj="my-data-test-subj"
|
||||
>
|
||||
Red
|
||||
</EuiBadge>
|
||||
`);
|
||||
});
|
||||
});
|
30
src/core/public/core_app/status/components/status_badge.tsx
Normal file
30
src/core/public/core_app/status/components/status_badge.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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, { FC } from 'react';
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import type { StatusState } from '../lib';
|
||||
|
||||
export type StatusWithoutMessage = Omit<StatusState, 'message'>;
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: StatusWithoutMessage;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
export const StatusBadge: FC<StatusBadgeProps> = (props) => {
|
||||
return (
|
||||
<EuiBadge
|
||||
data-test-subj={props['data-test-subj']}
|
||||
color={props.status.uiColor}
|
||||
aria-label={props.status.title}
|
||||
>
|
||||
{props.status.title}
|
||||
</EuiBadge>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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, { FC, useMemo } from 'react';
|
||||
import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import type { FormattedStatus } from '../lib';
|
||||
|
||||
interface StatusExpandedRowProps {
|
||||
status: FormattedStatus;
|
||||
}
|
||||
|
||||
export const StatusExpandedRow: FC<StatusExpandedRowProps> = ({ status }) => {
|
||||
const { original } = status;
|
||||
const statusAsString = useMemo(() => JSON.stringify(original, null, 2), [original]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiCodeBlock
|
||||
language="json"
|
||||
overflowHeight={300}
|
||||
isCopyable
|
||||
paddingSize="none"
|
||||
transparentBackground={true}
|
||||
>
|
||||
{statusAsString}
|
||||
</EuiCodeBlock>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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, { FC, useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { StatusTable } from './status_table';
|
||||
import { FormattedStatus, getHighestStatus } from '../lib';
|
||||
import { StatusBadge } from './status_badge';
|
||||
|
||||
interface StatusSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
statuses: FormattedStatus[];
|
||||
}
|
||||
|
||||
export const StatusSection: FC<StatusSectionProps> = ({ id, title, statuses }) => {
|
||||
const highestStatus = useMemo(() => getHighestStatus(statuses), [statuses]);
|
||||
|
||||
return (
|
||||
<EuiPageContent grow={false}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="s">
|
||||
<h2>{title}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<StatusBadge status={highestStatus} data-test-subj={`${id}SectionStatusBadge`} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
<StatusTable statuses={statuses} />
|
||||
</EuiPageContent>
|
||||
);
|
||||
};
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { ServiceStatus } from '../../../../types/status';
|
||||
import { StatusTable } from './status_table';
|
||||
|
||||
const state = {
|
||||
|
@ -17,13 +18,21 @@ const state = {
|
|||
title: 'green',
|
||||
};
|
||||
|
||||
const createServiceStatus = (parts: Partial<ServiceStatus> = {}): ServiceStatus => ({
|
||||
level: 'available',
|
||||
summary: 'Ready',
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('StatusTable', () => {
|
||||
it('renders when statuses is provided', () => {
|
||||
const component = shallow(<StatusTable statuses={[{ id: 'plugin:1', state }]} />);
|
||||
const component = shallow(
|
||||
<StatusTable statuses={[{ id: 'plugin:1', state, original: createServiceStatus() }]} />
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders when statuses is not provided', () => {
|
||||
it('renders empty when statuses is not provided', () => {
|
||||
const component = shallow(<StatusTable />);
|
||||
expect(component.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
|
|
@ -6,50 +6,115 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { EuiBasicTable, EuiIcon } from '@elastic/eui';
|
||||
import React, { FunctionComponent, ReactElement, useState } from 'react';
|
||||
import {
|
||||
EuiInMemoryTable,
|
||||
EuiIcon,
|
||||
EuiButtonIcon,
|
||||
EuiBasicTableColumn,
|
||||
EuiScreenReaderOnly,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { FormattedStatus } from '../lib';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { FormattedStatus, getLevelSortValue } from '../lib';
|
||||
import { StatusExpandedRow } from './status_expanded_row';
|
||||
|
||||
interface StatusTableProps {
|
||||
statuses?: FormattedStatus[];
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
field: 'state',
|
||||
name: '',
|
||||
render: (state: FormattedStatus['state']) => (
|
||||
<EuiIcon type="dot" aria-hidden color={state.uiColor} />
|
||||
),
|
||||
width: '32px',
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
name: i18n.translate('core.statusPage.statusTable.columns.idHeader', {
|
||||
defaultMessage: 'ID',
|
||||
}),
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
name: i18n.translate('core.statusPage.statusTable.columns.statusHeader', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
render: (state: FormattedStatus['state']) => <span>{state.message}</span>,
|
||||
},
|
||||
];
|
||||
const expandLabel = i18n.translate('core.statusPage.statusTable.columns.expandRow.expandLabel', {
|
||||
defaultMessage: 'Expand',
|
||||
});
|
||||
|
||||
const collapseLabel = i18n.translate(
|
||||
'core.statusPage.statusTable.columns.expandRow.collapseLabel',
|
||||
{ defaultMessage: 'Collapse' }
|
||||
);
|
||||
|
||||
export const StatusTable: FunctionComponent<StatusTableProps> = ({ statuses }) => {
|
||||
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<
|
||||
Record<string, ReactElement>
|
||||
>({});
|
||||
if (!statuses) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleDetails = (item: FormattedStatus) => {
|
||||
const newRowMap = { ...itemIdToExpandedRowMap };
|
||||
if (itemIdToExpandedRowMap[item.id]) {
|
||||
delete newRowMap[item.id];
|
||||
} else {
|
||||
newRowMap[item.id] = <StatusExpandedRow status={item} />;
|
||||
}
|
||||
setItemIdToExpandedRowMap(newRowMap);
|
||||
};
|
||||
|
||||
const tableColumns: Array<EuiBasicTableColumn<FormattedStatus>> = [
|
||||
{
|
||||
field: 'state',
|
||||
name: i18n.translate('core.statusPage.statusTable.columns.statusHeader', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
render: (state: FormattedStatus['state']) => (
|
||||
<EuiIcon type="dot" aria-hidden color={state.uiColor} title={state.title} />
|
||||
),
|
||||
width: '100px',
|
||||
align: 'center' as const,
|
||||
sortable: (row: FormattedStatus) => getLevelSortValue(row),
|
||||
},
|
||||
{
|
||||
field: 'id',
|
||||
name: i18n.translate('core.statusPage.statusTable.columns.idHeader', {
|
||||
defaultMessage: 'ID',
|
||||
}),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
name: i18n.translate('core.statusPage.statusTable.columns.statusSummaryHeader', {
|
||||
defaultMessage: 'Status summary',
|
||||
}),
|
||||
render: (state: FormattedStatus['state']) => <span>{state.message}</span>,
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<EuiScreenReaderOnly>
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusTable.columns.expandRowHeader"
|
||||
defaultMessage="Expand row"
|
||||
/>
|
||||
</EuiScreenReaderOnly>
|
||||
),
|
||||
align: 'right',
|
||||
width: '40px',
|
||||
isExpander: true,
|
||||
render: (item: FormattedStatus) => (
|
||||
<EuiButtonIcon
|
||||
onClick={() => toggleDetails(item)}
|
||||
aria-label={itemIdToExpandedRowMap[item.id] ? collapseLabel : expandLabel}
|
||||
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiBasicTable<FormattedStatus>
|
||||
<EuiInMemoryTable<FormattedStatus>
|
||||
columns={tableColumns}
|
||||
itemId={(item) => item.id}
|
||||
items={statuses}
|
||||
isExpandable={true}
|
||||
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
|
||||
rowProps={({ state }) => ({
|
||||
className: `status-table-row-${state.uiColor}`,
|
||||
})}
|
||||
sorting={{
|
||||
sort: {
|
||||
direction: 'asc',
|
||||
field: 'state',
|
||||
},
|
||||
}}
|
||||
data-test-subj="statusBreakdown"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { mountWithIntl, findTestSubject } from '@kbn/test/jest';
|
||||
import type { ServerVersion } from '../../../../types/status';
|
||||
import { VersionHeader } from './version_header';
|
||||
|
||||
const buildServerVersion = (parts: Partial<ServerVersion> = {}): ServerVersion => ({
|
||||
number: 'version_number',
|
||||
build_hash: 'build_hash',
|
||||
build_number: 9000,
|
||||
build_snapshot: false,
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('VersionHeader', () => {
|
||||
it('displays the version', () => {
|
||||
const version = buildServerVersion({ number: '8.42.13' });
|
||||
const component = mountWithIntl(<VersionHeader version={version} />);
|
||||
|
||||
const versionNode = findTestSubject(component, 'statusBuildVersion');
|
||||
expect(versionNode.text()).toEqual('VERSION: 8.42.13');
|
||||
});
|
||||
|
||||
it('displays the build number', () => {
|
||||
const version = buildServerVersion({ build_number: 42 });
|
||||
const component = mountWithIntl(<VersionHeader version={version} />);
|
||||
|
||||
const buildNumberNode = findTestSubject(component, 'statusBuildNumber');
|
||||
expect(buildNumberNode.text()).toEqual('BUILD: 42');
|
||||
});
|
||||
|
||||
it('displays the build hash', () => {
|
||||
const version = buildServerVersion({ build_hash: 'some_hash' });
|
||||
const component = mountWithIntl(<VersionHeader version={version} />);
|
||||
|
||||
const buildHashNode = findTestSubject(component, 'statusBuildHash');
|
||||
expect(buildHashNode.text()).toEqual('COMMIT: some_hash');
|
||||
});
|
||||
});
|
|
@ -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, { FC } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPageContent, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import type { ServerVersion } from '../../../../types/status';
|
||||
|
||||
interface VersionHeaderProps {
|
||||
version: ServerVersion;
|
||||
}
|
||||
|
||||
export const VersionHeader: FC<VersionHeaderProps> = ({ version }) => {
|
||||
const { build_hash: buildHash, build_number: buildNumber, number } = version;
|
||||
return (
|
||||
<EuiPageContent grow={false}>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<p data-test-subj="statusBuildVersion">
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusApp.statusActions.versionText"
|
||||
defaultMessage="VERSION: {versionNum}"
|
||||
values={{
|
||||
versionNum: <strong>{number}</strong>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<p data-test-subj="statusBuildNumber">
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusApp.statusActions.buildText"
|
||||
defaultMessage="BUILD: {buildNum}"
|
||||
values={{
|
||||
buildNum: <strong>{buildNumber}</strong>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<p data-test-subj="statusBuildHash">
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusApp.statusActions.commitText"
|
||||
defaultMessage="COMMIT: {buildSha}"
|
||||
values={{
|
||||
buildSha: <strong>{buildHash}</strong>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPageContent>
|
||||
);
|
||||
};
|
|
@ -9,4 +9,5 @@
|
|||
export { formatNumber } from './format_number';
|
||||
export { loadStatus } from './load_status';
|
||||
export type { DataType } from './format_number';
|
||||
export type { Metric, FormattedStatus, ProcessedServerResponse } from './load_status';
|
||||
export type { Metric, FormattedStatus, ProcessedServerResponse, StatusState } from './load_status';
|
||||
export { getHighestStatus, groupByLevel, getLevelSortValue, orderedLevels } from './status_level';
|
||||
|
|
|
@ -37,11 +37,11 @@ const mockedResponse: StatusResponse = {
|
|||
},
|
||||
},
|
||||
plugins: {
|
||||
'1': {
|
||||
plugin1: {
|
||||
level: 'available',
|
||||
summary: 'Ready',
|
||||
},
|
||||
'2': {
|
||||
plugin2: {
|
||||
level: 'degraded',
|
||||
summary: 'Something is weird',
|
||||
},
|
||||
|
@ -165,39 +165,50 @@ describe('response processing', () => {
|
|||
expect(notifications.toasts.addDanger).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('includes the plugin statuses', async () => {
|
||||
test('includes core statuses', async () => {
|
||||
const data = await loadStatus({ http, notifications });
|
||||
expect(data.statuses).toEqual([
|
||||
expect(data.coreStatus).toEqual([
|
||||
{
|
||||
id: 'core:elasticsearch',
|
||||
id: 'elasticsearch',
|
||||
state: {
|
||||
id: 'available',
|
||||
title: 'Green',
|
||||
message: 'Elasticsearch is available',
|
||||
uiColor: 'success',
|
||||
},
|
||||
original: mockedResponse.status.core.elasticsearch,
|
||||
},
|
||||
{
|
||||
id: 'core:savedObjects',
|
||||
id: 'savedObjects',
|
||||
state: {
|
||||
id: 'available',
|
||||
title: 'Green',
|
||||
message: 'SavedObjects service has completed migrations and is available',
|
||||
uiColor: 'success',
|
||||
},
|
||||
original: mockedResponse.status.core.savedObjects,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('includes the plugin statuses', async () => {
|
||||
const data = await loadStatus({ http, notifications });
|
||||
|
||||
expect(data.pluginStatus).toEqual([
|
||||
{
|
||||
id: 'plugin:1',
|
||||
id: 'plugin1',
|
||||
state: { id: 'available', title: 'Green', message: 'Ready', uiColor: 'success' },
|
||||
original: mockedResponse.status.plugins.plugin1,
|
||||
},
|
||||
{
|
||||
id: 'plugin:2',
|
||||
id: 'plugin2',
|
||||
state: {
|
||||
id: 'degraded',
|
||||
title: 'Yellow',
|
||||
message: 'Something is weird',
|
||||
uiColor: 'warning',
|
||||
},
|
||||
original: mockedResponse.status.plugins.plugin2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -21,12 +21,15 @@ export interface Metric {
|
|||
|
||||
export interface FormattedStatus {
|
||||
id: string;
|
||||
state: {
|
||||
id: ServiceStatusLevel;
|
||||
title: string;
|
||||
message: string;
|
||||
uiColor: string;
|
||||
};
|
||||
state: StatusState;
|
||||
original: ServiceStatus;
|
||||
}
|
||||
|
||||
export interface StatusState {
|
||||
id: ServiceStatusLevel;
|
||||
title: string;
|
||||
message: string;
|
||||
uiColor: string;
|
||||
}
|
||||
|
||||
interface StatusUIAttributes {
|
||||
|
@ -96,6 +99,7 @@ function formatStatus(id: string, status: ServiceStatus): FormattedStatus {
|
|||
|
||||
return {
|
||||
id,
|
||||
original: status,
|
||||
state: {
|
||||
id: status.level,
|
||||
message: status.summary,
|
||||
|
@ -105,7 +109,7 @@ function formatStatus(id: string, status: ServiceStatus): FormattedStatus {
|
|||
};
|
||||
}
|
||||
|
||||
const STATUS_LEVEL_UI_ATTRS: Record<ServiceStatusLevel, StatusUIAttributes> = {
|
||||
export const STATUS_LEVEL_UI_ATTRS: Record<ServiceStatusLevel, StatusUIAttributes> = {
|
||||
critical: {
|
||||
title: i18n.translate('core.status.redTitle', {
|
||||
defaultMessage: 'Red',
|
||||
|
@ -178,14 +182,13 @@ export async function loadStatus({
|
|||
return {
|
||||
name: response.name,
|
||||
version: response.version,
|
||||
statuses: [
|
||||
...Object.entries(response.status.core).map(([serviceName, status]) =>
|
||||
formatStatus(`core:${serviceName}`, status)
|
||||
),
|
||||
...Object.entries(response.status.plugins).map(([pluginName, status]) =>
|
||||
formatStatus(`plugin:${pluginName}`, status)
|
||||
),
|
||||
],
|
||||
coreStatus: Object.entries(response.status.core).map(([serviceName, status]) =>
|
||||
formatStatus(serviceName, status)
|
||||
),
|
||||
pluginStatus: Object.entries(response.status.plugins).map(([pluginName, status]) =>
|
||||
formatStatus(pluginName, status)
|
||||
),
|
||||
|
||||
serverState: formatStatus('overall', response.status.overall).state,
|
||||
metrics: formatMetrics(response),
|
||||
};
|
||||
|
|
122
src/core/public/core_app/status/lib/status_level.test.ts
Normal file
122
src/core/public/core_app/status/lib/status_level.test.ts
Normal file
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* 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 type { ServiceStatus } from '../../../../types/status';
|
||||
import { getLevelSortValue, groupByLevel, getHighestStatus } from './status_level';
|
||||
import { FormattedStatus, StatusState } from './load_status';
|
||||
|
||||
type CreateStatusInput = Partial<Omit<FormattedStatus, 'state' | 'original'>> & {
|
||||
state?: Partial<StatusState>;
|
||||
};
|
||||
|
||||
const dummyStatus: ServiceStatus = {
|
||||
level: 'available',
|
||||
summary: 'not used in this logic',
|
||||
};
|
||||
|
||||
const createFormattedStatus = (parts: CreateStatusInput = {}): FormattedStatus => ({
|
||||
original: dummyStatus,
|
||||
id: 'id',
|
||||
...parts,
|
||||
state: {
|
||||
id: 'available',
|
||||
title: 'Green',
|
||||
message: 'alright',
|
||||
uiColor: 'primary',
|
||||
...parts.state,
|
||||
},
|
||||
});
|
||||
|
||||
describe('getLevelSortValue', () => {
|
||||
it('returns the correct value for `critical` state', () => {
|
||||
expect(getLevelSortValue(createFormattedStatus({ state: { id: 'critical' } }))).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns the correct value for `unavailable` state', () => {
|
||||
expect(getLevelSortValue(createFormattedStatus({ state: { id: 'unavailable' } }))).toEqual(1);
|
||||
});
|
||||
|
||||
it('returns the correct value for `degraded` state', () => {
|
||||
expect(getLevelSortValue(createFormattedStatus({ state: { id: 'degraded' } }))).toEqual(2);
|
||||
});
|
||||
|
||||
it('returns the correct value for `available` state', () => {
|
||||
expect(getLevelSortValue(createFormattedStatus({ state: { id: 'available' } }))).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByLevel', () => {
|
||||
it('groups statuses by their level', () => {
|
||||
const result = groupByLevel([
|
||||
createFormattedStatus({
|
||||
id: 'available-1',
|
||||
state: { id: 'available', title: 'green', uiColor: '#00FF00' },
|
||||
}),
|
||||
createFormattedStatus({
|
||||
id: 'critical-1',
|
||||
state: { id: 'critical', title: 'red', uiColor: '#FF0000' },
|
||||
}),
|
||||
createFormattedStatus({
|
||||
id: 'degraded-1',
|
||||
state: { id: 'degraded', title: 'yellow', uiColor: '#FFFF00' },
|
||||
}),
|
||||
createFormattedStatus({
|
||||
id: 'critical-2',
|
||||
state: { id: 'critical', title: 'red', uiColor: '#FF0000' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.size).toEqual(3);
|
||||
expect(result.get('available')!.map((e) => e.id)).toEqual(['available-1']);
|
||||
expect(result.get('degraded')!.map((e) => e.id)).toEqual(['degraded-1']);
|
||||
expect(result.get('critical')!.map((e) => e.id)).toEqual(['critical-1', 'critical-2']);
|
||||
});
|
||||
|
||||
it('returns an empty map when input list is empty', () => {
|
||||
const result = groupByLevel([]);
|
||||
expect(result.size).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHighestStatus', () => {
|
||||
it('returns the values from the highest status', () => {
|
||||
expect(
|
||||
getHighestStatus([
|
||||
createFormattedStatus({ state: { id: 'available', title: 'green', uiColor: '#00FF00' } }),
|
||||
createFormattedStatus({ state: { id: 'critical', title: 'red', uiColor: '#FF0000' } }),
|
||||
createFormattedStatus({ state: { id: 'degraded', title: 'yellow', uiColor: '#FFFF00' } }),
|
||||
])
|
||||
).toEqual({
|
||||
id: 'critical',
|
||||
title: 'red',
|
||||
uiColor: '#FF0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple statuses with the same level', () => {
|
||||
expect(
|
||||
getHighestStatus([
|
||||
createFormattedStatus({ state: { id: 'degraded', title: 'yellow', uiColor: '#FF0000' } }),
|
||||
createFormattedStatus({ state: { id: 'available', title: 'green', uiColor: '#00FF00' } }),
|
||||
createFormattedStatus({ state: { id: 'degraded', title: 'yellow', uiColor: '#FFFF00' } }),
|
||||
])
|
||||
).toEqual({
|
||||
id: 'degraded',
|
||||
title: 'yellow',
|
||||
uiColor: '#FF0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default values for `available` when the input list is empty', () => {
|
||||
expect(getHighestStatus([])).toEqual({
|
||||
id: 'available',
|
||||
title: 'Green',
|
||||
uiColor: 'success',
|
||||
});
|
||||
});
|
||||
});
|
44
src/core/public/core_app/status/lib/status_level.ts
Normal file
44
src/core/public/core_app/status/lib/status_level.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 type { ServiceStatusLevel } from '../../../../types/status';
|
||||
import { FormattedStatus, StatusState, STATUS_LEVEL_UI_ATTRS } from './load_status';
|
||||
|
||||
export const orderedLevels: ServiceStatusLevel[] = [
|
||||
'critical',
|
||||
'unavailable',
|
||||
'degraded',
|
||||
'available',
|
||||
];
|
||||
|
||||
export const groupByLevel = (statuses: FormattedStatus[]) => {
|
||||
return statuses.reduce((map, status) => {
|
||||
const existing = map.get(status.state.id) ?? [];
|
||||
map.set(status.state.id, [...existing, status]);
|
||||
return map;
|
||||
}, new Map<ServiceStatusLevel, FormattedStatus[]>());
|
||||
};
|
||||
|
||||
export const getHighestStatus = (statuses: FormattedStatus[]): Omit<StatusState, 'message'> => {
|
||||
const grouped = groupByLevel(statuses);
|
||||
for (const level of orderedLevels) {
|
||||
if (grouped.has(level) && grouped.get(level)!.length) {
|
||||
const { message, ...status } = grouped.get(level)![0].state;
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'available',
|
||||
title: STATUS_LEVEL_UI_ATTRS.available.title,
|
||||
uiColor: STATUS_LEVEL_UI_ATTRS.available.uiColor,
|
||||
};
|
||||
};
|
||||
|
||||
export const getLevelSortValue = (status: FormattedStatus) => {
|
||||
return orderedLevels.indexOf(status.state.id);
|
||||
};
|
|
@ -7,22 +7,13 @@
|
|||
*/
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
EuiLoadingSpinner,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiPageContent,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { EuiLoadingSpinner, EuiText, EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { HttpSetup } from '../../http';
|
||||
import { NotificationsSetup } from '../../notifications';
|
||||
import { loadStatus, ProcessedServerResponse } from './lib';
|
||||
import { MetricTiles, StatusTable, ServerStatus } from './components';
|
||||
import { MetricTiles, ServerStatus, StatusSection, VersionHeader } from './components';
|
||||
|
||||
interface StatusAppProps {
|
||||
http: HttpSetup;
|
||||
|
@ -74,69 +65,36 @@ export class StatusApp extends Component<StatusAppProps, StatusAppState> {
|
|||
);
|
||||
}
|
||||
|
||||
// Extract the items needed to render each component
|
||||
const { metrics, statuses, serverState, name, version } = data!;
|
||||
const { build_hash: buildHash, build_number: buildNumber } = version;
|
||||
const { metrics, coreStatus, pluginStatus, serverState, name, version } = data!;
|
||||
|
||||
return (
|
||||
<EuiPage className="stsPage" data-test-subj="statusPageRoot">
|
||||
<EuiPageBody restrictWidth>
|
||||
<ServerStatus name={name} serverState={serverState} />
|
||||
<EuiSpacer />
|
||||
|
||||
<VersionHeader version={version} />
|
||||
<EuiSpacer />
|
||||
|
||||
<MetricTiles metrics={metrics} />
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiPageContent grow={false}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="s">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusApp.statusTitle"
|
||||
defaultMessage="Plugin status"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<p data-test-subj="statusBuildNumber">
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusApp.statusActions.buildText"
|
||||
defaultMessage="BUILD {buildNum}"
|
||||
values={{
|
||||
buildNum: <strong>{buildNumber}</strong>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<p data-test-subj="statusBuildHash">
|
||||
<FormattedMessage
|
||||
id="core.statusPage.statusApp.statusActions.commitText"
|
||||
defaultMessage="COMMIT {buildSha}"
|
||||
values={{
|
||||
buildSha: <strong>{buildHash}</strong>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<StatusSection
|
||||
id="core"
|
||||
title={i18n.translate('core.statusPage.coreStatus.sectionTitle', {
|
||||
defaultMessage: 'Core status',
|
||||
})}
|
||||
statuses={coreStatus}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<StatusTable statuses={statuses} />
|
||||
</EuiPageContent>
|
||||
<StatusSection
|
||||
id="plugins"
|
||||
title={i18n.translate('core.statusPage.statusApp.statusTitle', {
|
||||
defaultMessage: 'Plugin status',
|
||||
})}
|
||||
statuses={pluginStatus}
|
||||
/>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
|
|
|
@ -28,9 +28,7 @@ export interface ServiceStatus extends Omit<ServiceStatusFromServer, 'level'> {
|
|||
* but overwriting the `level` to its stringified version.
|
||||
*/
|
||||
export type CoreStatus = {
|
||||
[ServiceName in keyof CoreStatusFromServer]: Omit<CoreStatusFromServer[ServiceName], 'level'> & {
|
||||
level: ServiceStatusLevel;
|
||||
};
|
||||
[ServiceName in keyof CoreStatusFromServer]: ServiceStatus;
|
||||
};
|
||||
|
||||
export type ServerMetrics = Omit<OpsMetrics, 'collected_at'> & {
|
||||
|
|
|
@ -20,12 +20,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await PageObjects.common.navigateToApp('status_page');
|
||||
});
|
||||
|
||||
it('should show the build hash and number', async () => {
|
||||
const buildNumberText = await testSubjects.getVisibleText('statusBuildNumber');
|
||||
expect(buildNumberText).to.contain('BUILD ');
|
||||
it('should show the build version', async () => {
|
||||
const buildVersionText = await testSubjects.getVisibleText('statusBuildVersion');
|
||||
expect(buildVersionText).to.contain('VERSION: ');
|
||||
});
|
||||
|
||||
it('should show the build number', async () => {
|
||||
const buildNumberText = await testSubjects.getVisibleText('statusBuildNumber');
|
||||
expect(buildNumberText).to.contain('BUILD: ');
|
||||
});
|
||||
|
||||
it('should show the build hash', async () => {
|
||||
const hashText = await testSubjects.getVisibleText('statusBuildHash');
|
||||
expect(hashText).to.contain('COMMIT ');
|
||||
expect(hashText).to.contain('COMMIT: ');
|
||||
});
|
||||
|
||||
it('should display the server metrics', async () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue