Node Drives list

* Adds Drives List modal which lists relevant information about all
  Node disks. This list is accessible for Introspected node through
  Node's action menu in Nodes list view

Change-Id: I5985388527e307cd4d31235c79abc014332ffb7c
This commit is contained in:
Jiri Tomasek 2017-06-08 15:34:13 +02:00
parent 046188f13a
commit 85f04eb9af
11 changed files with 362 additions and 7 deletions

View File

@ -0,0 +1,5 @@
---
features:
- |
Information about Drives is now available for each Node. List of Drives is
accessible through Node action menu in Nodes list view.

View File

@ -0,0 +1,168 @@
/**
* Copyright 2017 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { defineMessages, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Col, Row } from 'react-bootstrap';
import { formatBytes } from '../../utils';
import {
ListViewAdditionalInfo,
ListViewAdditionalInfoItem,
ListViewBody,
ListViewDescription,
ListViewDescriptionHeading,
ListViewDescriptionText,
ListViewExpand,
ListViewIcon,
ListViewItem,
ListViewItemContainer,
ListViewItemHeader,
ListViewLeft,
ListViewMainInfo
} from '../../ui/ListView';
const messages = defineMessages({
type: {
id: 'NodeDrive.type',
defaultMessage: 'Type:'
},
size: {
id: 'NodeDrive.size',
defaultMessage: 'Size:'
},
model: {
id: 'NodeDrive.model',
defaultMessage: 'Model:'
},
serial: {
id: 'NodeDrive.serial',
defaultMessage: 'Serial:'
},
vendor: {
id: 'NodeDrive.vendor',
defaultMessage: 'Vendor:'
},
wwn: {
id: 'NodeDrive.wwn',
defaultMessage: 'WWN:'
},
wwnVendorExtension: {
id: 'NodeDrive.wwnVendorExtension',
defaultMessage: 'WWN Vendor Extension:'
},
wwnWithExtension: {
id: 'NodeDrive.wwnWithExtension',
defaultMessage: 'WWN with Extension:'
},
rootDisk: {
id: 'NodeDrive.rootDisk',
defaultMessage: 'Root Device'
},
na: {
id: 'NodeDrive.notAvailable',
defaultMessage: 'n/a'
}
});
export default class NodeDrive extends Component {
constructor() {
super();
this.state = {
expanded: false
};
}
toggleExpanded() {
this.setState(prevState => ({ expanded: !prevState.expanded }));
}
render() {
const { drive } = this.props;
const [driveSize, driveSizeUnit] = formatBytes(drive.size);
return (
<ListViewItem stacked={false} expanded={this.state.expanded}>
<ListViewItemHeader toggleExpanded={this.toggleExpanded.bind(this)}>
<ListViewExpand expanded={this.state.expanded} />
<ListViewMainInfo>
<ListViewLeft>
<ListViewIcon size="sm" icon="pficon pficon-volume" />
</ListViewLeft>
<ListViewBody>
<ListViewDescription>
<ListViewDescriptionHeading>
{drive.name}
</ListViewDescriptionHeading>
<ListViewDescriptionText>
{drive.rootDisk &&
<FormattedMessage {...messages.rootDisk} />}
</ListViewDescriptionText>
</ListViewDescription>
<ListViewAdditionalInfo>
<ListViewAdditionalInfoItem>
<FormattedMessage {...messages.type} />&nbsp;
<strong>{drive.rotational ? 'HDD' : 'SSD'}</strong>
</ListViewAdditionalInfoItem>
<ListViewAdditionalInfoItem>
<FormattedMessage {...messages.size} />&nbsp;
<strong>{driveSize}</strong>
{driveSizeUnit}
</ListViewAdditionalInfoItem>
</ListViewAdditionalInfo>
</ListViewBody>
</ListViewMainInfo>
</ListViewItemHeader>
<ListViewItemContainer
onClose={this.toggleExpanded.bind(this)}
expanded={this.state.expanded}
>
<Row>
<Col sm={11}>
<dl className="dl-horizontal">
<dt><FormattedMessage {...messages.model} /></dt>
<dd>{drive.model || <FormattedMessage {...messages.na} />}</dd>
<dt><FormattedMessage {...messages.serial} /></dt>
<dd>{drive.serial || <FormattedMessage {...messages.na} />}</dd>
<dt><FormattedMessage {...messages.vendor} /></dt>
<dd>{drive.vendor || <FormattedMessage {...messages.na} />}</dd>
<dt><FormattedMessage {...messages.wwn} /></dt>
<dd>{drive.wwn || <FormattedMessage {...messages.na} />}</dd>
<dt><FormattedMessage {...messages.wwnVendorExtension} /></dt>
<dd>
{drive.wwn_vendor_extension ||
<FormattedMessage {...messages.na} />}
</dd>
<dt><FormattedMessage {...messages.wwnWithExtension} /></dt>
<dd>
{drive.wwn_with_extension ||
<FormattedMessage {...messages.na} />}
</dd>
</dl>
</Col>
</Row>
</ListViewItemContainer>
</ListViewItem>
);
}
}
NodeDrive.propTypes = {
drive: PropTypes.object.isRequired
};

View File

@ -0,0 +1,90 @@
/**
* Copyright 2017 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { connect } from 'react-redux';
import { defineMessages, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { getNodeDrives } from '../../../selectors/nodes';
import { ListView } from '../../ui/ListView';
import Modal from '../../ui/Modal';
import NodeDrive from './NodeDrive';
import NodesActions from '../../../actions/NodesActions';
const messages = defineMessages({
title: {
id: 'NodeDrives.title',
defaultMessage: 'Node Drives - {nodeId}'
},
close: {
id: 'NodeDrives.close',
defaultMessage: 'Close'
}
});
class NodeDrives extends Component {
componentDidMount() {
this.props.fetchNodeIntrospectionData();
}
render() {
return (
<Modal dialogClasses="modal-xl">
<div className="modal-header">
<Link to="/nodes" type="button" className="close">
<span aria-hidden="true" className="pficon pficon-close" />
</Link>
<h4 className="modal-title">
<FormattedMessage
{...messages.title}
values={{ nodeId: this.props.match.params.nodeId }}
/>
</h4>
</div>
<div className="modal-body">
<ListView>
{this.props.drives
.toJS()
.map(drive => <NodeDrive key={drive.name} drive={drive} />)}
</ListView>
</div>
<div className="modal-footer">
<Link to="/nodes" type="button" className="btn btn-default">
<FormattedMessage {...messages.close} />
</Link>
</div>
</Modal>
);
}
}
NodeDrives.propTypes = {
drives: ImmutablePropTypes.list.isRequired,
fetchNodeIntrospectionData: PropTypes.func.isRequired,
match: PropTypes.object.isRequired
};
const mapStateToProps = (state, props) => ({
drives: getNodeDrives(state, props.match.params.nodeId)
});
const mapDispatchToProps = (dispatch, props) => ({
fetchNodeIntrospectionData: () =>
dispatch(NodesActions.fetchNodeIntrospectionData(props.match.params.nodeId))
});
export default connect(mapStateToProps, mapDispatchToProps)(NodeDrives);

View File

@ -25,6 +25,7 @@ import { Route } from 'react-router-dom';
import { getFilterByName } from '../../selectors/filters';
import { getFilteredNodes, nodesInProgress } from '../../selectors/nodes';
import Loader from '../ui/Loader';
import NodeDrives from './NodeDrives/NodeDrives';
import NodesActions from '../../actions/NodesActions';
import NodesListForm from './NodesListView/NodesListForm';
import NodesListView from './NodesListView/NodesListView';
@ -112,6 +113,7 @@ class Nodes extends React.Component {
{this.renderContentView()}
</Loader>
<Route path="/nodes/register" component={RegisterNodesDialog} />
<Route path="/nodes/:nodeId/drives" component={NodeDrives} />
</div>
);
}

View File

@ -91,7 +91,9 @@ const messages = defineMessages({
class NodeExtendedInfo extends React.Component {
componentDidMount() {
if (this.props.node.getIn(['introspectionStatus', 'finished'])) {
if (
this.props.node.getIn(['introspectionStatus', 'state']) === 'finished'
) {
this.props.fetchNodeIntrospectionData(this.props.node.get('uuid'));
}
}

View File

@ -16,11 +16,13 @@
import ClassNames from 'classnames';
import { defineMessages, FormattedMessage } from 'react-intl';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import React from 'react';
import DropdownKebab from '../../ui/dropdown/DropdownKebab';
import {
ListViewActions,
ListViewAdditionalInfo,
ListViewAdditionalInfoItem,
ListViewBody,
@ -36,6 +38,7 @@ import {
ListViewLeft,
ListViewMainInfo
} from '../../ui/ListView';
import MenuItemLink from '../../ui/dropdown/MenuItemLink';
import NodeExtendedInfo from './NodeExtendedInfo';
import {
NodeIntrospectionStatus,
@ -62,6 +65,10 @@ const messages = defineMessages({
disk: {
id: 'NodeListItem.gbDisk',
defaultMessage: 'GB Disk'
},
manageDrives: {
id: 'NodeListItem.actions.manageDrives',
defaultMessage: 'Manage Drives'
}
});
@ -92,6 +99,14 @@ export default class NodeListItem extends React.Component {
disabled={inProgress}
name={`values.${node.get('uuid')}`}
/>
{node.getIn(['introspectionStatus', 'state']) === 'finished' &&
<ListViewActions>
<DropdownKebab id={`${node.get('uuid')}Actions`} pullRight>
<MenuItemLink to={`/nodes/${node.get('uuid')}/drives`}>
<FormattedMessage {...messages.manageDrives} />
</MenuItemLink>
</DropdownKebab>
</ListViewActions>}
<ListViewMainInfo>
<ListViewLeft>
<ListViewIcon size="sm" icon={iconClass} />

View File

@ -17,7 +17,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import ClassNames from 'classnames';
import Timer from '../utils/Timer';
import { Timer } from '../utils';
export default class Notification extends React.Component {
constructor() {

View File

@ -8,8 +8,8 @@ import PropTypes from 'prop-types';
<ListView>
<ListViewItem stacked expanded>
<ListViewItemHeader> // required only if the ListViewItem is supposed to be expandable
<ListViewExpand toggleExpanded={functionToToggle} expanded />
<ListViewItemHeader toggleExpanded={functionToToggle}> // required only if the ListViewItem is supposed to be expandable
<ListViewExpand expanded />
<ListViewCheckbox disabled={inProgress} name={`values.${node.uuid}`} />
<ListViewActions>
// buttons, dropdowns...

View File

@ -0,0 +1,45 @@
/**
* Copyright 2017 Red Hat Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import PropTypes from 'prop-types';
import React from 'react';
import { Link, Route } from 'react-router-dom';
const MenuItemLink = ({ children, to, exact, location }) => {
return (
<Route
path={to}
exact={exact}
children={({ match, location }) => (
<li onClick={e => e.stopPropagation()}>
<Link to={to}>{children}</Link>
</li>
)}
/>
);
};
MenuItemLink.propTypes = {
children: PropTypes.node,
exact: PropTypes.bool.isRequired,
location: PropTypes.object,
to: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired
};
MenuItemLink.defaultProps = {
exact: false
};
export default MenuItemLink;

View File

@ -14,7 +14,7 @@
* under the License.
*/
export default function Timer(callback, delay) {
export function Timer(callback, delay) {
var timerId;
var start;
var remaining = delay;
@ -36,3 +36,15 @@ export default function Timer(callback, delay) {
this.resume();
}
/**
* Utility function to format value in Bytes to human readable format
*/
export const formatBytes = (bytes, decimals) => {
if (bytes == 0) return '0 Bytes';
var k = 1000,
dm = decimals || 2,
sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return [parseFloat((bytes / Math.pow(k, i)).toFixed(dm)), sizes[i]];
};

View File

@ -32,6 +32,8 @@ export const getNodesByIds = (state, nodeIds) =>
export const getPorts = state => state.nodes.get('ports');
export const getIntrospectionData = state =>
state.nodes.get('introspectionData');
export const getNodeIntrospectionData = (state, nodeId) =>
state.nodes.introspectionData.get(nodeId, Map());
export const getIntrospectionStatuses = state =>
state.nodes.get('introspectionStatuses');
export const nodesInProgress = state => state.nodes.get('nodesInProgress');
@ -145,6 +147,20 @@ export const getNodesOperationInProgress = createSelector(
nodesInProgress => !nodesInProgress.isEmpty()
);
export const getNodeDrives = createSelector(
getNodeIntrospectionData,
nodeIntrospectionData =>
nodeIntrospectionData
.getIn(['inventory', 'disks'], List())
.map(disk =>
disk.set(
'rootDisk',
nodeIntrospectionData.getIn(['root_disk', 'name']) ===
disk.get('name')
)
)
);
/**
* Helper function to get node capabilities object
* @param node