Add per namespace pages that show all resource stats for a namespace (#893)

Add namespaces as a top level resource in the Web UI

This PR does the following:
- Replace the deployments table in the service mesh page with namespaces
- Add a Namespaces index page that lists all namespaces and their stats
- Add an individual namespace page showing all resources for that namespace
- Make the incomplete mesh message more generic to any resource type
- Revamp rest of service mesh page to move off ListPods
This commit is contained in:
Risha Mars 2018-05-08 14:05:16 -07:00 committed by GitHub
parent 63fbbd6931
commit 1b0f269a43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 569 additions and 222 deletions

View File

@ -31,6 +31,36 @@
margin-top: 18px;
}
.conduit-table.mesh-completion-table {
& .ant-table-row {
& td {
& .container-bar {
&.neutral {
background-color: rgb(130,130,130, 0.1);;
}
&.poor{
background-color: rgb(235, 87, 87, 0.1);
}
&.good{
background-color: rgb(39, 174, 96, 0.1);
}
}
& .inner-bar {
&.neutral {
background-color: rgb(130,130,130, 0.8);;
}
&.poor{
background-color: rgb(235, 87, 87, 0.8);
}
&.good{
background-color: rgb(39, 174, 96, 0.8);
}
}
}
}
}
/* styles for the StatusTable */
td .status-dot {
float: left;

View File

@ -72,6 +72,10 @@ h2, h3, h4, h5, h6 {
float: right;
}
.page-section {
margin-bottom: calc(var(--base-width) * 3);
}
.subsection-header {
text-transform: uppercase;
font-size: 14px;

View File

@ -1,9 +1,12 @@
import _ from 'lodash';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import React from 'react';
import './../../css/cta.css';
export default class CallToAction extends React.Component {
render() {
let resource = this.props.resource || "resource";
return (
<div className="call-to-action">
<div className="action summary">The service mesh was successfully installed!</div>
@ -20,14 +23,14 @@ export default class CallToAction extends React.Component {
<div className="icon-container">
<i className="fa fa-check-circle" aria-hidden="true" />
</div>
<div className="message">{this.props.numDeployments || 0} deployments detected</div>
<div className="message">{_.isNil(this.props.numResources) ? "No" : this.props.numResources} {resource}s detected</div>
</div>
<div className="step-container incomplete">
<div className="icon-container">
<i className="fa fa-circle-o" aria-hidden="true" />
</div>
<div className="message">Connect your first deployment</div>
<div className="message">Connect your first {resource}</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
import _ from 'lodash';
import BaseTable from './BaseTable.jsx';
import GrafanaLink from './GrafanaLink.jsx';
import { metricToFormatter } from './util/Utils.js';
import React from 'react';
import { Tooltip } from 'antd';
import { metricToFormatter, numericSort } from './util/Utils.js';
/*
Table to display Success Rate, Requests and Latency in tabs.
@ -40,7 +40,7 @@ const formatTitle = (title, tooltipText) => {
};
const columnDefinitions = (sortable = true, resource, namespaces, onFilterClick, ConduitLink) => {
return [
let nsColumn = [
{
title: formatTitle("Namespace"),
key: "namespace",
@ -49,17 +49,29 @@ const columnDefinitions = (sortable = true, resource, namespaces, onFilterClick,
onFilterDropdownVisibleChange: onFilterClick,
onFilter: (value, row) => row.namespace.indexOf(value) === 0,
sorter: sortable ? (a, b) => (a.namespace || "").localeCompare(b.namespace) : false
},
}
];
let columns = [
{
title: formatTitle(resource),
key: "name",
defaultSortOrder: 'ascend',
sorter: sortable ? (a, b) => (a.name || "").localeCompare(b.name) : false,
render: row => row.added ? <GrafanaLink
name={row.name}
namespace={row.namespace}
resource={resource}
conduitLink={ConduitLink} /> : row.name
render: row => {
if (resource.toLowerCase() === "namespace") {
return <ConduitLink to={"/namespaces/" + row.name}>{row.name}</ConduitLink>;
} else if (!row.added) {
return row.name;
} else {
return (
<GrafanaLink
name={row.name}
namespace={row.namespace}
resource={resource}
conduitLink={ConduitLink} />
);
}
}
},
{
title: formatTitle("SR", "Success Rate"),
@ -102,9 +114,13 @@ const columnDefinitions = (sortable = true, resource, namespaces, onFilterClick,
render: metricToFormatter["LATENCY"]
}
];
};
const numericSort = (a, b) => (_.isNil(a) ? -1 : a) - (_.isNil(b) ? -1 : b);
if (resource.toLowerCase() === "namespace") {
return columns;
} else {
return _.concat(nsColumn, columns);
}
};
export default class MetricsTable extends BaseTable {
constructor(props) {

View File

@ -0,0 +1,125 @@
import _ from 'lodash';
import CallToAction from './CallToAction.jsx';
import ConduitSpinner from "./ConduitSpinner.jsx";
import ErrorBanner from './ErrorBanner.jsx';
import MetricsTable from './MetricsTable.jsx';
import PageHeader from './PageHeader.jsx';
import { processRollupMetrics } from './util/MetricUtils.js';
import React from 'react';
import './../../css/list.css';
import 'whatwg-fetch';
export default class Namespaces extends React.Component {
constructor(props) {
super(props);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
this.state = this.getInitialState(this.props.params);
}
getInitialState(params) {
let ns = _.get(params, "namespace", "default");
return {
ns: ns,
pollingInterval: 2000,
metrics: {},
pendingRequests: false,
loaded: false,
error: ''
};
}
componentDidMount() {
this.loadFromServer();
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
componentWillReceiveProps() {
// React won't unmount this component when switching resource pages so we need to clear state
this.api.cancelCurrentRequests();
this.setState(this.getInitialState(this.props.params));
}
componentWillUnmount() {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
}
loadFromServer() {
if (this.state.pendingRequests) {
return; // don't make more requests if the ones we sent haven't completed
}
this.setState({ pendingRequests: true });
this.api.setCurrentRequests(
_.map(["deployment", "replication_controller", "pod"], resource =>
this.api.fetchMetrics(this.api.urlsForResource[resource].url(this.state.ns).rollup))
);
Promise.all(this.api.getCurrentPromises())
.then(([deployRollup, rcRollup, podRollup]) => {
let includeConduitStats = this.state.ns === this.props.controllerNamespace; // allow us to get stats on the conduit ns
let deploys = processRollupMetrics(deployRollup, this.props.controllerNamespace, includeConduitStats);
let rcs = processRollupMetrics(rcRollup, this.props.controllerNamespace, includeConduitStats);
let pods = processRollupMetrics(podRollup, this.props.controllerNamespace, includeConduitStats);
this.setState({
metrics: {
deploy: deploys,
rc: rcs,
pod: pods
},
loaded: true,
pendingRequests: false,
error: ''
});
})
.catch(this.handleApiError);
}
handleApiError(e) {
if (e.isCanceled) {
return;
}
this.setState({
pendingRequests: false,
error: `Error getting data from server: ${e.message}`
});
}
renderResourceSection(friendlyTitle, metrics) {
if (_.isEmpty(metrics)) {
return null;
}
return (
<div className="page-section">
<h1>{friendlyTitle}s</h1>
<MetricsTable
resource={friendlyTitle}
metrics={metrics}
api={this.api} />
</div>
);
}
render() {
let noMetrics = _.isEmpty(this.state.metrics.pod);
return (
<div className="page-content">
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <ConduitSpinner /> :
<div>
<PageHeader header={"Namespace: " + this.state.ns} api={this.api} />
{ noMetrics ? <CallToAction /> : null}
{this.renderResourceSection("Deployment", this.state.metrics.deploy)}
{this.renderResourceSection("Replication Controller", this.state.metrics.rc)}
{this.renderResourceSection("Pod", this.state.metrics.pod)}
</div>
}
</div>);
}
}

View File

@ -79,10 +79,12 @@ export default class ResourceList extends React.Component {
});
}
renderEmptyMessage(resource) {
return this.props.resource === "deployment" ?
<CallToAction numDeployments={_.size(this.state.metrics)} /> :
<div>No {resource}s found</div>;
renderEmptyMessage() {
let shortResource = this.props.resource === "replication_controller" ?
"RC" : this.props.resource;
return (<CallToAction
resource={shortResource}
numResources={_.size(this.state.metrics)} />);
}
render() {

View File

@ -4,14 +4,12 @@ import ConduitSpinner from "./ConduitSpinner.jsx";
import ErrorBanner from './ErrorBanner.jsx';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import Metric from './Metric.jsx';
import { numericSort } from './util/Utils.js';
import PageHeader from './PageHeader.jsx';
import Percentage from './util/Percentage.js';
import React from 'react';
import StatusTable from './StatusTable.jsx';
import { Col, Row, Table } from 'antd';
import {
getComponentPods,
getPodsByDeployment,
} from './util/MetricUtils.js';
import { Col, Row, Table, Tooltip } from 'antd';
import './../../css/service-mesh.css';
const serviceMeshDetailsColumns = [
@ -27,6 +25,58 @@ const serviceMeshDetailsColumns = [
className: "numeric"
}
];
const barColor = percentMeshed => {
if (percentMeshed <= 0) {
return "neutral";
} else {
return "good";
}
};
const namespacesColumns = ConduitLink => [
{
title: "Namespace",
dataIndex: "namespace",
key: "namespace",
defaultSortOrder: "ascend",
sorter: (a, b) => (a.namespace || "").localeCompare(b.namespace),
render: d => <ConduitLink to={"/namespaces/" + d}>{d}</ConduitLink>
},
{
title: "Meshed pods",
dataIndex: "meshedPodsStr",
key: "meshedPodsStr",
className: "numeric",
sorter: (a, b) => numericSort(a.totalPods, b.totalPods),
},
{
title: "Mesh completion",
key: "meshification",
sorter: (a, b) => numericSort(a.meshedPercent.get(), b.meshedPercent.get()),
render: row => {
let containerWidth = 132;
let percent = row.meshedPercent.get();
let barWidth = percent < 0 ? 0 : Math.round(percent * containerWidth);
let barType = barColor(percent);
return (
<Tooltip
overlayStyle={{ fontSize: "12px" }}
title={<div>
<div>
{`${row.meshedPods} / ${row.totalPods} pods in mesh (${row.meshedPercent.prettyRate()})`}
</div>
</div>}>
<div className={"container-bar " + barType} style={{width: containerWidth}}>
<div className={"inner-bar " + barType} style={{width: barWidth}}>&nbsp;</div>
</div>
</Tooltip>
);
}
}
];
const componentNames = {
"prometheus": "Prometheus",
"destination": "Destination",
@ -55,7 +105,6 @@ export default class ServiceMesh extends React.Component {
this.state = {
pollingInterval: 2000,
metrics: [],
deploys: [],
components: [],
lastUpdated: 0,
pendingRequests: false,
@ -74,6 +123,45 @@ export default class ServiceMesh extends React.Component {
this.api.cancelCurrentRequests();
}
extractNsStatuses(nsData) {
let podsByNs = _.get(nsData, ["ok", "statTables", 0, "podGroup", "rows"], []);
let dataPlaneNamepaces = _.map(podsByNs, ns => {
if (ns.resource.name.indexOf("kube-") === 0) {
return;
}
let meshedPods = parseInt(ns.meshedPodCount, 10);
let totalPods = parseInt(ns.runningPodCount, 10);
return {
namespace: ns.resource.name,
meshedPodsStr: ns.meshedPodCount + "/" + ns.runningPodCount,
meshedPercent: new Percentage(meshedPods, totalPods),
meshedPods,
totalPods
};
});
return _.compact(dataPlaneNamepaces);
}
processComponents(conduitPods) {
let pods = _.get(conduitPods, ["ok", "statTables", 0, "podGroup", "rows"], 0);
return _.map(componentNames, (title, name) => {
let deployName = componentDeploys[name];
let matchingPods = _.filter(pods, p => p.resource.name.split("-")[0] === deployName);
return {
name: title,
pods: _.map(matchingPods, p => {
return {
name: p.resource.name,
// we need an endpoint to return the k8s status of these pods
value: _.size(matchingPods) > 0 ? "good" : "neutral"
};
})
};
});
}
loadFromServer() {
if (this.state.pendingRequests) {
return; // don't make more requests if the ones we sent haven't completed
@ -81,17 +169,15 @@ export default class ServiceMesh extends React.Component {
this.setState({ pendingRequests: true });
this.api.setCurrentRequests([
this.api.fetchPods()
this.api.fetchMetrics(this.api.urlsForResource["pod"].url(this.props.controllerNamespace).rollup),
this.api.fetchMetrics(this.api.urlsForResource["namespace"].url().rollup)
]);
this.serverPromise = Promise.all(this.api.getCurrentPromises())
.then(([pods]) => {
let podsByDeploy = getPodsByDeployment(pods.pods);
let controlPlanePods = this.processComponents(pods.pods);
.then(([conduitPods, nsStats]) => {
this.setState({
deploys: podsByDeploy,
components: controlPlanePods,
components: this.processComponents(conduitPods),
nsStatuses: this.extractNsStatuses(nsStats),
lastUpdated: Date.now(),
pendingRequests: false,
loaded: true,
@ -112,26 +198,14 @@ export default class ServiceMesh extends React.Component {
});
}
addedDeploymentCount() {
return _.size(_.filter(this.state.deploys, ["added", true]));
}
unaddedDeploymentCount() {
return this.deployCount() - this.addedDeploymentCount();
}
proxyCount() {
return _.sum(_.map(this.state.deploys, d => {
return _.size(_.filter(d.pods, ["value", "good"]));
}));
}
componentCount() {
return _.size(this.state.components);
}
deployCount() {
return _.size(this.state.deploys);
proxyCount() {
return _.sumBy(this.state.nsStatuses, d => {
return d.namespace === this.props.controllerNamespace ? 0 : d.meshedPods;
});
}
getServiceMeshDetails() {
@ -139,27 +213,10 @@ export default class ServiceMesh extends React.Component {
{ key: 1, name: "Conduit version", value: this.props.releaseVersion },
{ key: 2, name: "Conduit namespace", value: this.props.controllerNamespace },
{ key: 3, name: "Control plane components", value: this.componentCount() },
{ key: 4, name: "Added deployments", value: this.addedDeploymentCount() },
{ key: 5, name: "Unadded deployments", value: this.unaddedDeploymentCount() },
{ key: 6, name: "Data plane proxies", value: this.proxyCount() }
{ key: 4, name: "Data plane proxies", value: this.proxyCount() }
];
}
processComponents(pods) {
let podIndex = _(pods)
.filter(p => p.controlPlane)
.groupBy(p => _.last(_.split(p.deployment, "/")))
.value();
return _(componentNames)
.map((name, id) => {
let componentPods = _.get(podIndex, _.get(componentDeploys, id), []);
return { name: name, pods: getComponentPods(componentPods) };
})
.sortBy("name")
.value();
}
renderControlPlaneDetails() {
return (
<div className="mesh-section">
@ -177,24 +234,6 @@ export default class ServiceMesh extends React.Component {
);
}
renderDataPlaneDetails() {
return (
<div className="mesh-section">
<div className="clearfix header-with-metric">
<div className="subsection-header">Data plane</div>
<Metric title="Proxies" value={this.proxyCount()} className="metric-large" />
<Metric title="Deployments" value={this.deployCount()} className="metric-large" />
</div>
<StatusTable
data={this.state.deploys}
statusColumnTitle="Proxy Status"
shouldLink={true}
api={this.api} />
</div>
);
}
renderServiceMeshDetails() {
return (
<div className="mesh-section">
@ -214,61 +253,56 @@ export default class ServiceMesh extends React.Component {
);
}
renderAddDeploymentsMessage() {
if (this.deployCount() === 0) {
renderAddResourcesMessage() {
if (_.isEmpty(this.state.nsStatuses)) {
return <div className="mesh-completion-message">No resources detected.</div>;
}
let meshedCount = _.countBy(this.state.nsStatuses, pod => {
return pod.meshedPercent.get() > 0;
});
let numUnadded = meshedCount["false"] || 0;
if (numUnadded === 0) {
return (
<div className="mesh-completion-message">
No deployments detected. {incompleteMeshMessage()}
All namespaces have a conduit install.
</div>
);
} else {
switch (this.unaddedDeploymentCount()) {
case 0:
return (
<div className="mesh-completion-message">
All deployments have been added to the service mesh.
</div>
);
case 1:
return (
<div className="mesh-completion-message">
1 deployment has not been added to the service mesh. {incompleteMeshMessage()}
</div>
);
default:
return (
<div className="mesh-completion-message">
{this.unaddedDeploymentCount()} deployments have not been added to the service mesh. {incompleteMeshMessage()}
</div>
);
}
return (
<div className="mesh-completion-message">
{numUnadded} {numUnadded === 1 ? "namespace has" : "namespaces have"} no meshed resources.
{incompleteMeshMessage()}
</div>
);
}
}
renderControlPlane() {
return (
<Row gutter={16}>
<Col span={16}>{this.renderControlPlaneDetails()}</Col>
<Col span={8}>{this.renderServiceMeshDetails()}</Col>
</Row>
);
}
renderNamespaceStatusTable() {
let rowCn = row => {
return row.meshedPercent.get() > 0.9 ? "good" : "neutral";
};
renderDataPlane() {
return (
<Row gutter={16}>
<Col span={16}>{this.renderDataPlaneDetails()}</Col>
<Col span={8}>{this.renderAddDeploymentsMessage()}</Col>
</Row>
<div className="mesh-section">
<Row gutter={16}>
<Col span={16}>
<Table
className="conduit-table service-mesh-table mesh-completion-table"
dataSource={this.state.nsStatuses}
columns={namespacesColumns(this.api.ConduitLink)}
rowKey="namespace"
rowClassName={rowCn}
pagination={false}
size="middle" />
</Col>
<Col span={8}>{this.renderAddResourcesMessage()}</Col>
</Row>
</div>
);
}
renderOverview() {
if (this.proxyCount() === 0) {
return <CallToAction numDeployments={this.deployCount()} />;
}
}
render() {
return (
<div className="page-content">
@ -279,9 +313,18 @@ export default class ServiceMesh extends React.Component {
header="Service mesh overview"
hideButtons={this.proxyCount() === 0}
api={this.api} />
{this.renderOverview()}
{this.renderControlPlane()}
{this.renderDataPlane()}
{this.proxyCount() === 0 ?
<CallToAction
numResources={_.size(this.state.nsStatuses)}
resource="namespace" /> : null}
<Row gutter={16}>
<Col span={16}>{this.renderControlPlaneDetails()}</Col>
<Col span={8}>{this.renderServiceMeshDetails()}</Col>
</Row>
{this.renderNamespaceStatusTable()}
</div>
}
</div>

View File

@ -96,6 +96,11 @@ export default class Sidebar extends React.Component {
<span><ConduitLink to="/servicemesh">Service mesh</ConduitLink></span>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/namespaces">
<Icon><ConduitLink to="/namespaces"><Icon>N</Icon></ConduitLink></Icon>
<span><ConduitLink to="/namespaces">Namespaces</ConduitLink></span>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/deployments">
<Icon><ConduitLink to="/deployments"><Icon>D</Icon></ConduitLink></Icon>
<span><ConduitLink to="/deployments">Deployments</ConduitLink></span>

View File

@ -1,5 +1,4 @@
import _ from 'lodash';
import GrafanaLink from './GrafanaLink.jsx';
import React from 'react';
import { Table, Tooltip } from 'antd';
@ -40,21 +39,10 @@ const StatusDot = ({status, multilineDots, columnName}) => (
);
const columns = {
resourceName: (shouldLink, ConduitLink) => {
return {
title: "Deployment",
key: "name",
render: row => {
let ownerInfo = row.name.split("/");
return shouldLink && row.added ?
<GrafanaLink
name={ownerInfo[1]}
namespace={ownerInfo[0]}
displayName={row.name}
resource="deployment"
conduitLink={ConduitLink} /> : row.name;
}
};
resourceName: {
title: "Deployment",
dataIndex: "name",
key: "name"
},
pods: {
title: "Pods",
@ -95,7 +83,7 @@ export default class StatusTable extends React.Component {
render() {
let tableCols = [
columns.resourceName(this.props.shouldLink, this.props.api.ConduitLink),
columns.resourceName,
columns.pods,
columns.status(this.props.statusColumnTitle)
];

View File

@ -79,27 +79,27 @@ export const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
metricsWindow = window;
};
const genResourceUrl = type => {
return namespace => {
let baseUrl = '/api/stat?resource_type=' + type;
return {
rollup: !namespace ? baseUrl : baseUrl + '&namespace=' + namespace
};
};
};
const urlsForResource = {
"namespace": {
url: genResourceUrl("namespace")
},
"replication_controller": {
url: () => {
return {
rollup: `/api/stat?resource_type=replicationcontroller`
};
}
url: genResourceUrl("replicationcontroller")
},
"deployment": {
url: () => {
return {
rollup: `/api/stat?resource_type=deployment`
};
}
url: genResourceUrl("deployment")
},
"pod": {
url: () => {
return {
rollup: `/api/stat?resource_type=pod`
};
}
url: genResourceUrl("pod")
}
};

View File

@ -1,18 +1,18 @@
import React from 'react';
/*
* Instructions for adding deployments to service mesh
* Instructions for adding resources to service mesh
*/
export const incompleteMeshMessage = name => {
if (name) {
return (
<div className="action">Add {name} to the deployment.yml file<br /><br />
Then run <code>conduit inject deployment.yml | kubectl apply -f -</code> to add it to the service mesh</div>
<div className="action">Add {name} to the k8s.yml file<br /><br />
Then run <code>conduit inject k8s.yml | kubectl apply -f -</code> to add it to the service mesh</div>
);
} else {
return (
<div className="action">Add one or more deployments to the deployment.yml file<br /><br />
Then run <code>conduit inject deployment.yml | kubectl apply -f -</code> to add them to the service mesh</div>
<div className="action">Add one or more resources to the k8s.yml file<br /><br />
Then run <code>conduit inject k8s.yml | kubectl apply -f -</code> to add them to the service mesh</div>
);
}
};

View File

@ -94,7 +94,7 @@ export const getComponentPods = componentPods => {
const kubernetesNs = "kube-system";
const defaultControllerNs = "conduit";
export const processRollupMetrics = (rawMetrics, controllerNamespace) => {
export const processRollupMetrics = (rawMetrics, controllerNamespace, includeConduit) => {
if (_.isEmpty(rawMetrics.ok) || _.isEmpty(rawMetrics.ok.statTables)) {
return [];
}
@ -103,7 +103,10 @@ export const processRollupMetrics = (rawMetrics, controllerNamespace) => {
}
let metrics = _.flatMap(rawMetrics.ok.statTables, table => {
return _.map(table.podGroup.rows, row => {
if (row.resource.namespace === kubernetesNs || row.resource.namespace === controllerNamespace) {
if (row.resource.namespace === kubernetesNs) {
return null;
}
if (row.resource.namespace === controllerNamespace && !includeConduit) {
return null;
}

View File

@ -13,6 +13,6 @@ Percentage.prototype.prettyRate = function() {
if (this.decimal < 0) {
return "N/A";
} else {
return (100*this.decimal).toFixed(3) + "%";
return (100*this.decimal).toFixed(1) + "%";
}
};

View File

@ -86,3 +86,8 @@ export const toClassName = name => {
if (!name) return "";
return _.lowerCase(name).replace(/[^a-zA-Z0-9]/g, "_");
};
/*
Definition of sort, for ant table sorting
*/
export const numericSort = (a, b) => (_.isNil(a) ? -1 : a) - (_.isNil(b) ? -1 : b);

View File

@ -1,5 +1,6 @@
import { ApiHelpers } from './components/util/ApiHelpers.jsx';
import { Layout } from 'antd';
import Namespace from './components/Namespace.jsx';
import NoMatch from './components/NoMatch.jsx';
import React from 'react';
import ReactDOM from 'react-dom';
@ -18,6 +19,8 @@ if (proxyPathMatch) {
pathPrefix = proxyPathMatch[0];
}
let controllerNs = appData.controllerNamespace || "conduit";
let api = ApiHelpers(pathPrefix);
let applicationHtml = (
@ -36,10 +39,12 @@ let applicationHtml = (
<div className="main-content">
<Switch>
<Redirect exact from={`${pathPrefix}/`} to={`${pathPrefix}/servicemesh`} />
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh api={api} releaseVersion={appData.releaseVersion} controllerNamespace={appData.controllerNamespace} />} />
<Route path={`${pathPrefix}/deployments`} render={() => <ResourceList resource="deployment" api={api} controllerNamespace={appData.controllerNamespace} />} />
<Route path={`${pathPrefix}/replicationcontrollers`} render={() => <ResourceList resource="replication_controller" api={api} controllerNamespace={appData.controllerNamespace} />} />
<Route path={`${pathPrefix}/pods`} render={() => <ResourceList resource="pod" api={api} controllerNamespace={appData.controllerNamespace} />} />
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh api={api} releaseVersion={appData.releaseVersion} controllerNamespace={controllerNs} />} />
<Route path={`${pathPrefix}/namespaces/:namespace`} render={props => <Namespace resource="namespace" api={api} controllerNamespace={controllerNs} params={props.match.params} />} />
<Route path={`${pathPrefix}/namespaces`} render={() => <ResourceList resource="namespace" api={api} controllerNamespace={controllerNs} />} />
<Route path={`${pathPrefix}/deployments`} render={() => <ResourceList resource="deployment" api={api} controllerNamespace={controllerNs} />} />
<Route path={`${pathPrefix}/replicationcontrollers`} render={() => <ResourceList resource="replication_controller" api={api} controllerNamespace={controllerNs} />} />
<Route path={`${pathPrefix}/pods`} render={() => <ResourceList resource="pod" api={api} controllerNamespace={controllerNs} />} />
<Route component={NoMatch} />
</Switch>
</div>

View File

@ -1,9 +1,10 @@
import _ from 'lodash';
import Adapter from 'enzyme-adapter-react-16';
import conduitPodFixtures from './fixtures/conduitPods.json';
import Enzyme from 'enzyme';
import { expect } from 'chai';
import { mount } from 'enzyme';
import podFixtures from './fixtures/pods.json';
import nsFixtures from './fixtures/namespaces.json';
import { routerWrap } from './testHelpers.jsx';
import ServiceMesh from '../js/components/ServiceMesh.jsx';
import sinon from 'sinon';
@ -66,12 +67,9 @@ describe('ServiceMesh', () => {
});
it("renders controller component summaries", () => {
let addedPods = _.cloneDeep(podFixtures.pods);
_.set(addedPods[1], "added", true);
fetchStub.resolves({
ok: true,
json: () => Promise.resolve({ pods: addedPods})
json: () => Promise.resolve(conduitPodFixtures)
});
component = mount(routerWrap(ServiceMesh));
@ -129,59 +127,71 @@ describe('ServiceMesh', () => {
});
describe("renderAddDeploymentsMessage", () => {
it("displays when no deployments are in the mesh", () => {
it("displays when no resources are in the mesh", () => {
fetchStub.resolves({
ok: true,
json: () => Promise.resolve({ pods: []})
json: () => Promise.resolve({})
});
component = mount(routerWrap(ServiceMesh));
return withPromise(() => {
expect(component.html()).to.include("No deployments detected.");
expect(component.html()).to.include("No resources detected");
});
});
it("displays a message if >1 deployment has not been added to the mesh", () => {
it("displays a message if >1 resource has not been added to the mesh", () => {
let nsAllResourcesAdded = _.cloneDeep(nsFixtures);
nsAllResourcesAdded.ok.statTables[0].podGroup.rows.push({
"resource":{
"namespace":"",
"type":"namespaces",
"name":"test-1"
},
"timeWindow": "1m",
"meshedPodCount": "0",
"runningPodCount": "5",
"stats": null
});
fetchStub.resolves({
ok: true,
json: () => Promise.resolve({ pods: podFixtures.pods})
json: () => Promise.resolve(nsAllResourcesAdded)
});
component = mount(routerWrap(ServiceMesh));
return withPromise(() => {
expect(component.html()).to.include("deployments have not been added to the service mesh.");
expect(component.html()).to.include("2 namespaces have no meshed resources.");
});
});
it("displays a message if 1 deployment has not added to servicemesh", () => {
let addedPods = _.cloneDeep(podFixtures.pods);
_.set(addedPods[0], "added", true);
it("displays a message if 1 resource has not added to servicemesh", () => {
fetchStub.resolves({
ok: true,
json: () => Promise.resolve({ pods: addedPods})
json: () => Promise.resolve(nsFixtures)
});
component = mount(routerWrap(ServiceMesh));
return withPromise(() => {
expect(component.html()).to.include("1 deployment has not been added to the service mesh.");
expect(component.html()).to.include("1 namespace has no meshed resources.");
});
});
it("displays a message if all deployments have been added to servicemesh", () => {
let addedPods = _.cloneDeep(podFixtures.pods);
_.forEach(addedPods, pod => {
_.set(pod, "added", true);
it("displays a message if all resource have been added to servicemesh", () => {
let nsAllResourcesAdded = _.cloneDeep(nsFixtures);
_.each(nsAllResourcesAdded.ok.statTables[0].podGroup.rows, row => {
if (row.resource.name === "default") {
row.meshedPodCount = "10";
row.runningPodCount = "10";
}
});
fetchStub.resolves({
ok: true,
json: () => Promise.resolve({ pods: addedPods})
json: () => Promise.resolve(nsAllResourcesAdded)
});
component = mount(routerWrap(ServiceMesh));
return withPromise(() => {
expect(component.html()).to.include("All deployments have been added to the service mesh.");
expect(component.html()).to.include("All namespaces have a conduit install.");
});
});
});

68
web/app/test/fixtures/conduitPods.json vendored Normal file
View File

@ -0,0 +1,68 @@
{
"ok": {
"statTables": [
{
"podGroup": {
"rows": [
{
"resource": {
"namespace": "conduit",
"type": "pods",
"name": "web-5f97578cf6-597sr"
},
"timeWindow": "1m",
"meshedPodCount": "4",
"runningPodCount": "4",
"stats": {
"successCount": "6",
"failureCount": "0",
"latencyMsP50": "5",
"latencyMsP95": "9",
"latencyMsP99": "10"
}
},
{
"resource": {
"namespace": "conduit",
"type": "pods",
"name": "controller-795dd8df6-tjdt6"
},
"timeWindow": "1m",
"meshedPodCount": "4",
"runningPodCount": "4",
"stats": {
"successCount": "24",
"failureCount": "0",
"latencyMsP50": "7",
"latencyMsP95": "18",
"latencyMsP99": "20"
}
},
{
"resource": {
"namespace": "conduit",
"type": "pods",
"name": "prometheus-595785446-r7rlq"
},
"timeWindow": "1m",
"meshedPodCount": "4",
"runningPodCount": "4",
"stats": null
},
{
"resource": {
"namespace": "conduit",
"type": "pods",
"name": "grafana-78cdb656bf-v8lsj"
},
"timeWindow": "1m",
"meshedPodCount": "4",
"runningPodCount": "4",
"stats": null
}
]
}
}
]
}
}

View File

@ -19,7 +19,7 @@
"successCount": "56"
},
"timeWindow": "1m",
"totalPodCount": "1"
"runningPodCount": "1"
},
{
"meshedPodCount": "1",
@ -36,7 +36,7 @@
"successCount": "124"
},
"timeWindow": "1m",
"totalPodCount": "1"
"runningPodCount": "1"
},
{
"meshedPodCount": "1",
@ -53,7 +53,7 @@
"successCount": "6"
},
"timeWindow": "1m",
"totalPodCount": "1"
"runningPodCount": "1"
},
{
"meshedPodCount": "1",
@ -70,7 +70,7 @@
"successCount": "113"
},
"timeWindow": "1m",
"totalPodCount": "1"
"runningPodCount": "1"
}
]
}

62
web/app/test/fixtures/namespaces.json vendored Normal file
View File

@ -0,0 +1,62 @@
{
"ok": {
"statTables": [
{
"podGroup": {
"rows": [
{
"resource": {
"namespace": "",
"type": "namespaces",
"name": "conduit"
},
"timeWindow": "1m",
"meshedPodCount": "4",
"runningPodCount": "4",
"stats": {
"successCount": "30",
"failureCount": "0",
"latencyMsP50": "5",
"latencyMsP95": "10",
"latencyMsP99": "10"
}
},
{
"resource": {
"namespace": "",
"type": "namespaces",
"name": "kube-system"
},
"timeWindow": "1m",
"meshedPodCount": "0",
"runningPodCount": "9",
"stats": null
},
{
"resource": {
"namespace": "",
"type": "namespaces",
"name": "default"
},
"timeWindow": "1m",
"meshedPodCount": "0",
"runningPodCount": "0",
"stats": null
},
{
"resource": {
"namespace": "",
"type": "namespaces",
"name": "kube-public"
},
"timeWindow": "1m",
"meshedPodCount": "0",
"runningPodCount": "0",
"stats": null
}
]
}
}
]
}
}

View File

@ -1,24 +0,0 @@
{
"pods": [
{
"name": "test/gen2-2742169793-6rkz8",
"podIP": "10.196.1.205",
"deployment": "test/gen2",
"status": "Failed",
"added": false,
"sinceLastReport": null,
"controllerNamespace": "",
"controlPlane": false
},
{
"name": "test/gen3-689289393-xz7xg",
"podIP": "10.196.1.206",
"deployment": "test/gen3",
"status": "Running",
"added": false,
"sinceLastReport": null,
"controllerNamespace": "",
"controlPlane": false
}
]
}

View File

@ -83,6 +83,8 @@ func NewServer(addr, templateDir, staticDir, uuid, controllerNamespace, webpackD
// webapp routes
server.router.GET("/", handler.handleIndex)
server.router.GET("/servicemesh", handler.handleIndex)
server.router.GET("/namespaces", handler.handleIndex)
server.router.GET("/namespaces/:namespace", handler.handleIndex)
server.router.GET("/deployments", handler.handleIndex)
server.router.GET("/replicationcontrollers", handler.handleIndex)
server.router.GET("/pods", handler.handleIndex)