[Infra UI] Table View for Home Page (#29192) (#29739)

* Addding initial table implimentation

* Moving waffle map to seperate component; adding contextual menu to nodes; adding filter to groups; adding pagination; adding sorting

* Fixing EUI types for EuiInMemoryTable to work for EVERYONE

* Adding server plugin for tslint for VIM; Fixing tests

* Adding the view switcher

* removing dependency

* updating yarn.lock

* Change padding to use EUI rules

* Rename waffle/index to nodes_overview; move table to nodes_overview

* Adding missed files in last commit

* Adding textOnly to the columns that need special truncation because they are buttons

* Fixed an error in the merge

* Fixing merge issues
This commit is contained in:
Chris Cowan 2019-01-31 10:44:22 -07:00 committed by GitHub
parent 6ae764dbf9
commit 33e6b48e41
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 624 additions and 235 deletions

View file

@ -427,4 +427,4 @@
"node": "10.14.1",
"yarn": "^1.10.1"
}
}
}

View file

@ -10,34 +10,29 @@ import React from 'react';
import styled from 'styled-components';
import {
isWaffleMapGroupWithGroups,
isWaffleMapGroupWithNodes,
} from '../../containers/waffle/type_guards';
import { InfraMetricType, InfraNodeType, InfraTimerangeInput } from '../../graphql/types';
import {
InfraFormatterType,
InfraWaffleData,
InfraWaffleMapBounds,
InfraWaffleMapGroup,
InfraWaffleMapOptions,
} from '../../lib/lib';
InfraMetricType,
InfraNode,
InfraNodeType,
InfraTimerangeInput,
} from '../../graphql/types';
import { InfraFormatterType, InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib';
import { KueryFilterQuery } from '../../store/local/waffle_filter';
import { createFormatter } from '../../utils/formatters';
import { AutoSizer } from '../auto_sizer';
import { InfraLoadingPanel } from '../loading';
import { GroupOfGroups } from './group_of_groups';
import { GroupOfNodes } from './group_of_nodes';
import { Legend } from './legend';
import { applyWaffleMapLayout } from './lib/apply_wafflemap_layout';
import { Map } from '../waffle/map';
import { ViewSwitcher } from '../waffle/view_switcher';
import { TableView } from './table';
interface Props {
options: InfraWaffleMapOptions;
nodeType: InfraNodeType;
map: InfraWaffleData;
nodes: InfraNode[];
loading: boolean;
reload: () => void;
onDrilldown: (filter: KueryFilterQuery) => void;
timeRange: InfraTimerangeInput;
onViewChange: (view: string) => void;
view: string;
intl: InjectedIntl;
}
@ -71,24 +66,8 @@ const METRIC_FORMATTERS: MetricFormatters = {
},
};
const extractValuesFromMap = (groups: InfraWaffleMapGroup[], values: number[] = []): number[] => {
return groups.reduce((acc: number[], group: InfraWaffleMapGroup) => {
if (isWaffleMapGroupWithGroups(group)) {
return acc.concat(extractValuesFromMap(group.groups, values));
}
if (isWaffleMapGroupWithNodes(group)) {
return acc.concat(
group.nodes.map(node => {
return node.metric.value || 0;
})
);
}
return acc;
}, values);
};
const calculateBoundsFromMap = (map: InfraWaffleData): InfraWaffleMapBounds => {
const values = extractValuesFromMap(map);
const calculateBoundsFromNodes = (nodes: InfraNode[]): InfraWaffleMapBounds => {
const values = nodes.map(node => node.metric.value);
// if there is only one value then we need to set the bottom range to zero
if (values.length === 1) {
values.unshift(0);
@ -96,11 +75,11 @@ const calculateBoundsFromMap = (map: InfraWaffleData): InfraWaffleMapBounds => {
return { min: min(values) || 0, max: max(values) || 0 };
};
export const Waffle = injectI18n(
export const NodesOverview = injectI18n(
class extends React.Component<Props, {}> {
public static displayName = 'Waffle';
public render() {
const { loading, map, reload, timeRange, intl } = this.props;
const { loading, nodes, nodeType, reload, intl, view, options, timeRange } = this.props;
if (loading) {
return (
<InfraLoadingPanel
@ -112,7 +91,7 @@ export const Waffle = injectI18n(
})}
/>
);
} else if (!loading && map && map.length === 0) {
} else if (!loading && nodes && nodes.length === 0) {
return (
<CenteredEmptyPrompt
title={
@ -157,31 +136,42 @@ export const Waffle = injectI18n(
metric.type,
METRIC_FORMATTERS[InfraMetricType.count]
);
const bounds = (metricFormatter && metricFormatter.bounds) || calculateBoundsFromMap(map);
const bounds = (metricFormatter && metricFormatter.bounds) || calculateBoundsFromNodes(nodes);
return (
<AutoSizer content>
{({ measureRef, content: { width = 0, height = 0 } }) => {
const groupsWithLayout = applyWaffleMapLayout(map, width, height);
return (
<WaffleMapOuterContiner
innerRef={(el: any) => measureRef(el)}
data-test-subj="waffleMap"
>
<WaffleMapInnerContainer>
{groupsWithLayout.map(this.renderGroup(bounds, timeRange))}
</WaffleMapInnerContainer>
<Legend
formatter={this.formatter}
bounds={bounds}
legend={this.props.options.legend}
/>
</WaffleMapOuterContiner>
);
}}
</AutoSizer>
<MainContainer>
<ViewSwitcherContainer>
<ViewSwitcher view={view} onChange={this.handleViewChange} />
</ViewSwitcherContainer>
{view === 'table' ? (
<TableContainer>
<TableView
nodeType={nodeType}
nodes={nodes}
options={options}
formatter={this.formatter}
timeRange={timeRange}
onFilter={this.handleDrilldown}
/>
</TableContainer>
) : (
<MapContainer>
<Map
nodeType={nodeType}
nodes={nodes}
options={options}
formatter={this.formatter}
timeRange={timeRange}
onFilter={this.handleDrilldown}
bounds={bounds}
/>
</MapContainer>
)}
</MainContainer>
);
}
private handleViewChange = (view: string) => this.props.onViewChange(view);
// TODO: Change this to a real implimentation using the tickFormatter from the prototype as an example.
private formatter = (val: string | number) => {
const { metric } = this.props.options;
@ -204,61 +194,31 @@ export const Waffle = injectI18n(
});
return;
};
private renderGroup = (bounds: InfraWaffleMapBounds, timeRange: InfraTimerangeInput) => (
group: InfraWaffleMapGroup
) => {
if (isWaffleMapGroupWithGroups(group)) {
return (
<GroupOfGroups
onDrilldown={this.handleDrilldown}
key={group.id}
options={this.props.options}
group={group}
formatter={this.formatter}
bounds={bounds}
nodeType={this.props.nodeType}
timeRange={timeRange}
/>
);
}
if (isWaffleMapGroupWithNodes(group)) {
return (
<GroupOfNodes
key={group.id}
options={this.props.options}
group={group}
onDrilldown={this.handleDrilldown}
formatter={this.formatter}
isChild={false}
bounds={bounds}
nodeType={this.props.nodeType}
timeRange={timeRange}
/>
);
}
};
}
);
const WaffleMapOuterContiner = styled.div`
flex: 1 0 0%;
display: flex;
justify-content: center;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
`;
const WaffleMapInnerContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-content: flex-start;
padding: 10px;
`;
const CenteredEmptyPrompt = styled(EuiEmptyPrompt)`
align-self: center;
`;
const MainContainer = styled.div`
position: relative;
flex: 1 1 auto;
`;
const TableContainer = styled.div`
padding: ${props => props.theme.eui.paddingSizes.l};
`;
const ViewSwitcherContainer = styled.div`
padding: ${props => props.theme.eui.paddingSizes.l};
`;
const MapContainer = styled.div`
position: absolute;
display: flex;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;

View file

@ -0,0 +1,167 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButtonEmpty, EuiInMemoryTable, EuiToolTip } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { last } from 'lodash';
import React from 'react';
import { InfraNodeType } from '../../../server/lib/adapters/nodes';
import { createWaffleMapNode } from '../../containers/waffle/nodes_to_wafflemap';
import { InfraNode, InfraNodePath, InfraTimerangeInput } from '../../graphql/types';
import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib';
import { fieldToName } from '../waffle/lib/field_to_display_name';
import { NodeContextMenu } from '../waffle/node_context_menu';
interface Props {
nodes: InfraNode[];
nodeType: InfraNodeType;
options: InfraWaffleMapOptions;
formatter: (subject: string | number) => string;
timeRange: InfraTimerangeInput;
intl: InjectedIntl;
onFilter: (filter: string) => void;
}
const initialState = {
isPopoverOpen: [] as string[],
};
type State = Readonly<typeof initialState>;
const getGroupPaths = (path: InfraNodePath[]) => {
switch (path.length) {
case 3:
return path.slice(0, 2);
case 2:
return path.slice(0, 1);
default:
return [];
}
};
export const TableView = injectI18n(
class extends React.PureComponent<Props, State> {
public readonly state: State = initialState;
public render() {
const { nodes, options, formatter, intl, timeRange, nodeType } = this.props;
const columns = [
{
field: 'name',
name: intl.formatMessage({
id: 'xpack.infra.tableView.columnName.name',
defaultMessage: 'Name',
}),
sortable: true,
truncateText: true,
textOnly: true,
render: (value: string, item: { node: InfraWaffleMapNode }) => (
<NodeContextMenu
node={item.node}
nodeType={nodeType}
closePopover={this.closePopoverFor(item.node.pathId)}
timeRange={timeRange}
isPopoverOpen={this.state.isPopoverOpen.includes(item.node.pathId)}
options={options}
>
<EuiButtonEmpty onClick={this.openPopoverFor(item.node.pathId)}>
{value}
</EuiButtonEmpty>
</NodeContextMenu>
),
},
...options.groupBy.map((grouping, index) => ({
field: `group_${index}`,
name: fieldToName((grouping && grouping.field) || '', intl),
sortable: true,
truncateText: true,
textOnly: true,
render: (value: string) => {
const handleClick = () => this.props.onFilter(`${grouping.field}:"${value}"`);
return (
<EuiToolTip content="Set Filter">
<EuiButtonEmpty onClick={handleClick}>{value}</EuiButtonEmpty>
</EuiToolTip>
);
},
})),
{
field: 'value',
name: intl.formatMessage({
id: 'xpack.infra.tableView.columnName.last1m',
defaultMessage: 'Last 1m',
}),
sortable: true,
truncateText: true,
dataType: 'number',
render: (value: number) => <span>{formatter(value)}</span>,
},
{
field: 'avg',
name: intl.formatMessage({
id: 'xpack.infra.tableView.columnName.avg',
defaultMessage: 'Avg',
}),
sortable: true,
truncateText: true,
dataType: 'number',
render: (value: number) => <span>{formatter(value)}</span>,
},
{
field: 'max',
name: intl.formatMessage({
id: 'xpack.infra.tableView.columnName.max',
defaultMessage: 'Max',
}),
sortable: true,
truncateText: true,
dataType: 'number',
render: (value: number) => <span>{formatter(value)}</span>,
},
];
const items = nodes.map(node => {
const name = last(node.path);
return {
name: (name && name.value) || 'unknown',
...getGroupPaths(node.path).reduce(
(acc, path, index) => ({
...acc,
[`group_${index}`]: path.label,
}),
{}
),
value: node.metric.value,
avg: node.metric.avg,
max: node.metric.max,
node: createWaffleMapNode(node),
};
});
const initialSorting = {
sort: {
field: 'value',
direction: 'desc',
},
};
return (
<EuiInMemoryTable
pagination={true}
sorting={initialSorting}
items={items}
columns={columns}
/>
);
}
private openPopoverFor = (id: string) => () => {
this.setState(prevState => ({ isPopoverOpen: [...prevState.isPopoverOpen, id] }));
};
private closePopoverFor = (id: string) => () => {
this.setState(prevState => ({
isPopoverOpen: prevState.isPopoverOpen.filter(subject => subject !== id),
}));
};
}
);

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { InjectedIntl } from '@kbn/i18n/react';
interface Lookup {
[id: string]: string;
}
export const fieldToName = (field: string, intl: InjectedIntl) => {
const LOOKUP: Lookup = {
'kubernetes.namespace': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.kubernetesNamespace',
defaultMessage: 'Namespace',
}),
'kubernetes.node.name': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.kubernetesNodeName',
defaultMessage: 'Node',
}),
'host.name': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.hostName',
defaultMessage: 'Host',
}),
'meta.cloud.availability_zone': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.availabilityZone',
defaultMessage: 'Availability Zone',
}),
'meta.cloud.machine_type': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.machineType',
defaultMessage: 'Machine Type',
}),
'meta.cloud.project_id': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.projectID',
defaultMessage: 'Project ID',
}),
'meta.cloud.provider': intl.formatMessage({
id: 'xpack.infra.groupByDisplayNames.provider',
defaultMessage: 'Cloud Provider',
}),
};
return LOOKUP[field] || field;
};

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import styled from 'styled-components';
import { nodesToWaffleMap } from '../../containers/waffle/nodes_to_wafflemap';
import {
isWaffleMapGroupWithGroups,
isWaffleMapGroupWithNodes,
} from '../../containers/waffle/type_guards';
import { InfraNode, InfraNodeType, InfraTimerangeInput } from '../../graphql/types';
import { InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib';
import { AutoSizer } from '../auto_sizer';
import { GroupOfGroups } from './group_of_groups';
import { GroupOfNodes } from './group_of_nodes';
import { Legend } from './legend';
import { applyWaffleMapLayout } from './lib/apply_wafflemap_layout';
interface Props {
nodes: InfraNode[];
nodeType: InfraNodeType;
options: InfraWaffleMapOptions;
formatter: (subject: string | number) => string;
timeRange: InfraTimerangeInput;
onFilter: (filter: string) => void;
bounds: InfraWaffleMapBounds;
}
export const Map: React.SFC<Props> = ({
nodes,
options,
timeRange,
onFilter,
formatter,
bounds,
nodeType,
}) => {
const map = nodesToWaffleMap(nodes);
return (
<AutoSizer content>
{({ measureRef, content: { width = 0, height = 0 } }) => {
const groupsWithLayout = applyWaffleMapLayout(map, width, height);
return (
<WaffleMapOuterContainer
innerRef={(el: any) => measureRef(el)}
data-test-subj="waffleMap"
>
<WaffleMapInnerContainer>
{groupsWithLayout.map(group => {
if (isWaffleMapGroupWithGroups(group)) {
return (
<GroupOfGroups
onDrilldown={onFilter}
key={group.id}
options={options}
group={group}
formatter={formatter}
bounds={bounds}
nodeType={nodeType}
timeRange={timeRange}
/>
);
}
if (isWaffleMapGroupWithNodes(group)) {
return (
<GroupOfNodes
key={group.id}
options={options}
group={group}
onDrilldown={onFilter}
formatter={formatter}
isChild={false}
bounds={bounds}
nodeType={nodeType}
timeRange={timeRange}
/>
);
}
})}
</WaffleMapInnerContainer>
<Legend formatter={formatter} bounds={bounds} legend={options.legend} />
</WaffleMapOuterContainer>
);
}}
</AutoSizer>
);
};
const WaffleMapOuterContainer = styled.div`
flex: 1 0 0%;
display: flex;
justify-content: center;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
`;
const WaffleMapInnerContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-content: flex-start;
padding: 10px;
`;

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButtonGroup } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
interface Props {
view: string;
onChange: (view: string) => void;
intl: InjectedIntl;
}
export const ViewSwitcher = injectI18n(({ view, onChange, intl }: Props) => {
const buttons = [
{
id: 'map',
label: intl.formatMessage({
id: 'xpack.infra.viewSwitcher.mapViewLabel',
defaultMessage: 'Map View',
}),
iconType: 'apps',
},
{
id: 'table',
label: intl.formatMessage({
id: 'xpack.infra.viewSwitcher.tableViewLabel',
defaultMessage: 'Table View',
}),
iconType: 'editorUnorderedList',
},
];
return (
<EuiButtonGroup
legend={intl.formatMessage({
id: 'xpack.infra.viewSwitcher.lenged',
defaultMessage: 'Switch between table and map view',
})}
options={buttons}
color="primary"
idSelected={view}
onChange={onChange}
/>
);
});

View file

@ -18,6 +18,7 @@ import React from 'react';
import { InfraIndexField, InfraNodeType, InfraPathInput, InfraPathType } from '../../graphql/types';
import { InfraGroupByOptions } from '../../lib/lib';
import { CustomFieldPanel } from './custom_field_panel';
import { fieldToName } from './lib/field_to_display_name';
interface Props {
nodeType: InfraNodeType;
@ -29,107 +30,34 @@ interface Props {
customOptions: InfraGroupByOptions[];
}
const createFieldToOptionMapper = (intl: InjectedIntl) => (field: string) => ({
text: fieldToName(field, intl),
type: InfraPathType.terms,
field,
});
let OPTIONS: { [P in InfraNodeType]: InfraGroupByOptions[] };
const getOptions = (
nodeType: InfraNodeType,
intl: InjectedIntl
): Array<{ text: string; type: InfraPathType; field: string }> => {
if (!OPTIONS) {
const mapFieldToOption = createFieldToOptionMapper(intl);
OPTIONS = {
[InfraNodeType.pod]: [
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.podGroupByOptions.namespaceLabel',
defaultMessage: 'Namespace',
}),
type: InfraPathType.terms,
field: 'kubernetes.namespace',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.podGroupByOptions.nodeLabel',
defaultMessage: 'Node',
}),
type: InfraPathType.terms,
field: 'kubernetes.node.name',
},
],
[InfraNodeType.pod]: ['kubernetes.namespace', 'kubernetes.node.name'].map(mapFieldToOption),
[InfraNodeType.container]: [
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.containerGroupByOptions.hostLabel',
defaultMessage: 'Host',
}),
type: InfraPathType.terms,
field: 'host.name',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.containerGroupByOptions.availabilityZoneLabel',
defaultMessage: 'Availability Zone',
}),
type: InfraPathType.terms,
field: 'meta.cloud.availability_zone',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.containerGroupByOptions.machineTypeLabel',
defaultMessage: 'Machine Type',
}),
type: InfraPathType.terms,
field: 'meta.cloud.machine_type',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.containerGroupByOptions.projectIDLabel',
defaultMessage: 'Project ID',
}),
type: InfraPathType.terms,
field: 'meta.cloud.project_id',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.containerGroupByOptions.providerLabel',
defaultMessage: 'Provider',
}),
type: InfraPathType.terms,
field: 'meta.cloud.provider',
},
],
'host.name',
'meta.cloud.availability_zone',
'meta.cloud.machine_type',
'meta.cloud.project_id',
'meta.cloud.provider',
].map(mapFieldToOption),
[InfraNodeType.host]: [
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.hostGroupByOptions.availabilityZoneLabel',
defaultMessage: 'Availability Zone',
}),
type: InfraPathType.terms,
field: 'meta.cloud.availability_zone',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.hostGroupByOptions.machineTypeLabel',
defaultMessage: 'Machine Type',
}),
type: InfraPathType.terms,
field: 'meta.cloud.machine_type',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.hostGroupByOptions.projectIDLabel',
defaultMessage: 'Project ID',
}),
type: InfraPathType.terms,
field: 'meta.cloud.project_id',
},
{
text: intl.formatMessage({
id: 'xpack.infra.waffle.hostGroupByOptions.cloudProviderLabel',
defaultMessage: 'Cloud Provider',
}),
type: InfraPathType.terms,
field: 'meta.cloud.provider',
},
],
'meta.cloud.availability_zone',
'meta.cloud.machine_type',
'meta.cloud.project_id',
'meta.cloud.provider',
].map(mapFieldToOption),
};
}

View file

@ -16,7 +16,7 @@ import {
} from '../../lib/lib';
import { isWaffleMapGroupWithGroups, isWaffleMapGroupWithNodes } from './type_guards';
function createId(path: InfraNodePath[]) {
export function createId(path: InfraNodePath[]) {
return path.map(p => p.value).join('/');
}
@ -52,7 +52,7 @@ function findOrCreateGroupWithNodes(
? i18n.translate('xpack.infra.nodesToWaffleMap.groupsWithNodes.allName', {
defaultMessage: 'All',
})
: (lastPath && lastPath.label) || 'No Group',
: (lastPath && lastPath.label) || 'Unknown Group',
count: 0,
width: 0,
squareSize: 0,
@ -77,7 +77,7 @@ function findOrCreateGroupWithGroups(
? i18n.translate('xpack.infra.nodesToWaffleMap.groupsWithGroups.allName', {
defaultMessage: 'All',
})
: (lastPath && lastPath.label) || 'No Group',
: (lastPath && lastPath.label) || 'Unknown Group',
count: 0,
width: 0,
squareSize: 0,
@ -85,10 +85,10 @@ function findOrCreateGroupWithGroups(
};
}
function createWaffleMapNode(node: InfraNode): InfraWaffleMapNode {
export function createWaffleMapNode(node: InfraNode): InfraWaffleMapNode {
const nodePathItem = last(node.path);
if (!nodePathItem) {
throw new Error('There must be a minimum of one path');
throw new Error('There must be at least one node path item');
}
return {
pathId: node.path.map(p => p.value).join('/'),

View file

@ -25,6 +25,8 @@ export const waffleNodesQuery = gql`
metric {
name
value
avg
max
}
}
}

View file

@ -9,18 +9,17 @@ import { Query } from 'react-apollo';
import {
InfraMetricInput,
InfraNode,
InfraNodeType,
InfraPathInput,
InfraPathType,
InfraTimerangeInput,
WaffleNodesQuery,
} from '../../graphql/types';
import { InfraWaffleMapGroup } from '../../lib/lib';
import { nodesToWaffleMap } from './nodes_to_wafflemap';
import { waffleNodesQuery } from './waffle_nodes.gql_query';
interface WithWaffleNodesArgs {
nodes: InfraWaffleMapGroup[];
nodes: InfraNode[];
loading: boolean;
refetch: () => void;
}
@ -67,7 +66,7 @@ export const WithWaffleNodes = ({
loading,
nodes:
data && data.source && data.source.map && data.source.map.nodes
? nodesToWaffleMap(data.source.map.nodes)
? data.source.map.nodes
: [],
refetch,
})

View file

@ -22,13 +22,15 @@ import { UrlStateContainer } from '../../utils/url_state';
const selectOptionsUrlState = createSelector(
waffleOptionsSelectors.selectMetric,
waffleOptionsSelectors.selectView,
waffleOptionsSelectors.selectGroupBy,
waffleOptionsSelectors.selectNodeType,
waffleOptionsSelectors.selectCustomOptions,
(metric, groupBy, nodeType, customOptions) => ({
(metric, view, groupBy, nodeType, customOptions) => ({
metric,
groupBy,
nodeType,
view,
customOptions,
})
);
@ -38,6 +40,7 @@ export const withWaffleOptions = connect(
metric: waffleOptionsSelectors.selectMetric(state),
groupBy: waffleOptionsSelectors.selectGroupBy(state),
nodeType: waffleOptionsSelectors.selectNodeType(state),
view: waffleOptionsSelectors.selectView(state),
customOptions: waffleOptionsSelectors.selectCustomOptions(state),
urlState: selectOptionsUrlState(state),
}),
@ -45,6 +48,7 @@ export const withWaffleOptions = connect(
changeMetric: waffleOptionsActions.changeMetric,
changeGroupBy: waffleOptionsActions.changeGroupBy,
changeNodeType: waffleOptionsActions.changeNodeType,
changeView: waffleOptionsActions.changeView,
changeCustomOptions: waffleOptionsActions.changeCustomOptions,
})
);
@ -59,12 +63,20 @@ interface WaffleOptionsUrlState {
metric?: ReturnType<typeof waffleOptionsSelectors.selectMetric>;
groupBy?: ReturnType<typeof waffleOptionsSelectors.selectGroupBy>;
nodeType?: ReturnType<typeof waffleOptionsSelectors.selectNodeType>;
view?: ReturnType<typeof waffleOptionsSelectors.selectView>;
customOptions?: ReturnType<typeof waffleOptionsSelectors.selectCustomOptions>;
}
export const WithWaffleOptionsUrlState = () => (
<WithWaffleOptions>
{({ changeMetric, urlState, changeGroupBy, changeNodeType, changeCustomOptions }) => (
{({
changeMetric,
urlState,
changeGroupBy,
changeNodeType,
changeView,
changeCustomOptions,
}) => (
<UrlStateContainer
urlState={urlState}
urlStateKey="waffleOptions"
@ -79,6 +91,9 @@ export const WithWaffleOptionsUrlState = () => (
if (newUrlState && newUrlState.nodeType) {
changeNodeType(newUrlState.nodeType);
}
if (newUrlState && newUrlState.view) {
changeView(newUrlState.view);
}
if (newUrlState && newUrlState.customOptions) {
changeCustomOptions(newUrlState.customOptions);
}
@ -93,6 +108,9 @@ export const WithWaffleOptionsUrlState = () => (
if (initialUrlState && initialUrlState.nodeType) {
changeNodeType(initialUrlState.nodeType);
}
if (initialUrlState && initialUrlState.view) {
changeView(initialUrlState.view);
}
if (initialUrlState && initialUrlState.customOptions) {
changeCustomOptions(initialUrlState.customOptions);
}
@ -108,6 +126,7 @@ const mapToUrlState = (value: any): WaffleOptionsUrlState | undefined =>
metric: mapToMetricUrlState(value.metric),
groupBy: mapToGroupByUrlState(value.groupBy),
nodeType: mapToNodeTypeUrlState(value.nodeType),
view: mapToViewUrlState(value.view),
customOptions: mapToCustomOptionsUrlState(value.customOptions),
}
: undefined;
@ -141,6 +160,10 @@ const mapToNodeTypeUrlState = (subject: any) => {
return subject && InfraNodeType[subject] ? subject : undefined;
};
const mapToViewUrlState = (subject: any) => {
return subject && ['map', 'table'].includes(subject) ? subject : undefined;
};
const mapToCustomOptionsUrlState = (subject: any) => {
return subject && Array.isArray(subject) && subject.every(isInfraGroupByOption)
? subject

View file

@ -1774,6 +1774,30 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "avg",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "SCALAR", "name": "Float", "ofType": null }
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "max",
"description": "",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "SCALAR", "name": "Float", "ofType": null }
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,

View file

@ -215,6 +215,10 @@ export interface InfraNodeMetric {
name: InfraMetricType;
value: number;
avg: number;
max: number;
}
export interface InfraMetricData {
@ -719,6 +723,10 @@ export namespace WaffleNodesQuery {
name: InfraMetricType;
value: number;
avg: number;
max: number;
};
}

View file

@ -6,8 +6,8 @@
import React from 'react';
import { NodesOverview } from '../../components/nodes_overview';
import { PageContent } from '../../components/page';
import { Waffle } from '../../components/waffle';
import { WithWaffleFilter } from '../../containers/waffle/with_waffle_filters';
import { WithWaffleNodes } from '../../containers/waffle/with_waffle_nodes';
@ -27,7 +27,7 @@ export const HomePageContent: React.SFC = () => (
<WithWaffleTime>
{({ currentTimeRange, isAutoReloading }) => (
<WithWaffleOptions>
{({ metric, groupBy, nodeType }) => (
{({ metric, groupBy, nodeType, view, changeView }) => (
<WithWaffleNodes
filterQuery={filterQueryAsJson}
metric={metric}
@ -37,14 +37,16 @@ export const HomePageContent: React.SFC = () => (
timerange={currentTimeRange}
>
{({ nodes, loading, refetch }) => (
<Waffle
map={nodes}
<NodesOverview
nodes={nodes}
loading={nodes.length > 0 && isAutoReloading ? false : loading}
nodeType={nodeType}
options={{ ...wafflemap, metric, fields: configuredFields, groupBy }}
reload={refetch}
onDrilldown={applyFilterQuery}
timeRange={currentTimeRange}
view={view}
onViewChange={changeView}
/>
)}
</WithWaffleNodes>

View file

@ -5,7 +5,6 @@
*/
import actionCreatorFactory from 'typescript-fsa';
import { InfraMetricInput, InfraNodeType, InfraPathInput } from '../../../graphql/types';
import { InfraGroupByOptions } from '../../../lib/lib';
@ -15,3 +14,4 @@ export const changeMetric = actionCreator<InfraMetricInput>('CHANGE_METRIC');
export const changeGroupBy = actionCreator<InfraPathInput[]>('CHANGE_GROUP_BY');
export const changeCustomOptions = actionCreator<InfraGroupByOptions[]>('CHANGE_CUSTOM_OPTIONS');
export const changeNodeType = actionCreator<InfraNodeType>('CHANGE_NODE_TYPE');
export const changeView = actionCreator<string>('CHANGE_VIEW');

View file

@ -14,12 +14,19 @@ import {
InfraPathInput,
} from '../../../graphql/types';
import { InfraGroupByOptions } from '../../../lib/lib';
import { changeCustomOptions, changeGroupBy, changeMetric, changeNodeType } from './actions';
import {
changeCustomOptions,
changeGroupBy,
changeMetric,
changeNodeType,
changeView,
} from './actions';
export interface WaffleOptionsState {
metric: InfraMetricInput;
groupBy: InfraPathInput[];
nodeType: InfraNodeType;
view: string;
customOptions: InfraGroupByOptions[];
}
@ -27,6 +34,7 @@ export const initialWaffleOptionsState: WaffleOptionsState = {
metric: { type: InfraMetricType.cpu },
groupBy: [],
nodeType: InfraNodeType.host,
view: 'map',
customOptions: [],
};
@ -49,9 +57,15 @@ const currentNodeTypeReducer = reducerWithInitialState(initialWaffleOptionsState
(current, target) => target
);
const currentViewReducer = reducerWithInitialState(initialWaffleOptionsState.view).case(
changeView,
(current, target) => target
);
export const waffleOptionsReducer = combineReducers<WaffleOptionsState>({
metric: currentMetricReducer,
groupBy: currentGroupByReducer,
nodeType: currentNodeTypeReducer,
view: currentViewReducer,
customOptions: currentCustomOptionsReducer,
});

View file

@ -10,3 +10,4 @@ export const selectMetric = (state: WaffleOptionsState) => state.metric;
export const selectGroupBy = (state: WaffleOptionsState) => state.groupBy;
export const selectCustomOptions = (state: WaffleOptionsState) => state.customOptions;
export const selectNodeType = (state: WaffleOptionsState) => state.nodeType;
export const selectView = (state: WaffleOptionsState) => state.view;

View file

@ -10,6 +10,8 @@ export const nodesSchema: any = gql`
type InfraNodeMetric {
name: InfraMetricType!
value: Float!
avg: Float!
max: Float!
}
type InfraNodePath {

View file

@ -243,6 +243,10 @@ export interface InfraNodeMetric {
name: InfraMetricType;
value: number;
avg: number;
max: number;
}
export interface InfraMetricData {
@ -1283,6 +1287,10 @@ export namespace InfraNodeMetricResolvers {
name?: NameResolver<InfraMetricType, TypeParent, Context>;
value?: ValueResolver<number, TypeParent, Context>;
avg?: AvgResolver<number, TypeParent, Context>;
max?: MaxResolver<number, TypeParent, Context>;
}
export type NameResolver<
@ -1295,6 +1303,16 @@ export namespace InfraNodeMetricResolvers {
Parent = InfraNodeMetric,
Context = InfraContext
> = Resolver<R, Parent, Context>;
export type AvgResolver<R = number, Parent = InfraNodeMetric, Context = InfraContext> = Resolver<
R,
Parent,
Context
>;
export type MaxResolver<R = number, Parent = InfraNodeMetric, Context = InfraContext> = Resolver<
R,
Parent,
Context
>;
}
export namespace InfraMetricDataResolvers {

View file

@ -4,11 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { get, last } from 'lodash';
import { isNumber } from 'lodash';
import { get, isNumber, last, max, sum } from 'lodash';
import moment from 'moment';
import { InfraNode, InfraNodeMetric } from '../../../../graphql/types';
import { InfraMetricType, InfraNode, InfraNodeMetric } from '../../../../graphql/types';
import { InfraBucket, InfraNodeRequestOptions } from '../adapter_types';
import { NAME_FIELDS } from '../constants';
import { getBucketSizeInSeconds } from './get_bucket_size_in_seconds';
@ -34,6 +33,21 @@ const findLastFullBucket = (
}, last(buckets));
};
const getMetricValueFromBucket = (type: InfraMetricType) => (bucket: InfraBucket) => {
const metric = bucket[type];
return (metric && (metric.normalized_value || metric.value)) || 0;
};
function calculateMax(bucket: InfraBucket, type: InfraMetricType) {
const { buckets } = bucket.timeseries;
return max(buckets.map(getMetricValueFromBucket(type))) || 0;
}
function calculateAvg(bucket: InfraBucket, type: InfraMetricType) {
const { buckets } = bucket.timeseries;
return sum(buckets.map(getMetricValueFromBucket(type))) / buckets.length || 0;
}
function createNodeMetrics(
options: InfraNodeRequestOptions,
node: InfraBucket,
@ -45,11 +59,11 @@ function createNodeMetrics(
if (!lastBucket) {
throw new Error('Date histogram returned an empty set of buckets.');
}
const metricObj = lastBucket[metric.type];
const value = (metricObj && (metricObj.normalized_value || metricObj.value)) || 0;
return {
name: metric.type,
value,
value: getMetricValueFromBucket(metric.type)(lastBucket),
max: calculateMax(bucket, metric.type),
avg: calculateAvg(bucket, metric.type),
};
}

View file

@ -5,7 +5,6 @@
*/
import { cloneDeep, set } from 'lodash';
import moment from 'moment';
import { InfraESSearchBody, InfraProcesorRequestOptions } from '../../adapter_types';
import { createBasePath } from '../../lib/create_base_path';
import { getBucketSizeInSeconds } from '../../lib/get_bucket_size_in_seconds';
@ -24,10 +23,6 @@ export const dateHistogramProcessor = (options: InfraProcesorRequestOptions) =>
const result = cloneDeep(doc);
const { timerange, sourceConfiguration, groupBy } = options.nodeOptions;
const bucketSizeInSeconds = getBucketSizeInSeconds(timerange.interval);
const boundsMin = moment
.utc(timerange.from)
.subtract(5 * bucketSizeInSeconds, 's')
.valueOf();
const path = createBasePath(groupBy).concat('timeseries');
const bucketOffset = calculateOffsetInSeconds(timerange.from, bucketSizeInSeconds);
const offset = `${Math.floor(bucketOffset)}s`;
@ -38,7 +33,7 @@ export const dateHistogramProcessor = (options: InfraProcesorRequestOptions) =>
min_doc_count: 0,
offset,
extended_bounds: {
min: boundsMin,
min: timerange.from,
max: timerange.to,
},
},

View file

@ -1,3 +1,3 @@
{
"extends": "../../tsconfig.json"
}
"extends": "../../tsconfig.json",
}

View file

@ -170,4 +170,34 @@ declare module '@elastic/eui' {
};
export const EuiDatePickerRange: React.SFC<EuiDatePickerRangeProps>;
type EuiInMemoryTableProps = CommonProps & {
items?: any;
columns?: any;
sorting?: any;
search?: any;
selection?: any;
pagination?: any;
itemId?: any;
isSelectable?: any;
loading?: any;
hasActions?: any;
message?: any;
};
export const EuiInMemoryTable: React.SFC<EuiInMemoryTableProps>;
type EuiButtonGroupProps = CommonProps & {
buttonSize?: any;
color?: any;
idToSelectedMap?: any;
options?: any;
type?: any;
onChange?: any;
isIconOnly?: any;
isDisabled?: any;
isFullWidth?: any;
legend?: any;
idSelected?: any;
};
export const EuiButtonGroup: React.SFC<EuiButtonGroupProps>;
}

View file

@ -48,6 +48,8 @@ const waffleTests: KbnTestProvider = ({ getService }) => {
expect(firstNode.metric).to.eql({
name: 'cpu',
value: 0.011,
avg: 0.012215686274509805,
max: 0.020999999999999998,
__typename: 'InfraNodeMetric',
});
}