123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- 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 <tr className="min-height-constraint" style={{ height }} />;
- }
- 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 (
- <div className={className} style={this.props.style}>
- <div className="node-details-table-wrapper">
- <table className="node-details-table">
- <thead ref={this.saveTableHeadRef}>
- {this.props.nodes && this.props.nodes.length > 0 && (
- <NodeDetailsTableHeaders
- headers={headers}
- sortedBy={sortedBy}
- sortedDesc={sortedDesc}
- onClick={this.updateSorted}
- />
- )}
- </thead>
- <tbody
- style={this.props.tbodyStyle}
- ref={this.saveTableContentRef}
- onMouseEnter={onMouseEnter}
- onMouseLeave={onMouseLeave}>
- {nodes && nodes.map((node, index) => (
- <NodeDetailsTableRow
- key={node.id}
- renderIdCell={this.props.renderIdCell}
- selected={this.props.selectedNodeId === node.id}
- node={node}
- index={index}
- nodeIdKey={nodeIdKey}
- colStyles={styles}
- columns={columns}
- onClick={onClickRow}
- onMouseEnter={this.onMouseEnterRow}
- onMouseLeave={this.onMouseLeaveRow}
- timestamp={timestamp}
- topologyId={topologyId} />
- ))}
- {minHeightConstraint(tableContentMinHeightConstraint)}
- </tbody>
- </table>
- <ShowMore
- handleClick={this.handleLimitClick}
- collection={nodes}
- expanded={expanded}
- notShown={notShown} />
- </div>
- </div>
- );
- }
- }
- 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);
|