mirror of https://github.com/linkerd/linkerd2.git
Adding filter feature to web UI (#2297)
Fixes #1792. This PR adds filter functionality to the web UI via an optional Material-UI <Toolbar> on the top of the table which contains the table's title and a filter icon. The toolbar only shows if the enableFilter={true} prop is passed down from the parent component. The PR modifies the MetricsTable test and adds tests for BaseTable and TopRoutesTable. Note: The previous Ant-based UI allowed certain tables to be filtered by individual table column; this capacity is not part of this PR but can be added later if useful.
This commit is contained in:
parent
4cd1f99e89
commit
1ff04af024
|
@ -1,3 +1,5 @@
|
|||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
@ -7,7 +9,10 @@ import TableCell from '@material-ui/core/TableCell';
|
|||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import TableSortLabel from '@material-ui/core/TableSortLabel';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Toolbar from '@material-ui/core/Toolbar';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import _find from 'lodash/find';
|
||||
import _get from 'lodash/get';
|
||||
import _isNil from 'lodash/isNil';
|
||||
|
@ -25,12 +30,18 @@ const styles = theme => ({
|
|||
activeSortIcon: {
|
||||
opacity: 1,
|
||||
},
|
||||
toolbarIcon: {
|
||||
cursor: "pointer",
|
||||
opacity: 0.8
|
||||
},
|
||||
inactiveSortIcon: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
denseTable: {
|
||||
paddingRight: "8px",
|
||||
paddingLeft: "8px"
|
||||
paddingRight: "8px"
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -39,8 +50,11 @@ class BaseTable extends React.Component {
|
|||
super(props);
|
||||
this.state = {
|
||||
order: this.props.defaultOrder || "asc",
|
||||
orderBy: this.props.defaultOrderBy
|
||||
orderBy: this.props.defaultOrderBy,
|
||||
filterBy: ""
|
||||
};
|
||||
this.handleFilterInputChange = this.handleFilterInputChange.bind(this);
|
||||
this.handleFilterToggle = this.handleFilterToggle.bind(this);
|
||||
}
|
||||
|
||||
createSortHandler = col => () => {
|
||||
|
@ -54,14 +68,37 @@ class BaseTable extends React.Component {
|
|||
this.setState({ order, orderBy });
|
||||
};
|
||||
|
||||
sortRows = (tableRows, tableColumns, order, orderBy) => {
|
||||
if (!orderBy) {
|
||||
return tableRows;
|
||||
handleFilterInputChange = e => {
|
||||
let input = e.target.value.replace(/[^A-Z0-9/.\-_]/gi, "").toLowerCase();
|
||||
let swapWildCard = /[*]/g; // replace "*" in input with wildcard
|
||||
let filterBy = new RegExp(input.replace(swapWildCard, ".+"), "i");
|
||||
if (filterBy !== this.state.filterBy) {
|
||||
this.setState({ filterBy });
|
||||
}
|
||||
}
|
||||
|
||||
handleFilterToggle = () => {
|
||||
let newFilterStatus = !this.state.showFilter;
|
||||
this.setState({ showFilter: newFilterStatus, filterBy: "" });
|
||||
}
|
||||
|
||||
generateRows = (tableRows, tableColumns, order, orderBy, filterBy) => {
|
||||
let rows = tableRows;
|
||||
let col = _find(tableColumns, d => d.dataIndex === orderBy);
|
||||
let sorted = tableRows.sort(col.sorter);
|
||||
return order === 'desc' ? sorted.reverse() : sorted;
|
||||
if (orderBy) {
|
||||
rows = tableRows.sort(col.sorter);
|
||||
}
|
||||
if (filterBy) {
|
||||
let columnsToFilter = tableColumns.filter(col => col.filter);
|
||||
let filteredRows = tableRows.filter(row => {
|
||||
return columnsToFilter.some(col => {
|
||||
let rowText = col.filter(row);
|
||||
return rowText.match(filterBy);
|
||||
});
|
||||
});
|
||||
rows = filteredRows;
|
||||
}
|
||||
return order === 'desc' ? rows.reverse() : rows;
|
||||
}
|
||||
|
||||
renderHeaderCell = (col, order, orderBy) => {
|
||||
|
@ -100,13 +137,41 @@ class BaseTable extends React.Component {
|
|||
<Tooltip key={col.key || col.dataIndex} placement="top" title={col.tooltip}>{tableCell}</Tooltip>;
|
||||
}
|
||||
|
||||
renderToolbar = (classes, title) => {
|
||||
return (
|
||||
<Toolbar>
|
||||
<Typography
|
||||
className={classes.title}
|
||||
variant="h5">
|
||||
{title}
|
||||
</Typography>
|
||||
{this.state.showFilter &&
|
||||
<TextField
|
||||
id="input-with-icon-textfield"
|
||||
onChange={this.handleFilterInputChange}
|
||||
placeholder="Filter by text"
|
||||
autoFocus />}
|
||||
{!this.state.showFilter &&
|
||||
<FilterListIcon
|
||||
className={classes.toolbarIcon}
|
||||
onClick={this.handleFilterToggle} />}
|
||||
{this.state.showFilter &&
|
||||
<CloseIcon
|
||||
className={classes.toolbarIcon}
|
||||
onClick={this.handleFilterToggle} />}
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes, tableRows, tableColumns, tableClassName, rowKey, padding} = this.props;
|
||||
const {order, orderBy} = this.state;
|
||||
const sortedTableRows = this.sortRows(tableRows, tableColumns, order, orderBy);
|
||||
const { classes, enableFilter, tableRows, tableColumns, tableClassName, title, rowKey, padding} = this.props;
|
||||
const {order, orderBy, filterBy} = this.state;
|
||||
const sortedTableRows = tableRows.length > 0 ? this.generateRows(tableRows, tableColumns, order, orderBy, filterBy) : tableRows;
|
||||
|
||||
return (
|
||||
<Paper className={classes.root}>
|
||||
{enableFilter &&
|
||||
this.renderToolbar(classes, title)}
|
||||
<Table className={`${classes.table} ${tableClassName}`} padding={padding}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
|
@ -147,6 +212,7 @@ BaseTable.propTypes = {
|
|||
classes: PropTypes.shape({}).isRequired,
|
||||
defaultOrder: PropTypes.string,
|
||||
defaultOrderBy: PropTypes.string,
|
||||
enableFilter: PropTypes.bool,
|
||||
padding: PropTypes.string,
|
||||
rowKey: PropTypes.func,
|
||||
tableClassName: PropTypes.string,
|
||||
|
@ -158,16 +224,19 @@ BaseTable.propTypes = {
|
|||
sorter: PropTypes.func,
|
||||
title: PropTypes.string
|
||||
})).isRequired,
|
||||
tableRows: PropTypes.arrayOf(PropTypes.shape({}))
|
||||
tableRows: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
title: PropTypes.string
|
||||
};
|
||||
|
||||
BaseTable.defaultProps = {
|
||||
defaultOrder: "asc",
|
||||
defaultOrderBy: null,
|
||||
enableFilter: false,
|
||||
padding: "default",
|
||||
rowKey: null,
|
||||
tableClassName: "",
|
||||
tableRows: [],
|
||||
title: ""
|
||||
};
|
||||
|
||||
export default withStyles(styles)(BaseTable);
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import _merge from 'lodash/merge';
|
||||
import ApiHelpers from './util/ApiHelpers.jsx';
|
||||
import BaseTable from './BaseTable.jsx';
|
||||
import { routerWrap } from '../../test/testHelpers.jsx';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
describe("Tests for <BaseTable>", () => {
|
||||
const defaultProps = {
|
||||
api: ApiHelpers(""),
|
||||
};
|
||||
const tableColumns = [{
|
||||
dataIndex: "pods.totalPods",
|
||||
title: "Meshed"
|
||||
},
|
||||
{
|
||||
dataIndex: "deployment",
|
||||
title: "Name"
|
||||
},
|
||||
{
|
||||
dataIndex: "namespace",
|
||||
title: "Namespace"
|
||||
}];
|
||||
|
||||
it("renders the table with sample data", () => {
|
||||
|
||||
let extraProps = _merge({}, defaultProps, {
|
||||
tableRows: [{
|
||||
deployment: "authors",
|
||||
namespace: "default",
|
||||
key: "default-deployment-authors",
|
||||
pods: {totalPods: "1", meshedPods: "1"}
|
||||
}],
|
||||
tableColumns: tableColumns,
|
||||
});
|
||||
|
||||
const component = mount(routerWrap(BaseTable, extraProps));
|
||||
const table = component.find("BaseTable");
|
||||
expect(table).toBeDefined();
|
||||
expect(table.props().tableRows).toHaveLength(1);
|
||||
expect(table.props().tableColumns).toHaveLength(3);
|
||||
expect(table.find("td")).toHaveLength(3);
|
||||
expect(table.find("tr")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("if enableFilter is true, user is shown the filter dialog", () => {
|
||||
|
||||
let extraProps = _merge({}, defaultProps, {
|
||||
tableRows: [{
|
||||
deployment: "authors",
|
||||
namespace: "default",
|
||||
key: "default-deployment-authors",
|
||||
pods: {totalPods: "1", meshedPods: "1"}
|
||||
},
|
||||
{
|
||||
deployment: "books",
|
||||
namespace: "default",
|
||||
key: "default-deployment-books",
|
||||
pods: {totalPods: "2", meshedPods: "1"}
|
||||
}],
|
||||
tableColumns: tableColumns,
|
||||
enableFilter: true
|
||||
});
|
||||
|
||||
const component = mount(routerWrap(BaseTable, extraProps));
|
||||
const table = component.find("BaseTable");
|
||||
const enableFilter = table.prop("enableFilter");
|
||||
const filterIcon = table.find("FilterListIcon");
|
||||
expect(enableFilter).toEqual(true);
|
||||
expect(filterIcon).toBeDefined();
|
||||
});
|
||||
});
|
|
@ -94,6 +94,7 @@ const columnDefinitions = (resource, showNamespaceColumn, PrefixedLink, isTcpTab
|
|||
{
|
||||
title: "Namespace",
|
||||
dataIndex: "namespace",
|
||||
filter: d => d.namespace,
|
||||
isNumeric: false,
|
||||
render: d => !d.namespace ? "---" : <PrefixedLink to={"/namespaces/" + d.namespace}>{d.namespace}</PrefixedLink>,
|
||||
sorter: (a, b) => (a.namespace || "").localeCompare(b.namespace)
|
||||
|
@ -131,6 +132,7 @@ const columnDefinitions = (resource, showNamespaceColumn, PrefixedLink, isTcpTab
|
|||
title: isMultiResourceTable ? "Resource" : friendlyTitle(resource).singular,
|
||||
dataIndex: "name",
|
||||
isNumeric: false,
|
||||
filter: d => d.name,
|
||||
render: d => {
|
||||
let nameContents;
|
||||
if (resource === "namespace") {
|
||||
|
@ -197,16 +199,18 @@ class MetricsTable extends React.Component {
|
|||
metrics: PropTypes.arrayOf(processedMetricsPropType),
|
||||
resource: PropTypes.string.isRequired,
|
||||
showNamespaceColumn: PropTypes.bool,
|
||||
title: PropTypes.string
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
showNamespaceColumn: true,
|
||||
title: "",
|
||||
isTcpTable: false,
|
||||
metrics: []
|
||||
};
|
||||
|
||||
render() {
|
||||
const { metrics, resource, showNamespaceColumn, api, isTcpTable } = this.props;
|
||||
const { metrics, resource, showNamespaceColumn, title, api, isTcpTable } = this.props;
|
||||
|
||||
let showNsColumn = resource === "namespace" ? false : showNamespaceColumn;
|
||||
|
||||
|
@ -214,9 +218,11 @@ class MetricsTable extends React.Component {
|
|||
let rows = preprocessMetrics(metrics);
|
||||
return (
|
||||
<BaseTable
|
||||
enableFilter={true}
|
||||
tableRows={rows}
|
||||
tableColumns={columns}
|
||||
tableClassName="metric-table"
|
||||
title={title}
|
||||
defaultOrderBy="name"
|
||||
padding="dense" />
|
||||
);
|
||||
|
|
|
@ -28,6 +28,43 @@ describe('Tests for <MetricsTable>', () => {
|
|||
expect(table.props().tableColumns).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('if enableFilter is true, user can filter rows by search term', () => {
|
||||
let extraProps = _merge({}, defaultProps, {
|
||||
metrics: [{
|
||||
name: 'authors',
|
||||
namespace: 'default',
|
||||
key: 'authors-default-deploy',
|
||||
totalRequests: 0,
|
||||
}, {
|
||||
name: 'books',
|
||||
namespace: 'default',
|
||||
key: 'books-default-deploy',
|
||||
totalRequests: 0,
|
||||
}],
|
||||
resource: 'deployment',
|
||||
enableFilter: true
|
||||
});
|
||||
const component = mount(routerWrap(MetricsTable, extraProps));
|
||||
|
||||
const table = component.find('BaseTable');
|
||||
|
||||
const enableFilter = table.prop('enableFilter');
|
||||
|
||||
const filterIcon = table.find("FilterListIcon");
|
||||
|
||||
expect(enableFilter).toEqual(true);
|
||||
expect(filterIcon).toBeDefined();
|
||||
expect(table.html()).toContain('books');
|
||||
expect(table.html()).toContain('authors');
|
||||
filterIcon.simulate("click");
|
||||
setTimeout(() => {
|
||||
const input = table.find("input");
|
||||
input.simulate("change", {target: {value: "authors"}});
|
||||
expect(table.html()).not.toContain('books');
|
||||
expect(table.html()).toContain('authors');
|
||||
}, 100);
|
||||
});
|
||||
|
||||
it('omits the namespace column for the namespace resource', () => {
|
||||
let extraProps = _merge({}, defaultProps, { metrics: [], resource: "namespace"});
|
||||
const component = mount(routerWrap(MetricsTable, extraProps));
|
||||
|
|
|
@ -126,12 +126,9 @@ class NamespaceLanding extends React.Component {
|
|||
}
|
||||
return (
|
||||
<Grid container direction="column" justify="center">
|
||||
<Grid item>
|
||||
<Typography variant="h5">{friendlyTitle(resource).plural}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<MetricsTable
|
||||
title={friendlyTitle(resource).plural}
|
||||
resource={resource}
|
||||
metrics={metrics}
|
||||
showNamespaceColumn={false} />
|
||||
|
|
|
@ -303,9 +303,9 @@ export class ResourceDetailBase extends React.Component {
|
|||
|
||||
{ _isEmpty(upstreams) ? null : (
|
||||
<React.Fragment>
|
||||
<Typography variant="h5">Inbound</Typography>
|
||||
<MetricsTable
|
||||
resource="multi_resource"
|
||||
title="Inbound"
|
||||
metrics={upstreamMetrics} />
|
||||
</React.Fragment>
|
||||
)
|
||||
|
@ -313,9 +313,9 @@ export class ResourceDetailBase extends React.Component {
|
|||
|
||||
{ _isEmpty(this.state.downstreamMetrics) ? null : (
|
||||
<React.Fragment>
|
||||
<Typography variant="h5">Outbound</Typography>
|
||||
<MetricsTable
|
||||
resource="multi_resource"
|
||||
title="Outbound"
|
||||
metrics={downstreamMetrics} />
|
||||
</React.Fragment>
|
||||
)
|
||||
|
@ -324,18 +324,18 @@ export class ResourceDetailBase extends React.Component {
|
|||
{
|
||||
this.state.resource.type === "pod" ? null : (
|
||||
<React.Fragment>
|
||||
<Typography variant="h5">Pods</Typography>
|
||||
<MetricsTable
|
||||
resource="pod"
|
||||
title="Pods"
|
||||
metrics={this.state.podMetrics} />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
<React.Fragment>
|
||||
<Typography variant="h5">TCP</Typography>
|
||||
<MetricsTable
|
||||
resource="pod"
|
||||
title="TCP"
|
||||
isTcpTable={true}
|
||||
metrics={this.state.podMetrics} />
|
||||
</React.Fragment>
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { directionColumn, srcDstColumn, tapLink } from './util/TapUtils.jsx';
|
||||
import { formatLatencySec, numericSort } from './util/Utils.js';
|
||||
import { directionColumn, extractDisplayName, srcDstColumn, tapLink } from './util/TapUtils.jsx';
|
||||
import { formatLatencySec, numericSort, toShortResourceName } from './util/Utils.js';
|
||||
|
||||
import BaseTable from './BaseTable.jsx';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SuccessRateMiniChart from './util/SuccessRateMiniChart.jsx';
|
||||
import _isEmpty from 'lodash/isEmpty';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import { withContext } from './util/AppContext.jsx';
|
||||
|
||||
|
@ -16,17 +17,25 @@ const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
|
|||
},
|
||||
{
|
||||
title: "Name",
|
||||
filter: d => {
|
||||
let [labels, display] = extractDisplayName(d);
|
||||
return _isEmpty(labels[resourceType]) ?
|
||||
display.str :
|
||||
toShortResourceName(resourceType) + "/" + labels[resourceType];
|
||||
},
|
||||
key: "src-dst",
|
||||
render: d => srcDstColumn(d, resourceType, ResourceLink)
|
||||
},
|
||||
{
|
||||
title: "Method",
|
||||
dataIndex: "httpMethod",
|
||||
filter: d => d.httpMethod,
|
||||
sorter: (a, b) => a.httpMethod.localeCompare(b.httpMethod)
|
||||
},
|
||||
{
|
||||
title: "Path",
|
||||
dataIndex: "path",
|
||||
filter: d => d.path,
|
||||
sorter: (a, b) => a.path.localeCompare(b.path)
|
||||
},
|
||||
{
|
||||
|
@ -91,6 +100,7 @@ class TopEventTable extends React.Component {
|
|||
let columns = topColumns(resourceType, api.ResourceLink, api.PrefixedLink);
|
||||
return (
|
||||
<BaseTable
|
||||
enableFilter={true}
|
||||
tableRows={tableRows}
|
||||
tableColumns={columns}
|
||||
tableClassName="metric-table"
|
||||
|
|
|
@ -9,6 +9,7 @@ const routesColumns = [
|
|||
{
|
||||
title: "Route",
|
||||
dataIndex: "route",
|
||||
filter: d => d.route,
|
||||
sorter: (a, b) => {
|
||||
if (a.route === DefaultRoute) {
|
||||
return 1;
|
||||
|
@ -24,6 +25,7 @@ const routesColumns = [
|
|||
title: "Service",
|
||||
tooltip: "hostname:port used when communicating with this target",
|
||||
dataIndex: "authority",
|
||||
filter: d => d.authority,
|
||||
sorter: (a, b) => (a.authority).localeCompare(b.authority)
|
||||
},
|
||||
{
|
||||
|
@ -76,6 +78,7 @@ export default class TopRoutesTable extends React.Component {
|
|||
const { rows } = this.props;
|
||||
return (
|
||||
<BaseTable
|
||||
enableFilter={true}
|
||||
tableRows={rows}
|
||||
tableColumns={routesColumns}
|
||||
tableClassName="metric-table"
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import _merge from 'lodash/merge';
|
||||
import ApiHelpers from './util/ApiHelpers.jsx';
|
||||
import TopRoutesTable from './TopRoutesTable.jsx';
|
||||
import { routerWrap } from '../../test/testHelpers.jsx';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
describe("Tests for <TopRoutesTable>", () => {
|
||||
const defaultProps = {
|
||||
api: ApiHelpers(""),
|
||||
};
|
||||
|
||||
it("renders the table with all columns", () => {
|
||||
let extraProps = _merge({}, defaultProps, {
|
||||
rows: [{
|
||||
route: "[DEFAULT]",
|
||||
latency: {
|
||||
P50: 133,
|
||||
P95: 291,
|
||||
P99: 188
|
||||
},
|
||||
authority: "webapp",
|
||||
name: "authors:7001"
|
||||
}],
|
||||
});
|
||||
const component = mount(routerWrap(TopRoutesTable, extraProps));
|
||||
|
||||
const table = component.find("BaseTable");
|
||||
expect(table).toBeDefined();
|
||||
expect(table.html()).toContain("[DEFAULT]");
|
||||
expect(table.props().tableRows).toHaveLength(1);
|
||||
expect(table.props().tableColumns).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("if enableFilter is true, user can filter rows by search term", () => {
|
||||
let extraProps = _merge({}, defaultProps, {
|
||||
rows: [{
|
||||
route: "[DEFAULT]",
|
||||
latency: {
|
||||
P50: 133,
|
||||
P95: 291,
|
||||
P99: 188
|
||||
},
|
||||
authority: "authors:7001"
|
||||
},{
|
||||
route: "[DEFAULT]",
|
||||
latency: {
|
||||
P50: 500,
|
||||
P95: 1,
|
||||
P99: 2
|
||||
},
|
||||
authority: "localhost:6443"
|
||||
}],
|
||||
enableFilter: true
|
||||
});
|
||||
const component = mount(routerWrap(TopRoutesTable, extraProps));
|
||||
|
||||
const table = component.find("BaseTable");
|
||||
|
||||
const enableFilter = table.prop("enableFilter");
|
||||
|
||||
const filterIcon = table.find("FilterListIcon");
|
||||
|
||||
expect(enableFilter).toEqual(true);
|
||||
expect(filterIcon).toBeDefined();
|
||||
expect(table.html()).toContain("authors:7001");
|
||||
expect(table.html()).toContain("localhost");
|
||||
filterIcon.simulate("click");
|
||||
setTimeout(() => {
|
||||
const input = table.find("input");
|
||||
input.simulate("change", {target: {value: "localhost"}});
|
||||
expect(table.html()).not.toContain("authors:7001");
|
||||
expect(table.html()).toContain("localhost");
|
||||
}, 100);
|
||||
});
|
||||
});
|
|
@ -312,8 +312,7 @@ export const directionColumn = d => (
|
|||
</Tooltip>
|
||||
);
|
||||
|
||||
export const srcDstColumn = (d, resourceType, ResourceLink) => {
|
||||
|
||||
export const extractDisplayName = d => {
|
||||
let display = {};
|
||||
let labels = {};
|
||||
|
||||
|
@ -324,6 +323,11 @@ export const srcDstColumn = (d, resourceType, ResourceLink) => {
|
|||
display = d.destination;
|
||||
labels = d.destinationLabels;
|
||||
}
|
||||
return [labels, display];
|
||||
};
|
||||
|
||||
export const srcDstColumn = (d, resourceType, ResourceLink) => {
|
||||
let [labels, display] = extractDisplayName(d);
|
||||
|
||||
let link = (
|
||||
!_isEmpty(labels[resourceType]) ?
|
||||
|
|
Loading…
Reference in New Issue