Add top request table to resource detail page (#1507)

Includes a substantial refactor of Top.jsx to move the websocket
and top-request-aggregation code into a self-contained module
so that this code can be shared by /top and by each resource
detail page.

(This refactor also helps separate concerns in that
page; since that page also makes 10 second requests to the stat
api to populate the autocompletes in the form).

The TopModule uses the startTap prop to figure out whether it
should start a websocket connection and make a tap request
when mounted. (This is because the resource detail pages
start tapping immediately upon load, whereas /top can only
start once you've entered a query.

I've removed the spinner and the awaitingWebSocketConnection
state field because that now belongs in the top module. I think a
similar refactor of tap would be good before we re-add it.
This commit is contained in:
Risha Mars 2018-08-22 18:18:35 -07:00 committed by GitHub
parent 211fca1806
commit 3fde755a8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 305 additions and 246 deletions

View File

@ -7,6 +7,7 @@ import { processSingleResourceRollup } from './util/MetricUtils.js';
import PropTypes from 'prop-types';
import React from 'react';
import { Spin } from 'antd';
import TopModule from './TopModule.jsx';
import { withContext } from './util/AppContext.jsx';
import { resourceTypeToCamelCase, singularResource } from './util/Utils.js';
import 'whatwg-fetch';
@ -170,6 +171,12 @@ export class ResourceDetailBase extends React.Component {
return <Spin size="large" />;
}
let topQuery = {
resource: this.state.resourceType + "/" + this.state.resourceName,
namespace: this.state.namespace,
maxRps: "1.0"
};
return (
<div>
<div className="page-section">
@ -179,6 +186,14 @@ export class ResourceDetailBase extends React.Component {
api={this.api} />
</div>
<div className="page-section">
<TopModule
pathPrefix={this.props.pathPrefix}
query={topQuery}
startTap={true}
maxRowsToDisplay={10} />
</div>
{ _.isEmpty(this.state.neighborMetrics.upstream) ? null : (
<div className="page-section">
<h2 className="subsection-header">Upstreams</h2>

View File

@ -42,7 +42,6 @@ class Tap extends React.Component {
maxRps: defaultMaxRps
},
maxLinesToDisplay: 40,
awaitingWebSocketConnection: false,
tapRequestInProgress: false,
pollingInterval: 10000,
pendingRequests: false
@ -70,7 +69,6 @@ class Tap extends React.Component {
...query
}));
this.setState({
awaitingWebSocketConnection: false,
error: null
});
}
@ -250,9 +248,8 @@ class Tap extends React.Component {
this.api.cancelCurrentRequests();
}
startTapSteaming() {
startTapStreaming() {
this.setState({
awaitingWebSocketConnection: true,
tapRequestInProgress: true,
tapResultsById: {},
tapResultFilterOptions: this.getInitialTapFilterOptions()
@ -270,14 +267,13 @@ class Tap extends React.Component {
stopTapStreaming() {
this.setState({
tapRequestInProgress: false,
awaitingWebSocketConnection: false
tapRequestInProgress: false
});
}
handleTapStart = e => {
e.preventDefault();
this.startTapSteaming();
this.startTapStreaming();
}
handleTapStop = () => {
@ -336,7 +332,6 @@ class Tap extends React.Component {
<PageHeader header="Tap" />
<TapQueryForm
tapRequestInProgress={this.state.tapRequestInProgress}
awaitingWebSocketConnection={this.state.awaitingWebSocketConnection}
handleTapStart={this.handleTapStart}
handleTapStop={this.handleTapStop}
resourcesByNs={this.state.resourcesByNs}

View File

@ -21,7 +21,6 @@ const getResourceList = (resourcesByNs, ns) => {
};
export default class TapQueryForm extends React.Component {
static propTypes = {
awaitingWebSocketConnection: PropTypes.bool.isRequired,
enableAdvancedForm: PropTypes.bool,
handleTapStart: PropTypes.func.isRequired,
handleTapStop: PropTypes.func.isRequired,
@ -249,14 +248,10 @@ export default class TapQueryForm extends React.Component {
<Col span={colSpan}>
<Form.Item>
{
this.props.tapRequestInProgress ?
<Button type="primary" className="tap-stop" onClick={this.props.handleTapStop}>Stop</Button> :
<Button type="primary" className="tap-start" onClick={this.props.handleTapStart}>Start</Button>
}
{
this.props.awaitingWebSocketConnection ?
<Icon type="loading" style={{ paddingLeft: rowGutter, fontSize: 20, color: '#08c' }} /> : null
}
this.props.tapRequestInProgress ?
<Button type="primary" className="tap-stop" onClick={this.props.handleTapStop}>Stop</Button> :
<Button type="primary" className="tap-start" onClick={this.props.handleTapStart}>Start</Button>
}
</Form.Item>
</Col>
</Row>
@ -267,8 +262,9 @@ export default class TapQueryForm extends React.Component {
<Button
className="tap-form-toggle"
onClick={() => this.toggleAdvancedForm(!this.state.showAdvancedForm)}>
{ this.state.showAdvancedForm ?
"Hide filters" : "Show more request filters" } <Icon type={this.state.showAdvancedForm ? 'up' : 'down'} />
{
this.state.showAdvancedForm ? "Hide filters" : "Show more request filters"
} <Icon type={this.state.showAdvancedForm ? 'up' : 'down'} />
</Button>
{ !this.state.showAdvancedForm ? null : this.renderAdvancedTapForm() }

View File

@ -1,14 +1,13 @@
import _ from 'lodash';
import { defaultMaxRps } from './util/TapUtils.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import PageHeader from './PageHeader.jsx';
import Percentage from './util/Percentage.js';
import PropTypes from 'prop-types';
import React from 'react';
import TapQueryCliCmd from './TapQueryCliCmd.jsx';
import TapQueryForm from './TapQueryForm.jsx';
import TopEventTable from './TopEventTable.jsx';
import TopModule from './TopModule.jsx';
import { withContext } from './util/AppContext.jsx';
import { defaultMaxRps, processTapEvent } from './util/TapUtils.jsx';
import './../../css/tap.css';
class Top extends React.Component {
@ -25,8 +24,6 @@ class Top extends React.Component {
this.loadFromServer = this.loadFromServer.bind(this);
this.state = {
tapResultsById: {},
topEventIndex: {},
error: null,
resourcesByNs: {},
authoritiesByNs: {},
@ -41,10 +38,8 @@ class Top extends React.Component {
authority: "",
maxRps: defaultMaxRps
},
maxRowsToStore: 40,
awaitingWebSocketConnection: false,
tapRequestInProgress: false,
pollingInterval: 10000,
tapRequestInProgress: false,
pendingRequests: false
};
}
@ -54,51 +49,9 @@ class Top extends React.Component {
}
componentWillUnmount() {
if (this.ws) {
this.ws.close(1000);
}
this.stopTapStreaming();
this.stopServerPolling();
}
onWebsocketOpen = () => {
let query = _.cloneDeep(this.state.query);
query.maxRps = parseFloat(query.maxRps);
this.ws.send(JSON.stringify({
id: "top-web",
...query
}));
this.setState({
awaitingWebSocketConnection: false,
error: null
});
}
onWebsocketRecv = e => {
this.indexTapResult(e.data);
}
onWebsocketClose = e => {
this.stopTapStreaming();
if (!e.wasClean) {
this.setState({
error: {
error: `Websocket [${e.code}] ${e.reason}`
}
});
}
}
onWebsocketError = e => {
this.setState({
error: { error: e.message }
});
this.stopTapStreaming();
}
getResourcesByNs(rsp) {
let statTables = _.get(rsp, [0, "ok", "statTables"]);
let authoritiesByNs = {};
@ -127,142 +80,6 @@ class Top extends React.Component {
};
}
parseTapResult = data => {
let d = processTapEvent(data);
if (d.eventType === "responseEnd") {
d.latency = parseFloat(d.http.responseEnd.sinceRequestInit.replace("s", ""));
d.completed = true;
}
return d;
}
topEventKey = event => {
return [event.source.str, event.destination.str, event.http.requestInit.path].join("_");
}
initialTopResult(d, eventKey) {
return {
count: 1,
best: d.responseEnd.latency,
worst: d.responseEnd.latency,
last: d.responseEnd.latency,
success: !d.success ? 0 : 1,
failure: !d.success ? 1 : 0,
successRate: !d.success ? new Percentage(0, 1) : new Percentage(1, 1),
source: d.requestInit.source,
sourceLabels: d.requestInit.sourceMeta.labels,
destination: d.requestInit.destination,
destinationLabels: d.requestInit.destinationMeta.labels,
path: d.requestInit.http.requestInit.path,
key: eventKey,
lastUpdated: Date.now()
};
}
incrementTopResult(d, result) {
result.count++;
if (!d.success) {
result.failure++;
} else {
result.success++;
}
result.successRate = new Percentage(result.success, result.success + result.failure);
result.last = d.responseEnd.latency;
if (d.responseEnd.latency < result.best) {
result.best = d.responseEnd.latency;
}
if (d.responseEnd.latency > result.worst) {
result.worst = d.responseEnd.latency;
}
result.lastUpdated = Date.now();
}
indexTopResult = (d, topResults) => {
let eventKey = this.topEventKey(d.requestInit);
this.addSuccessCount(d);
if (!topResults[eventKey]) {
topResults[eventKey] = this.initialTopResult(d, eventKey);
} else {
this.incrementTopResult(d, topResults[eventKey]);
}
if (_.size(topResults) > this.state.maxRowsToStore) {
this.deleteOldestIndexedResult(topResults);
}
return topResults;
}
indexTapResult = data => {
// keep an index of tap results by id until the request is complete.
// when the request has completed, add it to the aggregated Top counts and
// discard the individual tap result
let resultIndex = this.state.tapResultsById;
let d = this.parseTapResult(data);
if (_.isNil(resultIndex[d.id])) {
// don't let tapResultsById grow unbounded
if (_.size(resultIndex) > this.state.maxRowsToStore) {
this.deleteOldestIndexedResult(resultIndex);
}
resultIndex[d.id] = {};
}
resultIndex[d.id][d.eventType] = d;
// assumption: requests of a given id all share the same high level metadata
resultIndex[d.id].base = d;
resultIndex[d.id].lastUpdated = Date.now();
let topIndex = this.state.topEventIndex;
if (d.completed) {
// only add results into top if the request has completed
// we can also now delete this result from the Tap result index
topIndex = this.indexTopResult(resultIndex[d.id], topIndex);
delete resultIndex[d.id];
}
this.setState({
tapResultsById: resultIndex,
topEventIndex: topIndex
});
}
addSuccessCount = d => {
// cope with the fact that gRPC failures are returned with HTTP status 200
// and correctly classify gRPC failures as failures
let success = parseInt(d.responseInit.http.responseInit.httpStatus, 10) < 500;
if (success) {
let grpcStatusCode = _.get(d, "responseEnd.http.responseEnd.eos.grpcStatusCode");
if (!_.isNil(grpcStatusCode)) {
success = grpcStatusCode === 0;
} else if (!_.isNil(_.get(d, "responseEnd.http.responseEnd.eos.resetErrorCode"))) {
success = false;
}
}
d.success = success;
}
deleteOldestIndexedResult = resultIndex => {
let oldest = Date.now();
let oldestId = "";
_.each(resultIndex, (res, id) => {
if (res.lastUpdated < oldest) {
oldest = res.lastUpdated;
oldestId = id;
}
});
delete resultIndex[oldestId];
}
startServerPolling() {
this.loadFromServer();
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
@ -273,40 +90,6 @@ class Top extends React.Component {
this.api.cancelCurrentRequests();
}
startTapSteaming() {
this.setState({
awaitingWebSocketConnection: true,
tapRequestInProgress: true,
tapResultsById: {},
topEventIndex: {}
});
let protocol = window.location.protocol === "https:" ? "wss" : "ws";
let tapWebSocket = `${protocol}://${window.location.host}${this.props.pathPrefix}/api/tap`;
this.ws = new WebSocket(tapWebSocket);
this.ws.onmessage = this.onWebsocketRecv;
this.ws.onclose = this.onWebsocketClose;
this.ws.onopen = this.onWebsocketOpen;
this.ws.onerror = this.onWebsocketError;
}
stopTapStreaming() {
this.setState({
tapRequestInProgress: false,
awaitingWebSocketConnection: false
});
}
handleTapStart = e => {
e.preventDefault();
this.startTapSteaming();
}
handleTapStop = () => {
this.ws.close(1000);
}
loadFromServer() {
if (this.state.pendingRequests) {
return; // don't make more requests if the ones we sent haven't completed
@ -347,29 +130,39 @@ class Top extends React.Component {
});
}
render() {
let tableRows = _.values(this.state.topEventIndex);
handleTapStart = () => {
this.setState({
tapRequestInProgress: true
});
}
handleTapStop = () => {
this.setState({
tapRequestInProgress: false
});
}
render() {
return (
<div>
{!this.state.error ? null :
<ErrorBanner message={this.state.error} onHideMessage={() => this.setState({ error: null })} />}
<PageHeader header="Top" />
<TapQueryForm
enableAdvancedForm={false}
tapRequestInProgress={this.state.tapRequestInProgress}
awaitingWebSocketConnection={this.state.awaitingWebSocketConnection}
handleTapStart={this.handleTapStart}
handleTapStop={this.handleTapStop}
resourcesByNs={this.state.resourcesByNs}
authoritiesByNs={this.state.authoritiesByNs}
tapRequestInProgress={this.state.tapRequestInProgress}
updateQuery={this.updateQuery}
query={this.state.query} />
<TapQueryCliCmd cmdName="top" query={this.state.query} />
<TopEventTable tableRows={tableRows} />
<TopModule
pathPrefix={this.props.pathPrefix}
query={this.state.query}
startTap={this.state.tapRequestInProgress} />
</div>
);
}

View File

@ -0,0 +1,260 @@
import _ from 'lodash';
import ErrorBanner from './ErrorBanner.jsx';
import Percentage from './util/Percentage.js';
import { processTapEvent } from './util/TapUtils.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import TopEventTable from './TopEventTable.jsx';
import { withContext } from './util/AppContext.jsx';
class TopModule extends React.Component {
static propTypes = {
maxRowsToDisplay: PropTypes.number,
pathPrefix: PropTypes.string.isRequired,
query: PropTypes.shape({}).isRequired,
startTap: PropTypes.bool.isRequired
}
static defaultProps = {
maxRowsToDisplay: 40 // max aggregated top rows to index and display in table
}
constructor(props) {
super(props);
this.state = {
error: null,
tapResultsById: {},
topEventIndex: {},
maxRowsToStore: 40 // max un-ended tap results to keep in index (pre-aggregation into the top counts)
};
}
componentDidMount() {
if (this.props.startTap) {
this.startTapStreaming();
}
}
componentDidUpdate(prevProps) {
if (this.props.startTap && !prevProps.startTap) {
this.startTapStreaming();
}
if (!this.props.startTap && prevProps.startTap) {
this.stopTapStreaming();
}
}
componentWillUnmount() {
this.stopTapStreaming();
}
onWebsocketOpen = () => {
let query = _.cloneDeep(this.props.query);
query.maxRps = parseFloat(query.maxRps);
this.ws.send(JSON.stringify({
id: "top-web",
...query
}));
this.setState({
error: null
});
}
onWebsocketRecv = e => {
this.indexTapResult(e.data);
}
onWebsocketClose = e => {
if (!e.wasClean) {
this.setState({
error: { error: `Websocket [${e.code}] ${e.reason}` }
});
}
}
onWebsocketError = e => {
this.setState({
error: { error: e.message }
});
}
closeWebSocket = () => {
if (this.ws) {
this.ws.close(1000);
}
}
parseTapResult = data => {
let d = processTapEvent(data);
if (d.eventType === "responseEnd") {
d.latency = parseFloat(d.http.responseEnd.sinceRequestInit.replace("s", ""));
d.completed = true;
}
return d;
}
topEventKey = event => {
return [event.source.str, event.destination.str, event.http.requestInit.path].join("_");
}
initialTopResult(d, eventKey) {
return {
count: 1,
best: d.responseEnd.latency,
worst: d.responseEnd.latency,
last: d.responseEnd.latency,
success: !d.success ? 0 : 1,
failure: !d.success ? 1 : 0,
successRate: !d.success ? new Percentage(0, 1) : new Percentage(1, 1),
source: d.requestInit.source,
sourceLabels: d.requestInit.sourceMeta.labels,
destination: d.requestInit.destination,
destinationLabels: d.requestInit.destinationMeta.labels,
path: d.requestInit.http.requestInit.path,
key: eventKey,
lastUpdated: Date.now()
};
}
incrementTopResult(d, result) {
result.count++;
if (!d.success) {
result.failure++;
} else {
result.success++;
}
result.successRate = new Percentage(result.success, result.success + result.failure);
result.last = d.responseEnd.latency;
if (d.responseEnd.latency < result.best) {
result.best = d.responseEnd.latency;
}
if (d.responseEnd.latency > result.worst) {
result.worst = d.responseEnd.latency;
}
result.lastUpdated = Date.now();
}
indexTopResult = (d, topResults) => {
let eventKey = this.topEventKey(d.requestInit);
this.addSuccessCount(d);
if (!topResults[eventKey]) {
topResults[eventKey] = this.initialTopResult(d, eventKey);
} else {
this.incrementTopResult(d, topResults[eventKey]);
}
if (_.size(topResults) > this.props.maxRowsToDisplay) {
this.deleteOldestIndexedResult(topResults);
}
return topResults;
}
indexTapResult = data => {
// keep an index of tap results by id until the request is complete.
// when the request has completed, add it to the aggregated Top counts and
// discard the individual tap result
let resultIndex = this.state.tapResultsById;
let d = this.parseTapResult(data);
if (_.isNil(resultIndex[d.id])) {
// don't let tapResultsById grow unbounded
if (_.size(resultIndex) > this.state.maxRowsToStore) {
this.deleteOldestIndexedResult(resultIndex);
}
resultIndex[d.id] = {};
}
resultIndex[d.id][d.eventType] = d;
// assumption: requests of a given id all share the same high level metadata
resultIndex[d.id].base = d;
resultIndex[d.id].lastUpdated = Date.now();
let topIndex = this.state.topEventIndex;
if (d.completed) {
// only add results into top if the request has completed
// we can also now delete this result from the Tap result index
topIndex = this.indexTopResult(resultIndex[d.id], topIndex);
delete resultIndex[d.id];
}
this.setState({
tapResultsById: resultIndex,
topEventIndex: topIndex
});
}
addSuccessCount = d => {
// cope with the fact that gRPC failures are returned with HTTP status 200
// and correctly classify gRPC failures as failures
let success = parseInt(d.responseInit.http.responseInit.httpStatus, 10) < 500;
if (success) {
let grpcStatusCode = _.get(d, "responseEnd.http.responseEnd.eos.grpcStatusCode");
if (!_.isNil(grpcStatusCode)) {
success = grpcStatusCode === 0;
} else if (!_.isNil(_.get(d, "responseEnd.http.responseEnd.eos.resetErrorCode"))) {
success = false;
}
}
d.success = success;
}
deleteOldestIndexedResult = resultIndex => {
let oldest = Date.now();
let oldestId = "";
_.each(resultIndex, (res, id) => {
if (res.lastUpdated < oldest) {
oldest = res.lastUpdated;
oldestId = id;
}
});
delete resultIndex[oldestId];
}
startTapStreaming() {
let protocol = window.location.protocol === "https:" ? "wss" : "ws";
let tapWebSocket = `${protocol}://${window.location.host}${this.props.pathPrefix}/api/tap`;
this.ws = new WebSocket(tapWebSocket);
this.ws.onmessage = this.onWebsocketRecv;
this.ws.onclose = this.onWebsocketClose;
this.ws.onopen = this.onWebsocketOpen;
this.ws.onerror = this.onWebsocketError;
}
stopTapStreaming() {
this.closeWebSocket();
}
banner = () => {
if (!this.state.error) {
return;
}
return <ErrorBanner message={this.state.error} />;
}
render() {
let tableRows = _.values(this.state.topEventIndex);
return (
<React.Fragment>
{this.banner()}
<TopEventTable tableRows={tableRows} />
</React.Fragment>
);
}
}
export default withContext(TopModule);