mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-04 02:30:45 +00:00
Sizes the fixed table header of `NodeDetailsTable` width dynamically depending on the content's scrollbar width. Makes sure the table header cells align with the table body cells. This also widens the "Parent PID" cell to make sure the text is not cut off. Alternatively, could be renamed to "PPID". Fixes #3158.
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
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);
|
|
} else 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,
|
|
sortedDesc: this.props.sortedDesc,
|
|
sortedBy: this.props.sortedBy
|
|
};
|
|
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() {
|
|
const limit = this.state.limit ? 0 : this.props.limit;
|
|
this.setState({ 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 = {
|
|
nodeIdKey: 'id', // key to identify a node in a row (used for topology links)
|
|
limit: NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT,
|
|
onSortChange: () => {},
|
|
sortedDesc: null,
|
|
sortedBy: null,
|
|
};
|
|
|
|
function mapStateToProps(state) {
|
|
return {
|
|
timestamp: state.get('pausedAt'),
|
|
};
|
|
}
|
|
|
|
export default connect(mapStateToProps)(NodeDetailsTable);
|