node-details-table.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import React from 'react';
  2. import classNames from 'classnames';
  3. import { connect } from 'react-redux';
  4. import {
  5. find, get, union, sortBy, groupBy, concat, debounce
  6. } from 'lodash';
  7. import { NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT } from '../../constants/limits';
  8. import { TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL } from '../../constants/timer';
  9. import ShowMore from '../show-more';
  10. import NodeDetailsTableRow from './node-details-table-row';
  11. import NodeDetailsTableHeaders from './node-details-table-headers';
  12. import { ipToPaddedString } from '../../utils/string-utils';
  13. import { moveElement, insertElement } from '../../utils/array-utils';
  14. import {
  15. isIP, isNumeric, defaultSortDesc, getTableColumnsStyles
  16. } from '../../utils/node-details-utils';
  17. function getDefaultSortedBy(columns, nodes) {
  18. // default sorter specified by columns
  19. const defaultSortColumn = find(columns, { defaultSort: true });
  20. if (defaultSortColumn) {
  21. return defaultSortColumn.id;
  22. }
  23. // otherwise choose first metric
  24. const firstNodeWithMetrics = find(nodes, n => get(n, ['metrics', 0]));
  25. if (firstNodeWithMetrics) {
  26. return get(firstNodeWithMetrics, ['metrics', 0, 'id']);
  27. }
  28. return 'label';
  29. }
  30. function maybeToLower(value) {
  31. if (!value || !value.toLowerCase) {
  32. return value;
  33. }
  34. return value.toLowerCase();
  35. }
  36. function getNodeValue(node, header) {
  37. const fieldId = header && header.id;
  38. if (fieldId !== null) {
  39. let field = union(node.metrics, node.metadata).find(f => f.id === fieldId);
  40. if (field) {
  41. if (isIP(header)) {
  42. // Format the IPs so that they are sorted numerically.
  43. return ipToPaddedString(field.value);
  44. } if (isNumeric(header)) {
  45. return parseFloat(field.value);
  46. }
  47. return field.value;
  48. }
  49. if (node.parents) {
  50. field = node.parents.find(f => f.topologyId === fieldId);
  51. if (field) {
  52. return field.label;
  53. }
  54. }
  55. if (node[fieldId] !== undefined && node[fieldId] !== null) {
  56. return node[fieldId];
  57. }
  58. }
  59. return null;
  60. }
  61. function getValueForSortedBy(sortedByHeader) {
  62. return node => maybeToLower(getNodeValue(node, sortedByHeader));
  63. }
  64. function getMetaDataSorters(nodes) {
  65. // returns an array of sorters that will take a node
  66. return get(nodes, [0, 'metadata'], []).map((field, index) => (node) => {
  67. const nodeMetadataField = node.metadata && node.metadata[index];
  68. if (nodeMetadataField) {
  69. if (isNumeric(nodeMetadataField)) {
  70. return parseFloat(nodeMetadataField.value);
  71. }
  72. return nodeMetadataField.value;
  73. }
  74. return null;
  75. });
  76. }
  77. function sortNodes(nodes, getValue, sortedDesc) {
  78. const sortedNodes = sortBy(
  79. nodes,
  80. getValue,
  81. getMetaDataSorters(nodes)
  82. );
  83. if (sortedDesc) {
  84. sortedNodes.reverse();
  85. }
  86. return sortedNodes;
  87. }
  88. function getSortedNodes(nodes, sortedByHeader, sortedDesc) {
  89. const getValue = getValueForSortedBy(sortedByHeader);
  90. const withAndWithoutValues = groupBy(nodes, (n) => {
  91. if (!n || n.valueEmpty) {
  92. return 'withoutValues';
  93. }
  94. const v = getValue(n);
  95. return v !== null && v !== undefined ? 'withValues' : 'withoutValues';
  96. });
  97. const withValues = sortNodes(withAndWithoutValues.withValues, getValue, sortedDesc);
  98. const withoutValues = sortNodes(withAndWithoutValues.withoutValues, getValue, sortedDesc);
  99. return concat(withValues, withoutValues);
  100. }
  101. // By inserting this fake invisible row into the table, with the help of
  102. // some CSS trickery, we make the inner scrollable content of the table
  103. // have a minimal height. That prevents auto-scroll under a focus if the
  104. // number of table rows shrinks.
  105. function minHeightConstraint(height = 0) {
  106. return <tr className="min-height-constraint" style={{ height }} />;
  107. }
  108. class NodeDetailsTable extends React.Component {
  109. constructor(props, context) {
  110. super(props, context);
  111. this.state = {
  112. limit: props.limit,
  113. sortedBy: this.props.sortedBy,
  114. sortedDesc: this.props.sortedDesc
  115. };
  116. this.focusState = {};
  117. this.updateSorted = this.updateSorted.bind(this);
  118. this.handleLimitClick = this.handleLimitClick.bind(this);
  119. this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this);
  120. this.onMouseEnterRow = this.onMouseEnterRow.bind(this);
  121. this.saveTableContentRef = this.saveTableContentRef.bind(this);
  122. this.saveTableHeadRef = this.saveTableHeadRef.bind(this);
  123. // Use debouncing to prevent event flooding when e.g. crossing fast with mouse cursor
  124. // over the whole table. That would be expensive as each focus causes table to rerender.
  125. this.debouncedFocusRow = debounce(this.focusRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL);
  126. this.debouncedBlurRow = debounce(this.blurRow, TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL);
  127. }
  128. updateSorted(sortedBy, sortedDesc) {
  129. this.setState({ sortedBy, sortedDesc });
  130. this.props.onSortChange(sortedBy, sortedDesc);
  131. }
  132. handleLimitClick() {
  133. this.setState(prevState => ({
  134. limit: prevState.limit ? 0 : this.props.limit
  135. }));
  136. }
  137. focusRow(rowIndex, node) {
  138. // Remember the focused row index, the node that was focused and
  139. // the table content height so that we can keep the node row fixed
  140. // without auto-scrolling happening.
  141. // NOTE: It would be ideal to modify the real component state here,
  142. // but that would cause whole table to rerender, which becomes to
  143. // expensive with the current implementation if the table consists
  144. // of 1000+ nodes.
  145. this.focusState = {
  146. focusedNode: node,
  147. focusedRowIndex: rowIndex,
  148. tableContentMinHeightConstraint: this.tableContentRef && this.tableContentRef.scrollHeight,
  149. };
  150. }
  151. blurRow() {
  152. // Reset the focus state
  153. this.focusState = {};
  154. }
  155. onMouseEnterRow(rowIndex, node) {
  156. this.debouncedBlurRow.cancel();
  157. this.debouncedFocusRow(rowIndex, node);
  158. }
  159. onMouseLeaveRow() {
  160. this.debouncedFocusRow.cancel();
  161. this.debouncedBlurRow();
  162. }
  163. saveTableContentRef(ref) {
  164. this.tableContentRef = ref;
  165. }
  166. saveTableHeadRef(ref) {
  167. this.tableHeadRef = ref;
  168. }
  169. getColumnHeaders() {
  170. const columns = this.props.columns || [];
  171. return [{ id: 'label', label: this.props.label }].concat(columns);
  172. }
  173. componentDidMount() {
  174. const scrollbarWidth = this.tableContentRef.offsetWidth - this.tableContentRef.clientWidth;
  175. this.tableHeadRef.style.paddingRight = `${scrollbarWidth}px`;
  176. }
  177. render() {
  178. const {
  179. nodeIdKey, columns, topologyId, onClickRow,
  180. onMouseEnter, onMouseLeave, timestamp
  181. } = this.props;
  182. const sortedBy = this.state.sortedBy || getDefaultSortedBy(columns, this.props.nodes);
  183. const sortedByHeader = this.getColumnHeaders().find(h => h.id === sortedBy);
  184. const sortedDesc = (this.state.sortedDesc === null)
  185. ? defaultSortDesc(sortedByHeader) : this.state.sortedDesc;
  186. let nodes = getSortedNodes(this.props.nodes, sortedByHeader, sortedDesc);
  187. const { focusedNode, focusedRowIndex, tableContentMinHeightConstraint } = this.focusState;
  188. if (Number.isInteger(focusedRowIndex) && focusedRowIndex < nodes.length) {
  189. const nodeRowIndex = nodes.findIndex(node => node.id === focusedNode.id);
  190. if (nodeRowIndex >= 0) {
  191. // If the focused node still exists in the table, we move it
  192. // to the hovered row, keeping the rest of the table sorted.
  193. nodes = moveElement(nodes, nodeRowIndex, focusedRowIndex);
  194. } else {
  195. // Otherwise we insert the dead focused node there, pretending
  196. // it's still alive. That enables the users to read off all the
  197. // info they want and perhaps even open the details panel. Also,
  198. // only if we do this, we can guarantee that mouse hover will
  199. // always freeze the table row until we focus out.
  200. nodes = insertElement(nodes, focusedRowIndex, focusedNode);
  201. }
  202. }
  203. // If we are 1 over the limit, we still show the row. We never display
  204. // "+1" but only "+2" and up.
  205. const limit = this.state.limit > 0 && nodes.length === this.state.limit + 1
  206. ? nodes.length
  207. : this.state.limit;
  208. const limited = nodes && limit > 0 && nodes.length > limit;
  209. const expanded = limit === 0;
  210. const notShown = nodes.length - limit;
  211. if (nodes && limited) {
  212. nodes = nodes.slice(0, limit);
  213. }
  214. const className = classNames('node-details-table-wrapper-wrapper', this.props.className);
  215. const headers = this.getColumnHeaders();
  216. const styles = getTableColumnsStyles(headers);
  217. return (
  218. <div className={className} style={this.props.style}>
  219. <div className="node-details-table-wrapper">
  220. <table className="node-details-table">
  221. <thead ref={this.saveTableHeadRef}>
  222. {this.props.nodes && this.props.nodes.length > 0 && (
  223. <NodeDetailsTableHeaders
  224. headers={headers}
  225. sortedBy={sortedBy}
  226. sortedDesc={sortedDesc}
  227. onClick={this.updateSorted}
  228. />
  229. )}
  230. </thead>
  231. <tbody
  232. style={this.props.tbodyStyle}
  233. ref={this.saveTableContentRef}
  234. onMouseEnter={onMouseEnter}
  235. onMouseLeave={onMouseLeave}>
  236. {nodes && nodes.map((node, index) => (
  237. <NodeDetailsTableRow
  238. key={node.id}
  239. renderIdCell={this.props.renderIdCell}
  240. selected={this.props.selectedNodeId === node.id}
  241. node={node}
  242. index={index}
  243. nodeIdKey={nodeIdKey}
  244. colStyles={styles}
  245. columns={columns}
  246. onClick={onClickRow}
  247. onMouseEnter={this.onMouseEnterRow}
  248. onMouseLeave={this.onMouseLeaveRow}
  249. timestamp={timestamp}
  250. topologyId={topologyId} />
  251. ))}
  252. {minHeightConstraint(tableContentMinHeightConstraint)}
  253. </tbody>
  254. </table>
  255. <ShowMore
  256. handleClick={this.handleLimitClick}
  257. collection={nodes}
  258. expanded={expanded}
  259. notShown={notShown} />
  260. </div>
  261. </div>
  262. );
  263. }
  264. }
  265. NodeDetailsTable.defaultProps = {
  266. limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT,
  267. // key to identify a node in a row (used for topology links)
  268. nodeIdKey: 'id',
  269. onSortChange: () => { },
  270. sortedBy: null,
  271. sortedDesc: null,
  272. };
  273. function mapStateToProps(state) {
  274. return {
  275. timestamp: state.get('pausedAt'),
  276. };
  277. }
  278. export default connect(mapStateToProps)(NodeDetailsTable);