import _ from 'lodash'; import ErrorBanner from './ErrorBanner.jsx'; import PageHeader from './PageHeader.jsx'; import PropTypes from 'prop-types'; import React from 'react'; import TapEventTable from './TapEventTable.jsx'; import TapQueryCliCmd from './TapQueryCliCmd.jsx'; import TapQueryForm from './TapQueryForm.jsx'; import { withContext } from './util/AppContext.jsx'; import { defaultMaxRps, httpMethods, processTapEvent } from './util/TapUtils.jsx'; import './../../css/tap.css'; 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 }, maxLinesToDisplay: 40, tapRequestInProgress: false, pollingInterval: 10000, pendingRequests: false }; } componentDidMount() { this.startServerPolling(); } 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: "tap-web", ...query })); this.setState({ 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(); } 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 }; } getFilterOptions(d) { let filters = this.state.tapResultFilterOptions; // keep track of unique values we encounter, to populate the table filters let addFilter = this.genFilterAdder(filters, Date.now()); addFilter("source", d.source.str); addFilter("destination", d.destination.str); if (d.source.pod) { addFilter("source", d.source.pod); } if (d.destination.pod) { addFilter("destination", d.destination.pod); } if (d.tls) { addFilter("tls", d.tls); } switch (d.eventType) { case "requestInit": addFilter("authority", d.http.requestInit.authority); addFilter("path", d.http.requestInit.path); addFilter("scheme", _.get(d, "http.requestInit.scheme.registered")); break; case "responseInit": addFilter("httpStatus", _.get(d, "http.responseInit.httpStatus")); break; } return filters; } parseTapResult = data => { let d = processTapEvent(data); return { tapResult: d, updatedFilters: this.getFilterOptions(d) }; } 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(); } startTapStreaming() { this.setState({ 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 }); } handleTapStart = e => { e.preventDefault(); this.startTapStreaming(); } handleTapStop = () => { this.ws.close(1000); } 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); this.setState({ resourcesByNs, authoritiesByNs, pendingRequests: false }); }) .catch(this.handleApiError); } handleApiError = e => { if (e.isCanceled) { return; } this.setState({ pendingRequests: false, error: e }); } updateQuery = query => { this.setState({ query }); } render() { let tableRows = _(this.state.tapResultsById) .values().sortBy('lastUpdated').reverse().value(); return (
{!this.state.error ? null : this.setState({ error: null })} />}
); } } export default withContext(Tap);