linkerd2/web/app/js/components/TopModule.jsx

394 lines
12 KiB
JavaScript

import { WS_ABNORMAL_CLOSURE, WS_NORMAL_CLOSURE, processNeighborData, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import Percentage from './util/Percentage.js';
import PropTypes from 'prop-types';
import React from 'react';
import TopEventTable from './TopEventTable.jsx';
import _cloneDeep from 'lodash/cloneDeep';
import _each from 'lodash/each';
import _get from 'lodash/get';
import _has from 'lodash/has';
import _includes from 'lodash/includes';
import _isEmpty from 'lodash/isEmpty';
import _isEqual from 'lodash/isEqual';
import _isNil from 'lodash/isNil';
import _noop from 'lodash/noop';
import _size from 'lodash/size';
import _take from 'lodash/take';
import _throttle from 'lodash/throttle';
import _values from 'lodash/values';
import { withContext } from './util/AppContext.jsx';
// https://github.com/grpc/grpc/blob/master/doc/statuscodes.md
const grpcErrorStatusCodes = [2, 4, 13, 14, 15];
class TopModule extends React.Component {
constructor(props) {
super(props);
this.tapResultsById = {};
this.topEventIndex = {};
this.throttledWebsocketRecvHandler = _throttle(this.updateTapEventIndexState, 500);
this.updateTapClosingState = props.updateTapClosingState;
this.unmeshedSources = {};
this.state = {
error: null,
topEventIndex: {},
};
}
componentDidMount() {
const { startTap } = this.props;
if (startTap) {
this.startTapStreaming();
}
}
componentDidUpdate(prevProps) {
const { startTap, query } = this.props;
if (startTap && !prevProps.startTap) {
this.startTapStreaming();
}
if (!startTap && prevProps.startTap) {
this.stopTapStreaming();
}
if (!_isEqual(query, prevProps.query)) {
this.clearTopTable();
}
}
componentWillUnmount() {
this.throttledWebsocketRecvHandler.cancel();
this.stopTapStreaming();
this.updateTapClosingState = _noop;
}
onWebsocketOpen = () => {
const { query } = this.props;
const tapQuery = _cloneDeep(query);
setMaxRps(tapQuery);
this.ws.send(JSON.stringify({
id: 'top-web',
...tapQuery,
}));
this.setState({
error: null,
});
}
onWebsocketRecv = e => {
this.indexTapResult(e.data);
this.throttledWebsocketRecvHandler();
}
onWebsocketClose = e => {
this.updateTapClosingState();
/* We ignore any abnormal closure since it doesn't matter as long as
the connection to the websocket is closed. This is also a workaround
where Chrome browsers incorrectly displays a 1006 close code
https://github.com/linkerd/linkerd2/issues/1630
*/
if (e.code !== WS_NORMAL_CLOSURE && e.code !== WS_ABNORMAL_CLOSURE) {
this.setState({
error: {
error: `Websocket close error [${e.code}: ${wsCloseCodes[e.code]}] ${e.reason ? ':' : ''} ${e.reason}`,
},
});
}
}
onWebsocketError = e => {
this.setState({
error: { error: `Websocket error: ${e.message}` },
});
}
closeWebSocket = () => {
if (this.ws) {
this.ws.close(1000);
}
}
parseTapResult = data => {
const d = processTapEvent(data);
if (d.eventType === 'responseEnd') {
d.latency = parseFloat(d.http.responseEnd.sinceRequestInit.replace('s', ''));
d.completed = true;
}
return d;
}
topEventKey = event => {
const sourceKey = event.source.owner || event.source.pod || event.source.str;
const dstKey = event.destination.owner || event.destination.pod || event.destination.str;
return [sourceKey, dstKey, _get(event, 'http.requestInit.method.registered'), event.http.requestInit.path].join('_');
}
initialTopResult = (d, eventKey) => {
// in the event that we key on resources with multiple pods/ips, store them so we can display
const sourceDisplay = {
ips: {},
pods: {},
};
sourceDisplay.ips[d.base.source.str] = true;
if (!_isNil(d.base.source.pod)) {
sourceDisplay.pods[d.base.source.pod] = d.base.source.namespace;
}
const destinationDisplay = {
ips: {},
pods: {},
};
destinationDisplay.ips[d.base.destination.str] = true;
if (!_isNil(d.base.destination.pod)) {
destinationDisplay.pods[d.base.destination.pod] = d.base.destination.namespace;
}
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,
meshed: true,
successRate: !d.success ? new Percentage(0, 1) : new Percentage(1, 1),
direction: d.base.proxyDirection,
source: d.requestInit.source,
sourceLabels: d.requestInit.sourceMeta.labels,
sourceDisplay,
destination: d.requestInit.destination,
destinationLabels: d.requestInit.destinationMeta.labels,
destinationDisplay,
httpMethod: _get(d, 'requestInit.http.requestInit.method.registered'),
path: d.requestInit.http.requestInit.path,
key: eventKey,
lastUpdated: Date.now(),
};
}
incrementTopResult = (d, result) => {
result.count += 1;
if (!d.success) {
result.failure += 1;
} else {
result.success += 1;
}
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.sourceDisplay.ips[d.base.source.str] = true;
if (!_isNil(d.requestInit.sourceMeta.labels.pod)) {
result.sourceDisplay.pods[d.requestInit.sourceMeta.labels.pod] = d.requestInit.sourceMeta.labels.namespace;
}
result.destinationDisplay.ips[d.base.destination.str] = true;
if (!_isNil(d.requestInit.destinationMeta.labels.pod)) {
result.destinationDisplay.pods[d.requestInit.destinationMeta.labels.pod] = d.requestInit.destinationMeta.labels.namespace;
}
result.lastUpdated = Date.now();
}
indexTopResult = (d, topResults) => {
const { query, maxRowsToStore } = this.props;
// only index if have the full request (i.e. init and end)
if (!d.requestInit) {
return topResults;
}
const 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) > maxRowsToStore) {
this.deleteOldestIndexedResult(topResults);
}
if (d.base.proxyDirection === 'INBOUND') {
this.updateNeighborsFromTapData(d.requestInit.source, _get(d, 'requestInit.sourceMeta.labels'));
const isPod = query.resource.split('/')[0] === 'pod';
if (isPod) {
topResults[eventKey].meshed = !_has(this.unmeshedSources, `pod/${d.base.source.pod}`);
} else {
topResults[eventKey].meshed = !_isEmpty(d.base.source.owner) && !_has(this.unmeshedSources, d.base.source.owner);
}
}
return topResults;
}
updateTapEventIndexState = () => {
// tap websocket events come in at a really high, bursty rate
// calling setState every time an event comes in causes a lot of re-rendering
// and causes the page to freeze. To fix this, limit the times we
// update the state (and thus trigger a render)
this.setState({
topEventIndex: this.topEventIndex,
});
}
updateNeighborsFromTapData = (source, sourceLabels) => {
const { query, updateUnmeshedSources } = this.props;
// store this outside of state, as updating the state upon every websocket event received
// is very costly and causes the page to freeze up
const resourceType = _isNil(query.resource) ? '' : query.resource.split('/')[0];
this.unmeshedSources = processNeighborData(source, sourceLabels, this.unmeshedSources, resourceType);
updateUnmeshedSources(this.unmeshedSources);
}
// 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
indexTapResult = data => {
const { maxRowsToStore } = this.props;
const resultIndex = this.tapResultsById;
const d = this.parseTapResult(data);
if (_isNil(resultIndex[d.id])) {
// don't let tapResultsById grow unbounded
if (_size(resultIndex) > 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();
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
this.topEventIndex = this.indexTopResult(resultIndex[d.id], this.topEventIndex);
delete resultIndex[d.id];
}
}
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(_get(d, 'responseInit.http.responseInit.httpStatus'), 10) < 500;
if (success) {
const grpcStatusCode = _get(d, 'responseEnd.http.responseEnd.eos.grpcStatusCode');
if (!_isNil(grpcStatusCode)) {
success = !_includes(grpcErrorStatusCodes, grpcStatusCode);
} 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];
}
clearTopTable() {
this.tapResultsById = {};
this.topEventIndex = {};
this.setState({
topEventIndex: {},
});
}
startTapStreaming() {
const { pathPrefix } = this.props;
this.clearTopTable();
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const tapWebSocket = `${protocol}://${window.location.host}${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 = () => {
const { error } = this.state;
return error ? <ErrorBanner message={error} /> : null;
}
render() {
const { topEventIndex } = this.state;
const { query, maxRowsToDisplay } = this.props;
const tableRows = _take(_values(topEventIndex), maxRowsToDisplay);
const resourceType = _isNil(query.resource) ? '' : query.resource.split('/')[0];
return (
<React.Fragment>
{this.banner()}
<TopEventTable resourceType={resourceType} tableRows={tableRows} />
</React.Fragment>
);
}
}
TopModule.propTypes = {
maxRowsToDisplay: PropTypes.number,
maxRowsToStore: PropTypes.number,
pathPrefix: PropTypes.string.isRequired,
query: PropTypes.shape({
resource: PropTypes.string,
}),
startTap: PropTypes.bool.isRequired,
updateTapClosingState: PropTypes.func,
updateUnmeshedSources: PropTypes.func,
};
TopModule.defaultProps = {
// max aggregated top rows to index and display in table
maxRowsToDisplay: 40,
// max rows to keep in index. there are two indexes we keep:
// - un-ended tap results, pre-aggregation into the top counts
// - aggregated top rows
maxRowsToStore: 50,
updateTapClosingState: _noop,
updateUnmeshedSources: _noop,
query: {
resource: '',
},
};
export default withContext(TopModule);