import React from 'react'; import classNames from 'classnames'; import { connect } from 'react-redux'; import { find, get, union, sortBy, groupBy, concat, debounce } from 'lodash'; import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits'; import { TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL } from '../../constants/timer'; import ShowMore from '../show-more'; import NodeDetailsTableRow from './node-details-table-row'; import NodeDetailsTableHeaders from './node-details-table-headers'; import { ipToPaddedString } from '../../utils/string-utils'; import { moveElement, insertElement } from '../../utils/array-utils'; import { isIP, isNumeric, defaultSortDesc, getTableColumnsStyles } from '../../utils/node-details-utils'; function getDefaultSortedBy(columns, nodes) { // default sorter specified by columns const defaultSortColumn = find(columns, { defaultSort: true }); if (defaultSortColumn) { return defaultSortColumn.id; } // otherwise choose first metric const firstNodeWithMetrics = find(nodes, n => get(n, ['metrics', 0])); if (firstNodeWithMetrics) { return get(firstNodeWithMetrics, ['metrics', 0, 'id']); } return 'label'; } function maybeToLower(value) { if (!value || !value.toLowerCase) { return value; } return value.toLowerCase(); } function getNodeValue(node, header) { const fieldId = header && header.id; if (fieldId !== null) { let field = union(node.metrics, node.metadata).find(f => f.id === fieldId); if (field) { if (isIP(header)) { // Format the IPs so that they are sorted numerically. return ipToPaddedString(field.value); } if (isNumeric(header)) { return parseFloat(field.value); } return field.value; } if (node.parents) { field = node.parents.find(f => f.topologyId === fieldId); if (field) { return field.label; } } if (node[fieldId] !== undefined && node[fieldId] !== null) { return node[fieldId]; } } return null; } function getValueForSortedBy(sortedByHeader) { return node => maybeToLower(getNodeValue(node, sortedByHeader)); } function getMetaDataSorters(nodes) { // returns an array of sorters that will take a node return get(nodes, [0, 'metadata'], []).map((field, index) => (node) => { const nodeMetadataField = node.metadata && node.metadata[index]; if (nodeMetadataField) { if (isNumeric(nodeMetadataField)) { return parseFloat(nodeMetadataField.value); } return nodeMetadataField.value; } return null; }); } function sortNodes(nodes, getValue, sortedDesc) { const sortedNodes = sortBy( nodes, getValue, getMetaDataSorters(nodes) ); if (sortedDesc) { sortedNodes.reverse(); } return sortedNodes; } function getSortedNodes(nodes, sortedByHeader, sortedDesc) { const getValue = getValueForSortedBy(sortedByHeader); const withAndWithoutValues = groupBy(nodes, (n) => { if (!n || n.valueEmpty) { return 'withoutValues'; } const v = getValue(n); return v !== null && v !== undefined ? 'withValues' : 'withoutValues'; }); const withValues = sortNodes(withAndWithoutValues.withValues, getValue, sortedDesc); const withoutValues = sortNodes(withAndWithoutValues.withoutValues, getValue, sortedDesc); return concat(withValues, withoutValues); } // By inserting this fake invisible row into the table, with the help of // some CSS trickery, we make the inner scrollable content of the table // have a minimal height. That prevents auto-scroll under a focus if the // number of table rows shrinks. function minHeightConstraint(height = 0) { return ; } class NodeDetailsTable extends React.Component { constructor(props, context) { super(props, context); this.state = { limit: props.limit, sortedBy: this.props.sortedBy, sortedDesc: this.props.sortedDesc }; this.focusState = {}; this.updateSorted = this.updateSorted.bind(this); this.handleLimitClick = this.handleLimitClick.bind(this); this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this); this.onMouseEnterRow = this.onMouseEnterRow.bind(this); this.saveTableContentRef = this.saveTableContentRef.bind(this); this.saveTableHeadRef = this.saveTableHeadRef.bind(this); // Use debouncing to prevent event flooding when e.g. crossing fast with mouse cursor // over the whole table. That would be expensive as each focus causes table to rerender. this.debouncedFocusRow = debounce(this.focusRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL); this.debouncedBlurRow = debounce(this.blurRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL); } updateSorted(sortedBy, sortedDesc) { this.setState({ sortedBy, sortedDesc }); this.props.onSortChange(sortedBy, sortedDesc); } handleLimitClick() { this.setState(prevState => ({ limit: prevState.limit ? 0 : this.props.limit })); } focusRow(rowIndex, node) { // Remember the focused row index, the node that was focused and // the table content height so that we can keep the node row fixed // without auto-scrolling happening. // NOTE: It would be ideal to modify the real component state here, // but that would cause whole table to rerender, which becomes to // expensive with the current implementation if the table consists // of 1000+ nodes. this.focusState = { focusedNode: node, focusedRowIndex: rowIndex, tableContentMinHeightConstraint: this.tableContentRef && this.tableContentRef.scrollHeight, }; } blurRow() { // Reset the focus state this.focusState = {}; } onMouseEnterRow(rowIndex, node) { this.debouncedBlurRow.cancel(); this.debouncedFocusRow(rowIndex, node); } onMouseLeaveRow() { this.debouncedFocusRow.cancel(); this.debouncedBlurRow(); } saveTableContentRef(ref) { this.tableContentRef = ref; } saveTableHeadRef(ref) { this.tableHeadRef = ref; } getColumnHeaders() { const columns = this.props.columns || []; return [{ id: 'label', label: this.props.label }].concat(columns); } componentDidMount() { const scrollbarWidth = this.tableContentRef.offsetWidth - this.tableContentRef.clientWidth; this.tableHeadRef.style.paddingRight = `${scrollbarWidth}px`; } render() { const { nodeIdKey, columns, topologyId, onClickRow, onMouseEnter, onMouseLeave, timestamp } = this.props; const sortedBy = this.state.sortedBy || getDefaultSortedBy(columns, this.props.nodes); const sortedByHeader = this.getColumnHeaders().find(h => h.id === sortedBy); const sortedDesc = (this.state.sortedDesc === null) ? defaultSortDesc(sortedByHeader) : this.state.sortedDesc; let nodes = getSortedNodes(this.props.nodes, sortedByHeader, sortedDesc); const { focusedNode, focusedRowIndex, tableContentMinHeightConstraint } = this.focusState; if (Number.isInteger(focusedRowIndex) && focusedRowIndex < nodes.length) { const nodeRowIndex = nodes.findIndex(node => node.id === focusedNode.id); if (nodeRowIndex >= 0) { // If the focused node still exists in the table, we move it // to the hovered row, keeping the rest of the table sorted. nodes = moveElement(nodes, nodeRowIndex, focusedRowIndex); } else { // Otherwise we insert the dead focused node there, pretending // it's still alive. That enables the users to read off all the // info they want and perhaps even open the details panel. Also, // only if we do this, we can guarantee that mouse hover will // always freeze the table row until we focus out. nodes = insertElement(nodes, focusedRowIndex, focusedNode); } } // If we are 1 over the limit, we still show the row. We never display // "+1" but only "+2" and up. const limit = this.state.limit > 0 && nodes.length === this.state.limit + 1 ? nodes.length : this.state.limit; const limited = nodes && limit > 0 && nodes.length > limit; const expanded = limit === 0; const notShown = nodes.length - limit; if (nodes && limited) { nodes = nodes.slice(0, limit); } const className = classNames('node-details-table-wrapper-wrapper', this.props.className); const headers = this.getColumnHeaders(); const styles = getTableColumnsStyles(headers); return (
{this.props.nodes && this.props.nodes.length > 0 && ( )} {nodes && nodes.map((node, index) => ( ))} {minHeightConstraint(tableContentMinHeightConstraint)}
); } } NodeDetailsTable.defaultProps = { limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT, // key to identify a node in a row (used for topology links) nodeIdKey: 'id', onSortChange: () => { }, sortedBy: null, sortedDesc: null, }; function mapStateToProps(state) { return { timestamp: state.get('pausedAt'), }; } export default connect(mapStateToProps)(NodeDetailsTable);