mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 18:20:27 +00:00
Having to toggle a +1 feels weird while that +1 takes up the same vertical space as displaying that row.
308 lines
9.9 KiB
JavaScript
308 lines
9.9 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 { getSerializedTimeTravelTimestamp } from '../../utils/web-api-utils';
|
|
import {
|
|
isIP, isNumber, 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 (isNumber(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 (isNumber(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) => {
|
|
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);
|
|
// 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.tableContent.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.tableContent = ref;
|
|
}
|
|
|
|
getColumnHeaders() {
|
|
const columns = this.props.columns || [];
|
|
return [{id: 'label', label: this.props.label}].concat(columns);
|
|
}
|
|
|
|
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>
|
|
{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: getSerializedTimeTravelTimestamp(state),
|
|
};
|
|
}
|
|
|
|
export default connect(mapStateToProps)(NodeDetailsTable);
|