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:
Pierre Gayvallet 2021-11-19 10:41:51 +01:00 committed by GitHub
parent eb81db2ddd
commit a28419a327
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 702 additions and 145 deletions

View file

@ -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>
`;

View file

@ -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"
/>
`;

View file

@ -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';

View file

@ -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',

View file

@ -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" />
),
}}
/>

View file

@ -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>
`);
});
});

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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);
});

View file

@ -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"
/>
);

View file

@ -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');
});
});

View file

@ -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>
);
};

View file

@ -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';

View file

@ -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,
},
]);
});

View file

@ -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),
};

View 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',
});
});
});

View 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);
};

View file

@ -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>
);

View file

@ -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'> & {

View file

@ -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 () => {