mirror of https://github.com/linkerd/linkerd2.git
606 lines
17 KiB
JavaScript
606 lines
17 KiB
JavaScript
import _ from 'lodash';
|
|
import ErrorBanner from './ErrorBanner.jsx';
|
|
import PageHeader from './PageHeader.jsx';
|
|
import PropTypes from 'prop-types';
|
|
import { publicAddressToString } from './util/Utils.js';
|
|
import React from 'react';
|
|
import TapEventTable from './TapEventTable.jsx';
|
|
import { withContext } from './util/AppContext.jsx';
|
|
import {
|
|
AutoComplete,
|
|
Button,
|
|
Col,
|
|
Form,
|
|
Icon,
|
|
Input,
|
|
Row,
|
|
Select
|
|
} from 'antd';
|
|
import './../../css/tap.css';
|
|
|
|
const colSpan = 5;
|
|
const rowGutter = 16;
|
|
const httpMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"];
|
|
const defaultMaxRps = 1.0;
|
|
const maxNumFilterOptions = 12;
|
|
class Tap extends React.Component {
|
|
static propTypes = {
|
|
api: PropTypes.shape({
|
|
PrefixedLink: PropTypes.func.isRequired,
|
|
}).isRequired,
|
|
pathPrefix: PropTypes.string.isRequired
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.api = this.props.api;
|
|
this.loadFromServer = this.loadFromServer.bind(this);
|
|
|
|
this.state = {
|
|
tapResultsById: {},
|
|
tapResultFilterOptions: this.getInitialTapFilterOptions(),
|
|
error: null,
|
|
resourcesByNs: {},
|
|
authoritiesByNs: {},
|
|
query: {
|
|
resource: "",
|
|
namespace: "",
|
|
toResource: "",
|
|
toNamespace: "",
|
|
method: "",
|
|
path: "",
|
|
scheme: "",
|
|
authority: "",
|
|
maxRps: defaultMaxRps
|
|
},
|
|
autocomplete: {
|
|
namespace: [],
|
|
resource: [],
|
|
toNamespace: [],
|
|
toResource: [],
|
|
authority: []
|
|
},
|
|
maxLinesToDisplay: 40,
|
|
awaitingWebSocketConnection: false,
|
|
tapRequestInProgress: false,
|
|
showAdvancedForm: false,
|
|
pollingInterval: 10000,
|
|
pendingRequests: false
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.startServerPolling();
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
if (this.ws) {
|
|
this.ws.close(1000);
|
|
}
|
|
this.stopTapStreaming();
|
|
this.stopServerPolling();
|
|
}
|
|
|
|
onWebsocketOpen = () => {
|
|
let query = this.state.query;
|
|
query.maxRps = parseFloat(query.maxRps) || defaultMaxRps;
|
|
|
|
this.ws.send(JSON.stringify({
|
|
id: "tap-web",
|
|
...query
|
|
}));
|
|
this.setState({
|
|
awaitingWebSocketConnection: false,
|
|
error: ""
|
|
});
|
|
}
|
|
|
|
onWebsocketRecv = e => {
|
|
this.indexTapResult(e.data);
|
|
}
|
|
|
|
onWebsocketClose = e => {
|
|
this.stopTapStreaming();
|
|
|
|
if (!e.wasClean) {
|
|
this.setState({
|
|
error: `Websocket [${e.code}] ${e.reason}`
|
|
});
|
|
}
|
|
}
|
|
|
|
onWebsocketError = e => {
|
|
this.setState({
|
|
error: e.message
|
|
});
|
|
|
|
this.stopTapStreaming();
|
|
}
|
|
|
|
getInitialTapFilterOptions() {
|
|
return {
|
|
source: {},
|
|
destination: {},
|
|
path: {},
|
|
authority: {},
|
|
scheme: {},
|
|
httpStatus: {},
|
|
tls: {},
|
|
httpMethod: httpMethods
|
|
};
|
|
}
|
|
|
|
getResourcesByNs(rsp) {
|
|
let statTables = _.get(rsp, [0, "ok", "statTables"]);
|
|
let authoritiesByNs = {};
|
|
let resourcesByNs = _.reduce(statTables, (mem, table) => {
|
|
_.map(table.podGroup.rows, row => {
|
|
if (!mem[row.resource.namespace]) {
|
|
mem[row.resource.namespace] = [];
|
|
authoritiesByNs[row.resource.namespace] = [];
|
|
}
|
|
|
|
switch (row.resource.type.toLowerCase()) {
|
|
case "service":
|
|
break;
|
|
case "authority":
|
|
authoritiesByNs[row.resource.namespace].push(row.resource.name);
|
|
break;
|
|
default:
|
|
mem[row.resource.namespace].push(`${row.resource.type}/${row.resource.name}`);
|
|
}
|
|
});
|
|
return mem;
|
|
}, {});
|
|
return {
|
|
authoritiesByNs,
|
|
resourcesByNs
|
|
};
|
|
}
|
|
|
|
getResourceList(resourcesByNs, ns) {
|
|
return resourcesByNs[ns] || _.uniq(_.flatten(_.values(resourcesByNs)));
|
|
}
|
|
|
|
parseTapResult = data => {
|
|
let d = JSON.parse(data);
|
|
let filters = this.state.tapResultFilterOptions;
|
|
|
|
// keep track of unique values we encounter, to populate the table filters
|
|
let addFilter = this.genFilterAdder(filters, Date.now());
|
|
d.source.str = publicAddressToString(_.get(d, "source.ip.ipv4"), d.source.port);
|
|
addFilter("source", d.source.str);
|
|
d.destination.str = publicAddressToString(_.get(d, "destination.ip.ipv4"), d.destination.port);
|
|
addFilter("destination", d.destination.str);
|
|
|
|
switch (d.proxyDirection) {
|
|
case "INBOUND":
|
|
d.tls = _.get(d, "sourceMeta.labels.tls", "");
|
|
addFilter("tls", d.tls);
|
|
break;
|
|
case "OUTBOUND":
|
|
d.tls = _.get(d, "destinationMeta.labels.tls", "");
|
|
addFilter("tls", d.tls);
|
|
break;
|
|
default:
|
|
// too old for TLS
|
|
}
|
|
|
|
if (_.isNil(d.http)) {
|
|
this.setState({ error: "Undefined request type"});
|
|
} else {
|
|
if (!_.isNil(d.http.requestInit)) {
|
|
d.eventType = "req";
|
|
d.id = `${_.get(d, "http.requestInit.id.base")}:${_.get(d, "http.requestInit.id.stream")} `;
|
|
|
|
addFilter("authority", d.http.requestInit.authority);
|
|
addFilter("path", d.http.requestInit.path);
|
|
addFilter("scheme", _.get(d, "http.requestInit.scheme.registered"));
|
|
} else if (!_.isNil(d.http.responseInit)) {
|
|
d.eventType = "rsp";
|
|
d.id = `${_.get(d, "http.responseInit.id.base")}:${_.get(d, "http.responseInit.id.stream")} `;
|
|
|
|
addFilter("httpStatus", _.get(d, "http.responseInit.httpStatus"));
|
|
} else if (!_.isNil(d.http.responseEnd)) {
|
|
d.eventType = "end";
|
|
d.id = `${_.get(d, "http.responseEnd.id.base")}:${_.get(d, "http.responseEnd.id.stream")} `;
|
|
}
|
|
}
|
|
|
|
return {
|
|
tapResult: d,
|
|
updatedFilters: filters
|
|
};
|
|
}
|
|
|
|
genFilterAdder(filterOptions, now) {
|
|
return (filterName, filterValue) => {
|
|
filterOptions[filterName][filterValue] = now;
|
|
|
|
if (_.size(filterOptions[filterName]) > maxNumFilterOptions) {
|
|
// reevaluate this if table updating gets too slow
|
|
let oldest = Date.now();
|
|
let oldestOption = "";
|
|
_.each(filterOptions[filterName], (timestamp, value) => {
|
|
if (timestamp < oldest) {
|
|
oldest = timestamp;
|
|
oldestOption = value;
|
|
}
|
|
});
|
|
|
|
delete filterOptions[filterName][oldestOption];
|
|
}
|
|
};
|
|
}
|
|
|
|
indexTapResult = data => {
|
|
// keep an index of tap request rows by id. this allows us to collate
|
|
// requestInit/responseInit/responseEnd into one single table row,
|
|
// as opposed to three separate rows as in the CLI
|
|
let resultIndex = this.state.tapResultsById;
|
|
let parsedResults = this.parseTapResult(data);
|
|
let d = parsedResults.tapResult;
|
|
|
|
if (_.isNil(resultIndex[d.id])) {
|
|
// don't let tapResultsById grow unbounded
|
|
if (_.size(resultIndex) > this.state.maxLinesToDisplay) {
|
|
this.deleteOldestTapResult(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();
|
|
|
|
this.setState({
|
|
tapResultsById: resultIndex,
|
|
tapResultFilterOptions: parsedResults.updatedFilters
|
|
});
|
|
}
|
|
|
|
deleteOldestTapResult = 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);
|
|
}
|
|
|
|
stopServerPolling() {
|
|
window.clearInterval(this.timerId);
|
|
this.api.cancelCurrentRequests();
|
|
}
|
|
|
|
startTapSteaming() {
|
|
this.setState({
|
|
awaitingWebSocketConnection: true,
|
|
tapRequestInProgress: true,
|
|
tapResultsById: {},
|
|
tapResultFilterOptions: this.getInitialTapFilterOptions()
|
|
});
|
|
|
|
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
|
|
});
|
|
}
|
|
|
|
toggleAdvancedForm = show => {
|
|
this.setState({
|
|
showAdvancedForm: show
|
|
});
|
|
}
|
|
|
|
handleTapStart = e => {
|
|
e.preventDefault();
|
|
this.startTapSteaming();
|
|
}
|
|
|
|
handleTapStop = () => {
|
|
this.ws.close(1000);
|
|
}
|
|
|
|
handleFormChange = (name, scopeResource, shouldScopeAuthority) => {
|
|
let state = {
|
|
query: this.state.query,
|
|
autocomplete: this.state.autocomplete
|
|
};
|
|
|
|
return formVal => {
|
|
state.query[name] = formVal;
|
|
if (!_.isNil(scopeResource)) {
|
|
// scope the available typeahead resources to the selected namespace
|
|
state.autocomplete[scopeResource] = this.state.resourcesByNs[formVal];
|
|
}
|
|
if (shouldScopeAuthority) {
|
|
state.autocomplete.authority = this.state.authoritiesByNs[formVal];
|
|
}
|
|
|
|
this.setState(state);
|
|
};
|
|
}
|
|
|
|
handleFormEvent = name => {
|
|
let state = {
|
|
query: this.state.query
|
|
};
|
|
|
|
return event => {
|
|
state.query[name] = event.target.value;
|
|
this.setState(state);
|
|
};
|
|
}
|
|
|
|
autoCompleteData = name => {
|
|
return _(this.state.autocomplete[name])
|
|
.filter(d => d.indexOf(this.state.query[name]) !== -1)
|
|
.sortBy()
|
|
.value();
|
|
}
|
|
|
|
loadFromServer() {
|
|
if (this.state.pendingRequests) {
|
|
return; // don't make more requests if the ones we sent haven't completed
|
|
}
|
|
this.setState({
|
|
pendingRequests: true
|
|
});
|
|
|
|
let url = "/api/tps-reports?resource_type=all&all_namespaces=true";
|
|
this.api.setCurrentRequests([this.api.fetchMetrics(url)]);
|
|
this.serverPromise = Promise.all(this.api.getCurrentPromises())
|
|
.then(rsp => {
|
|
let { resourcesByNs, authoritiesByNs } = this.getResourcesByNs(rsp);
|
|
let namespaces = _.sortBy(_.keys(resourcesByNs));
|
|
let resourceNames = this.getResourceList(resourcesByNs, this.state.query.namespace);
|
|
let toResourceNames = this.getResourceList(resourcesByNs, this.state.query.toNamespace);
|
|
let authorities = this.getResourceList(authoritiesByNs, this.state.query.namespace);
|
|
|
|
this.setState({
|
|
resourcesByNs,
|
|
authoritiesByNs,
|
|
autocomplete: {
|
|
namespace: namespaces,
|
|
resource: resourceNames,
|
|
toNamespace: namespaces,
|
|
toResource: toResourceNames,
|
|
authority: authorities
|
|
},
|
|
pendingRequests: false
|
|
});
|
|
})
|
|
.catch(this.handleApiError);
|
|
}
|
|
|
|
handleApiError = e => {
|
|
if (e.isCanceled) {
|
|
return;
|
|
}
|
|
|
|
this.setState({
|
|
pendingRequests: false,
|
|
error: e
|
|
});
|
|
}
|
|
|
|
renderTapForm = () => {
|
|
return (
|
|
<Form className="tap-form">
|
|
<Row gutter={rowGutter}>
|
|
<Col span={colSpan}>
|
|
<Form.Item>
|
|
<Select
|
|
showSearch
|
|
allowClear
|
|
placeholder="Namespace"
|
|
optionFilterProp="children"
|
|
onChange={this.handleFormChange("namespace", "resource", true)}>
|
|
{
|
|
_.map(this.state.autocomplete.namespace, (n, i) => (
|
|
<Select.Option key={`ns-dr-${i}`} value={n}>{n}</Select.Option>
|
|
))
|
|
}
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
<Col span={colSpan}>
|
|
<Form.Item>
|
|
<AutoComplete
|
|
dataSource={this.autoCompleteData("resource")}
|
|
onSelect={this.handleFormChange("resource")}
|
|
onSearch={this.handleFormChange("resource")}
|
|
placeholder="Resource" />
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
<Col span={colSpan}>
|
|
<Form.Item>
|
|
{
|
|
this.state.tapRequestInProgress ?
|
|
<Button type="primary" className="tap-stop" onClick={this.handleTapStop}>Stop</Button> :
|
|
<Button type="primary" className="tap-start" onClick={this.handleTapStart}>Start</Button>
|
|
}
|
|
{
|
|
this.state.awaitingWebSocketConnection ?
|
|
<Icon type="loading" style={{ paddingLeft: rowGutter, fontSize: 20, color: '#08c' }} /> : null
|
|
}
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<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'} />
|
|
</Button>
|
|
|
|
{ !this.state.showAdvancedForm ? null : this.renderAdvancedTapForm() }
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
renderAdvancedTapForm = () => {
|
|
return (
|
|
<React.Fragment>
|
|
<Row gutter={rowGutter}>
|
|
<Col span={colSpan}>
|
|
<Form.Item>
|
|
<Select
|
|
showSearch
|
|
allowClear
|
|
placeholder="To Namespace"
|
|
optionFilterProp="children"
|
|
onChange={this.handleFormChange("toNamespace", "toResource")}>
|
|
{
|
|
_.map(this.state.autocomplete.toNamespace, (n, i) => (
|
|
<Select.Option key={`ns-dr-${i}`} value={n}>{n}</Select.Option>
|
|
))
|
|
}
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
<Col span={colSpan}>
|
|
<Form.Item>
|
|
<AutoComplete
|
|
dataSource={this.autoCompleteData("toResource")}
|
|
onSelect={this.handleFormChange("toResource")}
|
|
onSearch={this.handleFormChange("toResource")}
|
|
placeholder="To Resource" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Row gutter={rowGutter}>
|
|
<Col span={2 * colSpan}>
|
|
<Form.Item
|
|
extra="Display requests with this :authority">
|
|
|
|
<AutoComplete
|
|
dataSource={this.autoCompleteData("authority")}
|
|
onSelect={this.handleFormChange("authority")}
|
|
onSearch={this.handleFormChange("authority")}
|
|
placeholder="Authority" />
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
<Col span={2 * colSpan}>
|
|
<Form.Item
|
|
extra="Display requests with paths that start with this prefix">
|
|
<Input placeholder="Path" onChange={this.handleFormEvent("path")} />
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
</Row>
|
|
|
|
<Row gutter={rowGutter}>
|
|
<Col span={colSpan}>
|
|
<Form.Item
|
|
extra="Display requests with this scheme">
|
|
<Input placeholder="Scheme" onChange={this.handleFormEvent("scheme")} />
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
<Col span={colSpan}>
|
|
<Form.Item
|
|
extra="Maximum requests per second to tap">
|
|
<Input
|
|
defaultValue={defaultMaxRps}
|
|
placeholder="Max RPS"
|
|
onChange={this.handleFormEvent("maxRps")} />
|
|
</Form.Item>
|
|
</Col>
|
|
|
|
<Col span={colSpan}>
|
|
<Form.Item
|
|
extra="Display requests with this HTTP method">
|
|
<Select
|
|
allowClear
|
|
placeholder="HTTP method"
|
|
onChange={this.handleFormChange("method")}>
|
|
{
|
|
_.map(httpMethods, m =>
|
|
<Select.Option key={`method-select-${m}`} value={m}>{m}</Select.Option>
|
|
)
|
|
}
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
renderCurrentQuery = () => {
|
|
let emptyVals = _.countBy(_.values(this.state.query), v => _.isEmpty(v));
|
|
|
|
return (
|
|
<div className="tap-query">
|
|
<code>
|
|
{ !emptyVals.false || emptyVals.false === 1 ? null :
|
|
<div>
|
|
Current query:
|
|
{
|
|
_.map(this.state.query, (query, queryName) => {
|
|
if (query === "") {
|
|
return null;
|
|
}
|
|
return <div key={queryName}>{queryName}: {query}</div>;
|
|
})
|
|
}
|
|
</div>
|
|
}
|
|
</code>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
let tableRows = _(this.state.tapResultsById)
|
|
.values().sortBy('lastUpdated').reverse().value();
|
|
|
|
return (
|
|
<div>
|
|
{!this.state.error ? null :
|
|
<ErrorBanner message={this.state.error} onHideMessage={() => this.setState({ error: "" })} />}
|
|
|
|
<PageHeader header="Tap" />
|
|
{this.renderTapForm()}
|
|
{this.renderCurrentQuery()}
|
|
|
|
<TapEventTable
|
|
tableRows={tableRows}
|
|
filterOptions={this.state.tapResultFilterOptions} />
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default withContext(Tap);
|