Add ability to change the time window for metrics fetching throughout the app (#237)

* Control metricsWindow from root of app

- Add buttons [currently hidden] on metrics pages to control window of metrics requests
- Consolidate metricsWindow usage (stop passing it around)
- Add a ConduitLink component so we can stop passing around pathPrefix
- Add tests for ApiHelpers

* Hide the time window buttons; fix bug in absolute links
* Add a note explaining why metricWindow buttons are disabled
* Convert ConduitLink in to a component that wraps another
This commit is contained in:
Risha Mars 2018-02-05 10:56:17 -08:00 committed by GitHub
parent b691c2e25b
commit 9887f10749
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 418 additions and 141 deletions

View File

@ -70,6 +70,10 @@ h2, h3, h4, h5, h6 {
margin-bottom: 0px;
}
.time-window-btns {
float: right;
}
.subsection-header {
text-transform: uppercase;
font-size: 14px;

View File

@ -4,13 +4,13 @@ import ConduitSpinner from "./ConduitSpinner.jsx";
import ErrorBanner from './ErrorBanner.jsx';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import Metric from './Metric.jsx';
import PageHeader from './PageHeader.jsx';
import React from 'react';
import ResourceHealthOverview from './ResourceHealthOverview.jsx';
import ResourceMetricsOverview from './ResourceMetricsOverview.jsx';
import { rowGutter } from './util/Utils.js';
import TabbedMetricsTable from './TabbedMetricsTable.jsx';
import UpstreamDownstream from './UpstreamDownstream.jsx';
import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js';
import { Col, Row } from 'antd';
import { emptyMetric, getPodsByDeployment, processRollupMetrics, processTimeseriesMetrics } from './util/MetricUtils.js';
import './../../css/deployment.css';
@ -19,7 +19,7 @@ import 'whatwg-fetch';
export default class DeploymentDetail extends React.Component {
constructor(props) {
super(props);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
this.state = this.initialState(this.props.location);
@ -47,7 +47,6 @@ export default class DeploymentDetail extends React.Component {
return {
lastUpdated: 0,
pollingInterval: 10000,
metricsWindow: "10m",
deploy: deployment,
metrics: [],
pods: [],
@ -66,7 +65,7 @@ export default class DeploymentDetail extends React.Component {
}
this.setState({ pendingRequests: true });
let urls = urlsForResource(this.props.pathPrefix, this.state.metricsWindow);
let urls = this.api.urlsForResource;
let deployMetricsUrl = urls["deployment"].url(this.state.deploy).ts;
let podRollupUrl = urls["pod"].url(this.state.deploy).rollup;
@ -74,12 +73,12 @@ export default class DeploymentDetail extends React.Component {
let downstreamRollupUrl = urls["downstream_deployment"].url(this.state.deploy).rollup;
let pathMetricsUrl = urls["path"].url(this.state.deploy).rollup;
let deployFetch = this.api.fetch(deployMetricsUrl);
let deployFetch = this.api.fetchMetrics(deployMetricsUrl);
let podListFetch = this.api.fetchPods();
let podRollupFetch = this.api.fetch(podRollupUrl);
let upstreamFetch = this.api.fetch(upstreamRollupUrl);
let downstreamFetch = this.api.fetch(downstreamRollupUrl);
let pathsFetch = this.api.fetch(pathMetricsUrl);
let podRollupFetch = this.api.fetchMetrics(podRollupUrl);
let upstreamFetch = this.api.fetchMetrics(upstreamRollupUrl);
let downstreamFetch = this.api.fetchMetrics(downstreamRollupUrl);
let pathsFetch = this.api.fetchMetrics(pathMetricsUrl);
// expose serverPromise for testing
this.serverPromise = Promise.all([deployFetch, podRollupFetch, upstreamFetch, downstreamFetch, podListFetch, pathsFetch])
@ -142,7 +141,8 @@ export default class DeploymentDetail extends React.Component {
<ResourceMetricsOverview
key="stat-pane"
lastUpdated={this.state.lastUpdated}
timeseries={this.state.deployTs} />,
timeseries={this.state.deployTs}
window={this.api.getMetricsWindow()} />,
this.renderMidsection(),
<UpstreamDownstream
key="deploy-upstream-downstream"
@ -151,8 +151,7 @@ export default class DeploymentDetail extends React.Component {
lastUpdated={this.state.lastUpdated}
upstreamMetrics={this.state.upstreamMetrics}
downstreamMetrics={this.state.downstreamMetrics}
metricsWindow={this.state.metricsWindow}
pathPrefix={this.props.pathPrefix} />,
api={this.api} />,
this.renderPaths()
];
}
@ -188,8 +187,7 @@ export default class DeploymentDetail extends React.Component {
resourceName={this.state.deploy}
metrics={podTableData}
lastUpdated={this.state.lastUpdated}
pathPrefix={this.props.pathPrefix}
metricsWindow={this.state.metricsWindow} />
api={this.api} />
</div>
</Col>
@ -220,8 +218,7 @@ export default class DeploymentDetail extends React.Component {
metrics={this.state.pathMetrics}
hideSparklines={true}
lastUpdated={this.props.lastUpdated}
metricsWindow={this.props.metricsWindow}
pathPrefix={this.props.pathPrefix} />
api={this.api} />
</div>;
}
@ -231,12 +228,7 @@ export default class DeploymentDetail extends React.Component {
<h1>{this.state.deploy}</h1>
{
!this.state.added ? (
<div className="unadded-message">
<div className="status-badge unadded"><p>UNADDED</p></div>
<div className="call-to-action">
{incompleteMeshMessage(this.state.deploy)}
</div>
</div>
<p className="status-badge unadded">UNADDED</p>
) : null
}
</div>
@ -249,10 +241,12 @@ export default class DeploymentDetail extends React.Component {
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<div className="page-header">
<div className="subsection-header">Deployment detail</div>
{this.renderDeploymentTitle()}
</div>
<PageHeader
subHeaderTitle="Deployment detail"
subHeader={this.renderDeploymentTitle()}
subMessage={!this.state.added ? incompleteMeshMessage(this.state.deploy) : null}
api={this.api} />
{this.renderSections()}
</div>
}

View File

@ -1,5 +1,4 @@
import LineGraph from './LineGraph.jsx';
import { Link } from 'react-router-dom';
import React from 'react';
import { metricToFormatter, toClassName } from './util/Utils.js';
@ -8,7 +7,9 @@ export default class DeploymentSummary extends React.Component {
if (this.props.noLink) {
return this.props.data.name;
} else {
return <Link to={`${this.props.pathPrefix}/deployment?deploy=${this.props.data.name}`}>{this.props.data.name}</Link>;
return (<this.props.api.ConduitLink
to={`/deployment?deploy=${this.props.data.name}`}
name={this.props.data.name} />);
}
}
@ -17,7 +18,7 @@ export default class DeploymentSummary extends React.Component {
<div className={`border-container border-neutral`}>
<div className="border-container-content">
<div className="summary-title">{this.title()}</div>
<div className="summary-info">Last 10 minutes RPS</div>
<div className="summary-info">RPS (last {this.props.api.getMetricsWindowDisplayText()})</div>
<LineGraph
data={this.props.requestTs}

View File

@ -3,10 +3,10 @@ import CallToAction from './CallToAction.jsx';
import ConduitSpinner from "./ConduitSpinner.jsx";
import DeploymentSummary from './DeploymentSummary.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import PageHeader from './PageHeader.jsx';
import React from 'react';
import ScatterPlot from './ScatterPlot.jsx';
import TabbedMetricsTable from './TabbedMetricsTable.jsx';
import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js';
import { Col, Row } from 'antd';
import { emptyMetric, getPodsByDeployment, processRollupMetrics, processTimeseriesMetrics } from './util/MetricUtils.js';
import { metricToFormatter, rowGutter } from './util/Utils.js';
@ -26,13 +26,12 @@ let nodeStats = (description, node) => (
export default class DeploymentsList extends React.Component {
constructor(props) {
super(props);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
this.loadTimeseriesFromServer = this.loadTimeseriesFromServer.bind(this);
this.state = {
metricsWindow: "10m",
pollingInterval: 10000, // TODO: poll based on metricsWindow size
metrics: [],
timeseriesByDeploy: {},
@ -70,9 +69,7 @@ export default class DeploymentsList extends React.Component {
}
this.setState({ pendingRequests: true });
let rollupPath = `${this.props.pathPrefix}/api/metrics?window=${this.state.metricsWindow}`;
let rollupRequest = this.api.fetch(rollupPath);
let rollupRequest = this.api.fetchMetrics(this.api.urlsForResource["deployment"].url().rollup);
let podsRequest = this.api.fetchPods();
// expose serverPromise for testing
@ -91,12 +88,12 @@ export default class DeploymentsList extends React.Component {
// fetch only the timeseries for the 3 deployments we display at the top of the page
let limitSparklineData = _.size(meshDeployMetrics) > maxTsToFetch;
let resourceInfo = urlsForResource(this.props.pathPrefix, this.state.metricsWindow)["deployment"];
let resourceInfo = this.api.urlsForResource["deployment"];
let leastHealthyDeployments = this.getLeastHealthyDeployments(meshDeployMetrics);
let tsPromises = _.map(leastHealthyDeployments, dep => {
let tsPathForDeploy = resourceInfo.url(dep.name).ts;
return this.api.fetch(tsPathForDeploy);
return this.api.fetchMetrics(tsPathForDeploy);
});
Promise.all(tsPromises)
@ -147,8 +144,8 @@ export default class DeploymentsList extends React.Component {
key={deployment.name}
lastUpdated={this.state.lastUpdated}
data={deployment}
requestTs={_.get(this.state.timeseriesByDeploy, [deployment.name, "REQUEST_RATE"], [])}
pathPrefix={this.props.pathPrefix} />
api = {this.api}
requestTs={_.get(this.state.timeseriesByDeploy, [deployment.name, "REQUEST_RATE"], [])} />
</Col>);
})
}
@ -162,8 +159,7 @@ export default class DeploymentsList extends React.Component {
lastUpdated={this.state.lastUpdated}
metrics={this.state.metrics}
hideSparklines={this.state.limitSparklineData}
metricsWindow={this.state.metricsWindow}
pathPrefix={this.props.pathPrefix} />
api={this.api} />
</div>
</div>
);
@ -213,9 +209,7 @@ export default class DeploymentsList extends React.Component {
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<div className="page-header">
<h1>Deployments</h1>
</div>
<PageHeader header="Deployments" api={this.api} />
{ _.isEmpty(this.state.metrics) ?
<CallToAction numDeployments={_.size(this.state.metrics)} /> :
this.renderPageContents()

View File

@ -0,0 +1,57 @@
import _ from 'lodash';
import React from 'react';
import { Col, Radio, Row } from 'antd';
export default class PageHeader extends React.Component {
constructor(props) {
super(props);
this.onTimeWindowClick = this.onTimeWindowClick.bind(this);
this.state = {
selectedWindow: this.props.api.getMetricsWindow()
};
}
onTimeWindowClick(e) {
let window = e.target.value;
this.props.api.setMetricsWindow(window);
this.setState({selectedWindow: window});
}
// don't use time window changing until the results of Telemetry Scalability are in
// https://github.com/runconduit/conduit/milestone/4
renderMetricWindowButtons() {
if (this.props.hideButtons) {
return null;
} else {
return (<Col span={6}>
<Radio.Group
className="time-window-btns"
value={this.state.selectedWindow}
onChange={this.onTimeWindowClick} >
{
_.map(this.props.api.getValidMetricsWindows(), (w, i) => {
return <Radio.Button key={`metrics-window-btn-${i}`} value={w}>{w}</Radio.Button>;
})
}
</Radio.Group>
</Col>);
}
}
render() {
return (
<div className="page-header">
<Row>
<Col span={18}>
{!this.props.header ? null : <h1>{this.props.header}</h1>}
{!this.props.subHeaderTitle ? null : <div className="subsection-header">{this.props.subHeaderTitle}</div>}
{!this.props.subHeader ? null : <h1>{this.props.subHeader}</h1>}
</Col>
{/* {this.renderMetricWindowButtons()} */}
</Row>
{!this.props.subMessage ? null : <div>{this.props.subMessage}</div>}
</div>
);
}
}

View File

@ -1,15 +1,15 @@
import ConduitSpinner from "./ConduitSpinner.jsx";
import ErrorBanner from './ErrorBanner.jsx';
import PageHeader from './PageHeader.jsx';
import { processRollupMetrics } from './util/MetricUtils.js';
import React from 'react';
import TabbedMetricsTable from './TabbedMetricsTable.jsx';
import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js';
import 'whatwg-fetch';
export default class Paths extends React.Component {
constructor(props) {
super(props);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
this.state = this.initialState();
@ -28,7 +28,6 @@ export default class Paths extends React.Component {
return {
lastUpdated: 0,
pollingInterval: 10000,
metricsWindow: "1m",
metrics: [],
pendingRequests: false,
loaded: false,
@ -42,9 +41,9 @@ export default class Paths extends React.Component {
}
this.setState({ pendingRequests: true });
let urls = urlsForResource(this.props.pathPrefix, this.state.metricsWindow);
let urls = this.api.urlsForResource;
this.api.fetch(urls["path"].url().rollup).then(r => {
this.api.fetchMetrics(urls["path"].url().rollup).then(r => {
let metrics = processRollupMetrics(r.metrics, "path");
this.setState({
@ -70,16 +69,13 @@ export default class Paths extends React.Component {
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<div className="page-header">
<h1>Paths</h1>
</div>
<PageHeader header="Paths" api={this.api} />
<TabbedMetricsTable
resource="path"
metrics={this.state.metrics}
lastUpdated={this.state.lastUpdated}
pathPrefix={this.props.pathPrefix}
metricsWindow={this.state.metricsWindow}
api={this.api}
sortable={true}
hideSparklines={true} />
</div>

View File

@ -1,18 +1,18 @@
import _ from 'lodash';
import ConduitSpinner from "./ConduitSpinner.jsx";
import ErrorBanner from './ErrorBanner.jsx';
import PageHeader from './PageHeader.jsx';
import React from 'react';
import ResourceHealthOverview from './ResourceHealthOverview.jsx';
import ResourceMetricsOverview from './ResourceMetricsOverview.jsx';
import UpstreamDownstream from './UpstreamDownstream.jsx';
import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js';
import { processRollupMetrics, processTimeseriesMetrics } from './util/MetricUtils.js';
import 'whatwg-fetch';
export default class PodDetail extends React.Component {
constructor(props) {
super(props);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
this.state = this.initialState(this.props.location);
@ -40,7 +40,6 @@ export default class PodDetail extends React.Component {
return {
lastUpdated: 0,
pollingInterval: 10000,
metricsWindow: "10m",
pod: pod,
upstreamMetrics: [],
downstreamMetrics: [],
@ -57,16 +56,16 @@ export default class PodDetail extends React.Component {
}
this.setState({ pendingRequests: true });
let urls = urlsForResource(this.props.pathPrefix, this.state.metricsWindow);
let urls = this.api.urlsForResource;
let metricsUrl = urls["deployment"].url().rollup;
let podMetricsUrl = `${metricsUrl}&timeseries=true&target_pod=${this.state.pod}`;
let upstreamRollupUrl = urls["upstream_pod"].url(this.state.pod).rollup;
let downstreamRollupUrl = urls["downstream_pod"].url(this.state.pod).rollup;
let podFetch = this.api.fetch(podMetricsUrl);
let upstreamFetch = this.api.fetch(upstreamRollupUrl);
let downstreamFetch = this.api.fetch(downstreamRollupUrl);
let podFetch = this.api.fetchMetrics(podMetricsUrl);
let upstreamFetch = this.api.fetchMetrics(upstreamRollupUrl);
let downstreamFetch = this.api.fetchMetrics(downstreamRollupUrl);
Promise.all([podFetch, upstreamFetch, downstreamFetch])
.then(([podMetrics, upstreamRollup, downstreamRollup]) => {
@ -110,7 +109,8 @@ export default class PodDetail extends React.Component {
<ResourceMetricsOverview
key="pod-stat-pane"
lastUpdated={this.state.lastUpdated}
timeseries={this.state.podTs} />,
timeseries={this.state.podTs}
window={this.api.getMetricsWindow()} />,
<UpstreamDownstream
key="pod-upstream-downstream"
resourceType="pod"
@ -118,8 +118,7 @@ export default class PodDetail extends React.Component {
lastUpdated={this.state.lastUpdated}
upstreamMetrics={this.state.upstreamMetrics}
downstreamMetrics={this.state.downstreamMetrics}
metricsWindow={this.state.metricsWindow}
pathPrefix={this.props.pathPrefix} />
api={this.api} />
];
}
@ -129,10 +128,10 @@ export default class PodDetail extends React.Component {
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<div className="page-header">
<div className="subsection-header">Pod detail</div>
<h1>{this.state.pod}</h1>
</div>
<PageHeader
subHeaderTitle="Pod detail"
subHeader={this.state.pod}
api={this.api} />
{this.renderSections()}
</div>
}

View File

@ -15,12 +15,14 @@ export default class ResourceMetricsOverview extends React.Component {
name="Request rate"
metric="REQUEST_RATE"
lastUpdated={this.props.lastUpdated}
window={this.props.window}
timeseries={_.get(this.props.timeseries, "REQUEST_RATE", [])} />
</Col>
<Col span={8}>
<ResourceOverviewMetric
name="Success rate"
metric="SUCCESS_RATE"
window={this.props.window}
lastUpdated={this.props.lastUpdated}
timeseries={_.get(this.props.timeseries, "SUCCESS_RATE", [])} />
</Col>

View File

@ -3,7 +3,7 @@ import LineGraph from './LineGraph.jsx';
import React from 'react';
import { metricToFormatter, toClassName } from './util/Utils.js';
export default class EntityOverviewMetric extends React.Component {
export default class ResourceOverviewMetric extends React.Component {
render() {
let lastDatapoint = _.last(this.props.timeseries) || {};
let metric = _.get(lastDatapoint, "value");
@ -15,7 +15,7 @@ export default class EntityOverviewMetric extends React.Component {
<div className="summary-container clearfix">
<div className="metric-info">
<div className="summary-title">{this.props.name}</div>
<div className="summary-info">Last 10 minutes performance</div>
<div className="summary-info">last {this.props.window}</div>
</div>
<div className="metric-value">{displayMetric}</div>
</div>

View File

@ -1,11 +1,11 @@
import _ from 'lodash';
import { ApiHelpers } from './util/ApiHelpers.js';
import CallToAction from './CallToAction.jsx';
import ConduitSpinner from "./ConduitSpinner.jsx";
import DeploymentSummary from './DeploymentSummary.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import Metric from './Metric.jsx';
import PageHeader from './PageHeader.jsx';
import React from 'react';
import { rowGutter } from './util/Utils.js';
import StatusTable from './StatusTable.jsx';
@ -54,11 +54,10 @@ export default class ServiceMesh extends React.Component {
super(props);
this.loadFromServer = this.loadFromServer.bind(this);
this.handleApiError = this.handleApiError.bind(this);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.state = {
pollingInterval: 2000,
metricsWindow: "10m",
metrics: [],
deploys: [],
components: [],
@ -84,11 +83,11 @@ export default class ServiceMesh extends React.Component {
}
this.setState({ pendingRequests: true });
let rollupPath = `${this.props.pathPrefix}/api/metrics?window=${this.state.metricsWindow}&aggregation=mesh`;
let rollupPath = `/api/metrics?aggregation=mesh`;
let timeseriesPath = `${rollupPath}&timeseries=true`;
let rollupRequest = this.api.fetch(rollupPath);
let timeseriesRequest = this.api.fetch(timeseriesPath);
let rollupRequest = this.api.fetchMetrics(rollupPath);
let timeseriesRequest = this.api.fetchMetrics(timeseriesPath);
let podsRequest = this.api.fetchPods();
this.serverPromise = Promise.all([rollupRequest, timeseriesRequest, podsRequest])
@ -199,6 +198,7 @@ export default class ServiceMesh extends React.Component {
data.name = componentNames[meshComponent];
return (<Col span={8} key={`col-${data.id}`}>
<DeploymentSummary
api={this.api}
key={data.id}
lastUpdated={this.state.lastUpdated}
data={data}
@ -222,8 +222,9 @@ export default class ServiceMesh extends React.Component {
<StatusTable
data={this.state.components}
statusColumnTitle="Pod Status"
shouldLink={false}
statusColumnTitle="Pod Status" />
api={this.api} />
</div>
);
}
@ -241,7 +242,7 @@ export default class ServiceMesh extends React.Component {
data={this.state.deploys}
statusColumnTitle="Proxy Status"
shouldLink={true}
pathPrefix={this.props.pathPrefix} />
api={this.api} />
</div>
);
}
@ -327,9 +328,10 @@ export default class ServiceMesh extends React.Component {
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<div className="page-header">
<h1>Service mesh overview</h1>
</div>
<PageHeader
header="Service mesh overview"
hideButtons={this.proxyCount() === 0}
api={this.api} />
{this.renderOverview()}
{this.renderControlPlane()}
{this.renderDataPlane()}

View File

@ -1,7 +1,5 @@
import _ from 'lodash';
import { ApiHelpers } from './util/ApiHelpers.js';
import { getPodsByDeployment } from './util/MetricUtils.js';
import { Link } from 'react-router-dom';
import logo from './../../img/reversed_logo.png';
import React from 'react';
import Version from './Version.jsx';
@ -13,7 +11,7 @@ const searchBarWidth = 240;
export default class Sidebar extends React.Component {
constructor(props) {
super(props);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.filterDeployments = this.filterDeployments.bind(this);
this.onAutocompleteSelect = this.onAutocompleteSelect.bind(this);
this.loadFromServer();
@ -60,12 +58,12 @@ export default class Sidebar extends React.Component {
render() {
let normalizedPath = this.props.location.pathname.replace(this.props.pathPrefix, "");
let ConduitLink = this.api.ConduitLink;
return (
<div className="sidebar">
<div className="list-container">
<div className="sidebar-headers">
<Link to={`${this.props.pathPrefix}/servicemesh`}><img src={logo} /></Link>
<ConduitLink to="/servicemesh"><img src={logo} /></ConduitLink>
</div>
<AutoComplete
@ -79,16 +77,16 @@ export default class Sidebar extends React.Component {
<Menu className="sidebar-menu" theme="dark" selectedKeys={[normalizedPath]}>
<Menu.Item className="sidebar-menu-item" key="/servicemesh">
<Link to={`${this.props.pathPrefix}/servicemesh`}>Service mesh</Link>
<ConduitLink to="/servicemesh">Service mesh</ConduitLink>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/deployments">
<Link to={`${this.props.pathPrefix}/deployments`}>Deployments</Link>
<ConduitLink to="/deployments">Deployments</ConduitLink>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/routes">
<Link to={`${this.props.pathPrefix}/routes`}>Routes</Link>
<ConduitLink to="/routes">Routes</ConduitLink>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/docs">
<Link to="https://conduit.io/docs/" target="_blank">Documentation</Link>
<ConduitLink to="https://conduit.io/docs/" absolute="true">Documentation</ConduitLink>
</Menu.Item>
</Menu>

View File

@ -1,5 +1,4 @@
import _ from 'lodash';
import { Link } from 'react-router-dom';
import React from 'react';
import { Table, Tooltip } from 'antd';
@ -37,12 +36,12 @@ const StatusDot = ({status, multilineDots, columnName}) => (
);
const columns = {
resourceName: (shouldLink, pathPrefix) => {
resourceName: (shouldLink, ConduitLink) => {
return {
title: "Deployment",
dataIndex: "name",
key: "name",
render: name => shouldLink ? <Link to={`${pathPrefix}/deployment?deploy=${name}`}>{name}</Link> : name
render: name => shouldLink ? <ConduitLink to={`/deployment?deploy=${name}`}>{name}</ConduitLink> : name
};
},
pods: {
@ -85,7 +84,7 @@ export default class StatusTable extends React.Component {
render() {
let tableCols = [
columns.resourceName(this.props.shouldLink, this.props.pathPrefix),
columns.resourceName(this.props.shouldLink, this.props.api.ConduitLink),
columns.pods,
columns.status(this.props.statusColumnTitle)
];

View File

@ -1,10 +1,8 @@
import _ from 'lodash';
import LineGraph from './LineGraph.jsx';
import { Link } from 'react-router-dom';
import Percentage from './util/Percentage.js';
import { processTimeseriesMetrics } from './util/MetricUtils.js';
import React from 'react';
import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js';
import { metricToFormatter, toClassName } from './util/Utils.js';
import { Table, Tabs } from 'antd';
@ -23,16 +21,16 @@ const resourceInfo = {
"path": { title: "path", url: null }
};
const generateColumns = sortable => {
const generateColumns = (sortable, ConduitLink) => {
return {
resourceName: (resource, pathPrefix) => {
resourceName: resource => {
return {
title: resource.title,
dataIndex: "name",
key: "name",
sorter: sortable ? (a, b) => (a.name || "").localeCompare(b.name) : false,
render: name => !resource.url ? name :
<Link to={`${pathPrefix}${resource.url}${name}`}>{name}</Link>
<ConduitLink to={`${resource.url}${name}`}>{name}</ConduitLink>
};
},
successRate: {
@ -91,14 +89,14 @@ const numericSort = (a, b) => (_.isNil(a) ? -1 : a) - (_.isNil(b) ? -1 : b);
const metricToColumns = baseCols => {
return {
requestRate: (resource, pathPrefix) => [
baseCols.resourceName(resource, pathPrefix),
requestRate: resource => [
baseCols.resourceName(resource),
baseCols.requests,
resource.title === "deployment" ? null : baseCols.requestDistribution
],
successRate: (resource, pathPrefix) => [baseCols.resourceName(resource, pathPrefix), baseCols.successRate],
latency: (resource, pathPrefix) => [
baseCols.resourceName(resource, pathPrefix),
successRate: resource => [baseCols.resourceName(resource), baseCols.successRate],
latency: resource => [
baseCols.resourceName(resource),
baseCols.latencyP50,
baseCols.latencyP95,
baseCols.latencyP99
@ -115,11 +113,11 @@ const nameToDataKey = {
export default class TabbedMetricsTable extends React.Component {
constructor(props) {
super(props);
this.api = ApiHelpers(this.props.pathPrefix);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
let tsHelper = urlsForResource(this.props.pathPrefix, this.props.metricsWindow)[this.props.resource];
let tsHelper = this.api.urlsForResource[this.props.resource];
this.state = {
timeseries: {},
@ -128,7 +126,6 @@ export default class TabbedMetricsTable extends React.Component {
metricsUrl: tsHelper.url(this.props.resourceName),
error: '',
lastUpdated: this.props.lastUpdated,
metricsWindow: "10s",
pollingInterval: 10000,
pendingRequests: false
};
@ -167,7 +164,7 @@ export default class TabbedMetricsTable extends React.Component {
}
this.setState({ pendingRequests: true });
this.api.fetch(this.state.metricsUrl.ts)
this.api.fetchMetrics(this.state.metricsUrl.ts)
.then(tsResp => {
let tsByEntity = processTimeseriesMetrics(tsResp.metrics, this.state.groupBy);
this.setState({
@ -188,7 +185,7 @@ export default class TabbedMetricsTable extends React.Component {
getSparklineColumn(metricName) {
return {
title: "10 minute history",
title: `History (last ${this.api.getMetricsWindow()})`,
key: metricName,
className: "numeric",
render: d => {
@ -212,8 +209,8 @@ export default class TabbedMetricsTable extends React.Component {
renderTable(metric) {
let resource = resourceInfo[this.props.resource];
let columnDefinitions = metricToColumns(generateColumns(this.props.sortable));
let columns = _.compact(columnDefinitions[metric](resource, this.props.pathPrefix));
let columnDefinitions = metricToColumns(generateColumns(this.props.sortable, this.props.api.ConduitLink));
let columns = _.compact(columnDefinitions[metric](resource));
if (!this.props.hideSparklines) {
columns.push(this.getSparklineColumn(metric));
}

View File

@ -26,8 +26,7 @@ export default class UpstreamDownstreamTables extends React.Component {
hideSparklines={numUpstreams > maxTsToFetch}
lastUpdated={this.props.lastUpdated}
metrics={this.props.upstreamMetrics}
metricsWindow={this.props.metricsWindow}
pathPrefix={this.props.pathPrefix} />
api={this.props.api} />
</div>
}
{
@ -44,8 +43,7 @@ export default class UpstreamDownstreamTables extends React.Component {
hideSparklines={numDownstreams > maxTsToFetch}
lastUpdated={this.props.lastUpdated}
metrics={this.props.downstreamMetrics}
metricsWindow={this.props.metricsWindow}
pathPrefix={this.props.pathPrefix} />
api={this.props.api} />
</div>
}
</Col>

View File

@ -1,4 +1,4 @@
import { ApiHelpers } from './util/ApiHelpers.js';
import { ApiHelpers } from './util/ApiHelpers.jsx';
import { Link } from 'react-router-dom';
import React from 'react';
import './../../css/version.css';

View File

@ -1,12 +1,37 @@
import _ from 'lodash';
import { Link } from 'react-router-dom';
import React from 'react';
import 'whatwg-fetch';
export const ApiHelpers = pathPrefix => {
const podsPath = `${pathPrefix}/api/pods`;
export const ApiHelpers = (pathPrefix, defaultMetricsWindow = '10m') => {
let metricsWindow = defaultMetricsWindow;
const podsPath = `/api/pods`;
const validMetricsWindows = {
"10s": "10 minutes",
"1m": "1 minute",
"10m": "10 minutes",
"1h": "1 hour"
};
const apiFetch = path => {
if (!_.isEmpty(pathPrefix)) {
path = `${pathPrefix}${path}`;
}
return fetch(path).then(handleFetchErr).then(r => r.json());
};
const fetchMetrics = path => {
if (path.indexOf("window") === -1) {
if (path.indexOf("?") === -1) {
path = `${path}?window=${getMetricsWindow()}`;
} else {
path = `${path}&window=${getMetricsWindow()}`;
}
}
return apiFetch(path);
};
const fetchPods = () => {
return apiFetch(podsPath);
};
@ -18,20 +43,16 @@ export const ApiHelpers = pathPrefix => {
return resp;
};
return {
fetch: apiFetch,
fetchPods
const getMetricsWindow = () => metricsWindow;
const getMetricsWindowDisplayText = () => validMetricsWindows[metricsWindow];
const setMetricsWindow = window => {
if (!validMetricsWindows[window]) return;
metricsWindow = window;
};
};
export const urlsForResource = (pathPrefix, metricsWindow) => {
/*
Timeseries fetches used in the TabbedMetricsTable
Rollup fetches used throughout app
*/
let metricsUrl = `${pathPrefix}/api/metrics?window=${metricsWindow}`;
return {
const metricsUrl = `/api/metrics?`;
const urlsForResource = {
// all deploys (default), or a given deploy if specified
"deployment": {
groupBy: "targetDeploy",
@ -119,4 +140,28 @@ export const urlsForResource = (pathPrefix, metricsWindow) => {
}
}
};
// prefix all links in the app with `pathPrefix`
const ConduitLink = props => {
let {to, absolute} = props;
if (absolute) {
return <Link to={to} target="_blank">{props.children}</Link>;
} else {
return <Link to={`${pathPrefix}${to}`}>{props.children}</Link>;
}
};
return {
fetch: apiFetch,
fetchMetrics,
fetchPods,
getMetricsWindow,
setMetricsWindow,
getValidMetricsWindows: () => _.keys(validMetricsWindows),
getMetricsWindowDisplayText,
urlsForResource: urlsForResource,
ConduitLink
};
};

View File

@ -1,3 +1,4 @@
import { ApiHelpers } from './components/util/ApiHelpers.jsx';
import DeploymentDetail from './components/DeploymentDetail.jsx';
import DeploymentsList from './components/DeploymentsList.jsx';
import { Layout } from 'antd';
@ -13,7 +14,7 @@ import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import './../css/styles.css';
let appMain = document.getElementById('main');
let appData = appMain.dataset;
let appData = !appMain ? {} : appMain.dataset;
let pathPrefix = "";
let proxyPathMatch = window.location.pathname.match(/\/api\/v1\/namespaces\/.*\/proxy/g);
@ -21,23 +22,25 @@ if (proxyPathMatch) {
pathPrefix = proxyPathMatch[0];
}
let api = ApiHelpers(pathPrefix);
ReactDOM.render((
<BrowserRouter>
<Layout>
<Layout.Sider width="310">
<Route render={routeProps => <Sidebar {...routeProps} goVersion={appData.goVersion} releaseVersion={appData.releaseVersion} pathPrefix={pathPrefix} uuid={appData.uuid} />} />
<Route render={routeProps => <Sidebar {...routeProps} goVersion={appData.goVersion} releaseVersion={appData.releaseVersion} api={api} pathPrefix={pathPrefix} uuid={appData.uuid} />} />
</Layout.Sider>
<Layout>
<Layout.Content style={{ margin: '0 0', padding: 0, background: '#fff' }}>
<div className="main-content">
<Switch>
<Redirect exact from={`${pathPrefix}/`} to={`${pathPrefix}/servicemesh`} />
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh pathPrefix={pathPrefix} releaseVersion={appData.releaseVersion} />} />
<Route path={`${pathPrefix}/deployments`} render={() => <DeploymentsList pathPrefix={pathPrefix} />} />
<Route path={`${pathPrefix}/deployment`} render={props => <DeploymentDetail pathPrefix={pathPrefix} location={props.location} />} />
<Route path={`${pathPrefix}/paths`} render={props => <Paths pathPrefix={pathPrefix} location={props.location} />} />
<Route path={`${pathPrefix}/pod`} render={props => <PodDetail pathPrefix={pathPrefix} location={props.location} />} />
<Route path={`${pathPrefix}/routes`} render={() => <Routes pathPrefix={pathPrefix} />} />
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh api={api} releaseVersion={appData.releaseVersion} />} />
<Route path={`${pathPrefix}/deployments`} render={() => <DeploymentsList api={api} />} />
<Route path={`${pathPrefix}/deployment`} render={props => <DeploymentDetail api={api} location={props.location} />} />
<Route path={`${pathPrefix}/paths`} render={props => <Paths api={api} location={props.location} />} />
<Route path={`${pathPrefix}/pod`} render={props => <PodDetail api={api} location={props.location} />} />
<Route path={`${pathPrefix}/routes`} render={() => <Routes />} />
<Route component={NoMatch} />
</Switch>
</div>

View File

@ -0,0 +1,183 @@
/* eslint-disable */
import 'raf/polyfill'; // the polyfill import must be first
import Adapter from 'enzyme-adapter-react-16';
import { ApiHelpers } from '../js/components/util/ApiHelpers.jsx';
import Enzyme from 'enzyme';
import { expect } from 'chai';
import { mount } from 'enzyme';
import { routerWrap } from './testHelpers.jsx';
import sinon from 'sinon';
import sinonStubPromise from 'sinon-stub-promise';
/* eslint-enable */
Enzyme.configure({ adapter: new Adapter() });
sinonStubPromise(sinon);
describe('ApiHelpers', () => {
let api, fetchStub;
beforeEach(() => {
fetchStub = sinon.stub(window, 'fetch');
fetchStub.returnsPromise().resolves({
ok: true,
json: () => Promise.resolve({ metrics: [] })
});
api = ApiHelpers("");
});
afterEach(() => {
api = null;
window.fetch.restore();
});
describe('getMetricsWindow/setMetricsWindow', () => {
it('sets a default metricsWindow', () => {
expect(api.getMetricsWindow()).to.equal('10m');
});
it('changes the metricsWindow on valid window input', () => {
expect(api.getMetricsWindow()).to.equal('10m');
api.setMetricsWindow('10s');
expect(api.getMetricsWindow()).to.equal('10s');
api.setMetricsWindow('1m');
expect(api.getMetricsWindow()).to.equal('1m');
api.setMetricsWindow('10m');
expect(api.getMetricsWindow()).to.equal('10m');
});
it('does not change metricsWindow on invalid window size', () => {
expect(api.getMetricsWindow()).to.equal('10m');
api.setMetricsWindow('10h');
expect(api.getMetricsWindow()).to.equal('10m');
});
});
describe('ConduitLink', () => {
it('wraps a relative link with the pathPrefix', () => {
api = ApiHelpers('/my/path/prefix');
let linkProps = { to: "/myrelpath", children: ["Informative Link Title"] };
let conduitLink = mount(routerWrap(api.ConduitLink, linkProps));
expect(conduitLink.find("Link")).to.have.length(1);
expect(conduitLink.html()).to.contain('href="/my/path/prefix/myrelpath"');
expect(conduitLink.html()).to.contain(linkProps.children[0]);
});
it('wraps a relative link with no pathPrefix', () => {
api = ApiHelpers('');
let linkProps = { to: "/myrelpath", children: ["Informative Link Title"] };
let conduitLink = mount(routerWrap(api.ConduitLink, linkProps));
expect(conduitLink.find("Link")).to.have.length(1);
expect(conduitLink.html()).to.contain('href="/myrelpath"');
expect(conduitLink.html()).to.contain(linkProps.children[0]);
});
it('leaves an absolute link unchanged', () => {
api = ApiHelpers('/my/path/prefix');
let linkProps = { absolute: "true", to: "http://xkcd.com", children: ["Best Webcomic"] };
let conduitLink = mount(routerWrap(api.ConduitLink, linkProps));
expect(conduitLink.find("Link")).to.have.length(1);
expect(conduitLink.html()).to.contain('href="http://xkcd.com"');
expect(conduitLink.html()).to.contain(linkProps.children[0]);
});
});
describe('fetch', () => {
it('adds pathPrefix to a metrics request', () => {
api = ApiHelpers('/the/path/prefix');
api.fetch('/resource/foo');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/the/path/prefix/resource/foo');
});
it('requests from / when there is no path prefix', () => {
api = ApiHelpers('');
api.fetch('/resource/foo');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/resource/foo');
});
it('throws an error if response status is not "ok"', () => {
let errorMessage = "do or do not. there is no try.";
fetchStub.returnsPromise().resolves({
ok: false,
statusText: errorMessage
});
api = ApiHelpers('');
let errorHandler = sinon.spy();
api.fetch('/resource/foo')
.catch(errorHandler);
expect(errorHandler.args[0][0].message).to.equal(errorMessage);
expect(errorHandler.calledOnce).to.be.true;
});
});
describe('fetchMetrics', () => {
it('adds pathPrefix and metricsWindow to a metrics request', () => {
api = ApiHelpers('/the/prefix');
api.fetchMetrics('/my/path');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/the/prefix/my/path?window=10m');
});
it('adds a ?window= if metricsWindow is the only param', () => {
api.fetchMetrics('/metrics');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/metrics?window=10m');
});
it('adds &window= if metricsWindow is not the only param', () => {
api.fetchMetrics('/metrics?foo=3&bar="me"');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/metrics?foo=3&bar="me"&window=10m');
});
it('does not add another &window= if there is already a window param', () => {
api.fetchMetrics('/metrics?foo=3&window=24h&bar="me"');
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/metrics?foo=3&window=24h&bar="me"');
});
});
describe('fetchPods', () => {
it('fetches the pods from the api', () => {
api = ApiHelpers("/random/prefix");
api.fetchPods();
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.args[0][0]).to.equal('/random/prefix/api/pods');
});
});
describe('urlsForResource', () => {
it('returns the correct timeseries and metric rollup urls for deployment overviews', () => {
api = ApiHelpers('/go/my/own/way');
let deploymentUrls = api.urlsForResource["deployment"].url("myDeploy");
expect(deploymentUrls.ts).to.equal('/api/metrics?&timeseries=true&target_deploy=myDeploy');
expect(deploymentUrls.rollup).to.equal('/api/metrics?&target_deploy=myDeploy');
});
it('returns the correct timeseries and metric rollup urls for upstream deployments', () => {
let deploymentUrls = api.urlsForResource["upstream_deployment"].url("farUp");
expect(deploymentUrls.ts).to.equal('/api/metrics?&aggregation=source_deploy&target_deploy=farUp&timeseries=true');
expect(deploymentUrls.rollup).to.equal('/api/metrics?&aggregation=source_deploy&target_deploy=farUp');
});
});
});

View File

@ -1,5 +1,6 @@
/* eslint-disable */
import 'raf/polyfill';
import 'raf/polyfill'; // the polyfill import must be first
import { ApiHelpers } from '../js/components/util/ApiHelpers.jsx';
import Adapter from 'enzyme-adapter-react-16';
import DeploymentDetail from '../js/components/DeploymentDetail.jsx';
import Enzyme from 'enzyme';

View File

@ -1,12 +1,16 @@
import _ from 'lodash';
import { ApiHelpers } from '../js/components/util/ApiHelpers.jsx';
import { createMemoryHistory } from 'history';
import React from 'react';
import { Route, Router } from 'react-router';
const componentDefaultProps = { api: ApiHelpers("") };
export function routerWrap(Component, extraProps={}, route="/", currentLoc="/") {
const createElement = (ComponentToWrap, props) => <ComponentToWrap {...(_.merge({}, componentDefaultProps, props, extraProps))} />;
return (
<Router history={createMemoryHistory(currentLoc)} createElement={(Component, props) => <Component {...(_.merge({}, props, extraProps))} />}>
<Route path={route} component={Component} />
<Router history={createMemoryHistory(currentLoc)} createElement={createElement}>
<Route path={route} render={props => createElement(Component, props)} />
</Router>
);
}