Move the dashboard's component library from antd to material-ui (#1776)

Switch the dashboard's component library from antd to material-ui.

There are extensive changes to most of the frontend components in the app.

This branch changes all uses of antd components to their closest equivalent in
material. There is still a lot of polish that needs to go into the look of
individual components, but since the major component rewrites are done, I think
get this work in so that further work can be done in smaller branches.

Changes in this branch:

- add Material-UI 3.2.2 to the project
- replace all uses of antd with material-ui components
- remove antd from the project
- slight modifications of eslint rules 
- restructuring of app components to be rendered under the Navigation 
component 
- deleted most of our css (replaced with material's inline styles) 
- pinned package versions in package.json (mostly removing ^)
This commit is contained in:
Risha Mars 2018-10-19 11:23:43 -07:00 committed by GitHub
parent f549868033
commit e69da1b8a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 3280 additions and 4192 deletions

View File

@ -1,8 +1,5 @@
{
"presets":[
"env", "react-app"
],
"plugins": [
["import", { "libraryName": "antd", "style": true }]
]
"presets": [
"env", "react-app"
]
}

View File

@ -54,10 +54,11 @@
"arrow": "parens-new-line"
}
],
"react/no-did-update-set-state": [0],
"react/prefer-stateless-function": 0, // TODO: convert to stateless functions and enable
"react/sort-prop-types": 2,
"semi": [2, "always"],
"sort-imports": [2, { "ignoreCase": true, "memberSyntaxSortOrder": ["single", "multiple", "all", "none"] }],
"sort-imports": [2],
"space-before-blocks": [2, "always"],
"space-before-function-paren": [2, "never"],
"strict": [2, "safe"]

View File

@ -1,21 +0,0 @@
@import 'styles.css';
.ant-layout-header {
background-color: var(--green);
height: 64px;
z-index: 100;
position: fixed;
width: 100vw;
padding:16px 40px;
& span.ant-breadcrumb-link, & span.ant-breadcrumb-separator, & :first-child {
color: rgb(0, 0, 0, 0.85);
font-size: 21px;
font-weight: var(--font-weight-bold);
line-height: 32px;
}
}
.ant-layout-header:last-child {
color: var(--white);
}

View File

@ -1,72 +0,0 @@
@import 'styles.css';
.call-to-action {
padding: 16px 0;
& code {
background-color: rgba(47, 128, 237, 1);
color: white;
font-size: .9rem;
letter-spacing: 0px;
padding: 4px 8px;
border-radius: 4px;
}
& .summary {
color: var(--green);
}
& .action-steps div {
line-height: 41px;
margin-bottom: 8px;
max-width: 272px;
height: 41px;
@media screen and (min-width: 1120px) {
float: left;
}
}
& .step-container {
border-radius: calc(var(--base-width)*4);
margin-right: var(--base-width);
width: 98%
}
& .step-container > div {
float: left;
text-align: center;
}
& .icon-container {
width: 37px;
border-radius: 0px;
padding-left: var(--base-width);
}
& .step-container .fa {
font-size: 2em;
line-height: 41px;
color: #fff;
}
& .action-steps {
font-size: 14px;
font-weight: var(--font-weight-bold);
& .message {
color: white;
padding-left: 8px;
}
& .complete {
color: var(--green);
background-color: var(--green);
}
& .incomplete {
color: #828282;
background-color: #828282;
}
}
}

View File

@ -1,67 +0,0 @@
@import 'styles.css';
.octopus-graph {
max-width: 940px;
padding: calc(var(--base-width) * 2);
margin-left: auto;
margin-right: auto;
background-color: #FAFAFA;
& .octopus-body {
background-color: white;
border: 1px solid #F2F2F2;
padding: var(--base-width);
}
& .octopus-col {
text-align: center;
& .neighbor {
height: 200px;
margin-top: 5px;
margin-bottom: 5px;
}
}
& .octopus-title {
padding: 8px 0;
&.main-title {
font-size: 24px;
font-weight: var(--font-weight-extra-bold);
}
&.neighbor-title {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
font-size: 16px;
font-weight: var(--font-weight-bold);
}
}
& .octopus-sr-gauge {
padding-top: calc(var(--base-width) * 2);
& .ant-progress-text {
color: black;
}
}
& .octopus-metric {
line-height: 32px;
&.status-good {
color: var(--green);
}
&.status-poor {
color: var(--red);
}
&.status-neutral {
color: var(--graphgrey);
}
&.status-ok {
color: var(--orange);
}
}
}

View File

@ -1,110 +0,0 @@
@import 'styles.css';
.header-with-metric {
margin-top: 32px;
& .subsection-header {
float: left;
}
& .metric {
float: right;
text-align: right;
}
}
.mesh-section .metric-large {
width: 131px;
}
.mesh-completion-message {
margin-top: calc(4 * var(--base-width));
background-color: var(--royalblue);
color: white;
padding: var(--base-width);
font-size: 16;
border-radius: calc(var(--base-width)/2);
font-weight: var(--font-weight-extra-bold);
& code {
color: #2f80ed;
background-color: white;
padding: 0.2em;
}
}
.service-mesh-table {
margin-top: 18px;
}
.metric-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: rgba(255, 77, 43, 0.8);
}
&.good{
background-color: rgba(38, 233, 157, 0.8);
}
}
}
}
}
/* styles for the StatusTable */
td div.status-dot {
float: left;
margin-right: var(--base-width);
&.dot-multiline {
margin-top: calc(0.5 * var(--base-width));
margin-bottom: calc(0.5 * var(--base-width));
}
}
/* error indicator and modal */
.controller-error-icon {
cursor: pointer;
margin-left: 5px;
color: var(--red);
}
.controller-init-icon {
margin-left: 5px;
}
.controller-pod-error {
margin-bottom: calc(3 * var(--base-width));
& .container-error {
margin-bottom: calc(3 * var(--base-width));
}
& p {
line-height: 8px;
}
& .error-text {
padding: var(--base-width);
margin-top: var(--base-width);
border-radius: calc(0.5 * var(--base-width));
font-size: 12px;
color: white;
background-color: #696969;
}
}

View File

@ -1,133 +0,0 @@
@import 'styles.css';
ul.ant-menu.ant-menu-sub {
background: var(--sider-black);
}
.sidebar {
background: var(--header-black);
color: white;
overflow-x: hidden;
overflow-y: scroll;
height: 100%;
position: relative;
&::-webkit-scrollbar {
display: none;
}
& .ant-menu-submenu {
& .ant-menu-submenu-title, & .anticon {
/* override ant transition animation */
transition: none;
font-size: 16px;
}
}
& .sidebar-menu {
background: none;
}
& .sidebar-menu-item, & .sidebar-title {
font-size: 16px;
font-weight: var(--font-weight-bold);
color: white;
& a {
color: white;
}
& .update {
color: var(--red);
}
& .ant-select-selection {
width: 220px;
}
}
& .sidebar-submenu-item {
font-size: 14px;
font-weight: 500;
& a {
color: var(--white);
}
}
& img {
height: 36px;
}
& .ant-menu-item {
& div {
& a {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
width: 160px;
}
& span.ant-badge-status {
padding-left: 26px;
vertical-align: top;
}
}
}
& .ant-menu-item:hover {
background-color: #007EFF;
}
& .ant-menu-item-active, & .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background: #007EFF;
& a {
text-decoration: none;
}
}
& .sidebar-menu-header {
height: 60px;
padding: var(--base-width) 14px;
&.collapsed {
text-align: center;
}
}
& .version {
padding: 0 0 0 16px;
font-size: 13px;
font-weight: var(--font-weight-bold);
line-height: 2;
min-height: 56px;
}
& .ant-badge-status-success {
background-color: var(--green);
}
& .ant-badge-status-error {
background-color: var(--red);
}
}
.ant-layout-sider {
/* override ant color */
background: var(--header-black);
overflow: hidden;
}
.ant-layout .ant-layout-sider .ant-layout-sider-trigger {
/* override ant color */
background: var(--sider-black);
}
/* Hack to hide the scrollbar for firefox browsers */
@-moz-document url-prefix() {
.ant-layout-sider-children {
margin-right: -15px;
}
}

View File

@ -1,10 +1,13 @@
@import url('https://use.fontawesome.com/releases/v5.3.1/css/all.css');
/* material-ui uses Roboto */
@import url("https://fonts.googleapis.com/css?family=Montserrat:400,700|Roboto:100,300,400");
:root {
--white: #fff;
--header-black: #1e2322;
--sider-black: #343838;
--royalblue: #2F80ED;
--linkblue: #2196f3;
--darkblue: #071E3C;
--curiousblue: #2D9CDB;
--pictonblue: #56CCF2;
@ -19,264 +22,48 @@
--latency-p99: var(--royalblue);
--latency-p95: var(--curiousblue);
--latency-p50: var(--pictonblue);
--font-stack: 'Lato', helvetica, arial, sans-serif;
--font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-bold: 700;
--font-weight-extra-bold: 900;
--font-stack: 'Roboto', 'Lato', helvetica, arial, sans-serif;
--base-width: 8px;
}
.hide {
display: none;
}
body {
font-size: 100%;
font-weight: var(--font-weight-regular);
font-family: var(--font-stack);
line-height: 1.4;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 24px;
font-weight: var(--font-weight-extra-bold);
}
h2, h3, h4, h5, h6 {
font-weight: var(--font-weight-bold);
}
.clearfix {
clear: both;
}
.main {
margin-left: auto;
margin-right: auto;
}
.main-content {
max-width: 100%;
width: 100%;
padding: 40px;
float: left;
}
.page-header {
margin-bottom: 30px;
}
.page-header .subsection-header {
margin-bottom: 0px;
}
.time-window-btns {
float: right;
}
/* center the spinner */
.page-content .ant-spin {
display: flex;
align-items: center;
justify-content: center;
height: 75vh;
&.short-spin-section {
height: 100px;
}
}
.page-section {
margin-bottom: calc(var(--base-width) * 3);
}
.subsection-header {
text-transform: uppercase;
font-size: 14px;
font-weight: var(--font-weight-extra-bold);
margin-bottom: 15px;
}
.subsection-header.border-container {
height: 48px; /* override height */
}
.subsection-header.border-container-content {
height: 32px; /* override height */
}
.summary-title {
font-size: 14px;
font-weight: var(--font-weight-extra-bold);
}
.summary-info {
font-size: 12px;
color: var(--neutralgrey);
}
.metric-title {
font-size: 12px;
font-weight: var(--font-weight-regular);
}
.metric-value {
font-size: 16px;
font-weight: var(--font-weight-extra-bold);
}
.metric.metric-large {
& .metric-title {
font-size: 14px;
font-weight: var(--font-weight-extra-bold);
}
& .metric-value {
font-size: 24px;
}
}
.no-data-msg {
font-size: 12px;
color: var(--neutralgrey);
}
.action {
font-size: 14px;
font-weight: var(--font-weight-bold);
padding: 8px 0;
}
.error-message-container {
overflow: auto;
width: 100%;
padding: var(--base-width);
margin-bottom: calc(3 * var(--base-width));
border-radius: 5px;
background: var(--red);
color: white;
font-size: 12px;
font-weight: var(--font-weight-bold);
& .dismiss {
float: right;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
}
a.button {
color: var(--royalblue);
background: #fff;
border-radius: 48px;
border: 1px solid var(--royalblue);
padding: 10px 14px 11px 14px;
font-weight: var(--font-weight-bold);
font-size: 14px;
margin: 0px 8px 8px 0px;
position: relative;
line-height: 4;
}
a.button:hover {
top: 1px;
background-color: #efefef;
a {
text-decoration: none;
}
a.button:active {
background-color: var(--royalblue);
color: #fff;
}
a.button.primary {
color: #fff;
background-color: var(--royalblue);
}
a.button.primary:hover {
top: 1px;
background-color: #2875DE;
text-decoration: none;
}
a.button.primary:active {
background-color: #2469C7;
}
color: var(--linkblue);
/*
Ant overrides
*/
.ant-menu, .ant-table, .ant-tabs {
font-family: var(--font-stack);
&:focus, &:hover, &:visited, &:link, &:active {
text-decoration: none;
}
}
.metric-table {
& .ant-table-content {
font-size: 12px;
}
& thead > tr > th {
background: var(--white);
text-transform: uppercase;
font-weight: var(--font-weight-bold);
border-bottom: 1px solid;
border-color: var(--silver);
word-break: keep-all;
&.ant-table-column-sort {
background: none;
}
}
& .ant-table table {
font-weight: bold;
font-size: .8rem;
}
& tr > th, & tr > td {
padding-left: 0;
padding-right: 0;
}
& .ant-table-tbody > tr:hover > td {
background: rgba(47, 128, 237, .1);
}
& tr .numeric {
text-align: right;
}
& .metric-table-sr {
margin-right: 6px;
}
& .metric-table-sr-chart.ant-progress-circle {
/* override ant progress circle height */
& div.ant-progress-inner {
max-height: 16px;
}
}
}
/* ant Progress bar color orverrides */
.success-rate-arc {
&.status-good .ant-progress-circle-path {
stroke: var(--green);
}
&.status-ok .ant-progress-circle-path {
stroke: var(--orange)
}
&.status-poor .ant-progress-circle-path {
stroke: var(--red);
padding-right: 6px;
}
}
/* Colored dot for indicating statuses */
div.status-dot {
div.status-table-dot {
width: calc(2 * var(--base-width));
height: calc(2 * var(--base-width));
min-width: calc(2 * var(--base-width));
border-radius: 50%;
}
div.success-rate-dot {
width: calc(1 * var(--base-width));
height: calc(1 * var(--base-width));
min-width: calc(1 * var(--base-width));
border-radius: 50%;
}
.status-dot {
&.status-dot-good {
background-color: var(--green);
}
@ -284,18 +71,23 @@ div.status-dot {
background-color: var(--red);
}
&.status-dot-neutral {
background-color: #E0E0E0;
}
&.status-dot-ok {
background-color: var(--orange);
}
&.status-dot-default {
background-color: #E0E0E0;
}
}
.anticon-check-circle.status-ok {
color: var(--green);
.breadcrumb-link a {
color: white;
}
div.ant-layout-has-sider {
height: 100vh;
overflow: hidden;
.version {
max-width: 250px;
padding: 24px;
font-size: 12px;
& .new-version-text {
margin-bottom: 12px;
}
}

View File

@ -1,42 +0,0 @@
@import 'styles.css';
.tap-form {
& button.ant-btn-primary {
&.tap-ctrl {
margin-right: var(--base-width);
}
&.tap-start {
background-color: var(--green);
border-color: var(--green);
}
&.tap-stop {
background-color: var(--red);
border-color: var(--red);
}
}
& button.tap-form-toggle {
padding-left: 0;
border: none;
color: rgba(52, 137, 206, 0.906);
}
}
.tap-more-info {
padding-right: 50px;
& .tap-info-section {
padding: 4px 0;
& .expand-section-header {
font-weight: var(--font-weight-bold);
}
}
}
.metric-table {
& .popover-td {
padding: 5px;
}
}

View File

@ -1,7 +1,7 @@
import { friendlyTitle } from './util/Utils.js';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { friendlyTitle } from './util/Utils.js';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
export default class AddResources extends React.Component {
static propTypes = {

View File

@ -1,31 +1,79 @@
import { Table } from 'antd';
import Paper from '@material-ui/core/Paper';
import PropTypes from 'prop-types';
import React from 'react';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import _ from 'lodash';
import { withStyles } from '@material-ui/core/styles';
// BaseTable extends ant-design's table, but overwrites the `toggleSortOrder`
// method, in order to remove the default behavior of unsorting a column when
// the same sorting arrow is pressed twice:
// https://github.com/ant-design/ant-design/blob/master/components/table/Table.tsx#L348
export default class BaseTable extends Table {
constructor(props) {
super(props);
Table.prototype.toggleSortOrder = this.toggleSortOrder;
}
const styles = theme => ({
root: {
width: '100%',
marginTop: theme.spacing.unit * 3,
marginBottom: theme.spacing.unit * 3,
overflowX: 'auto',
},
table: {},
});
toggleSortOrder(order, column) {
const newState = {
sortOrder: order,
sortColumn: column,
};
function BaseTable(props) {
const { classes, tableRows, tableColumns, tableClassName, rowKey} = props;
if (this.getSortOrderColumns().length === 0) {
this.setState(newState);
}
const onChange = this.props.onChange;
if (onChange) {
onChange.apply(null, this.prepareParamsArguments({
...this.state,
...newState,
}));
}
}
return (
<Paper className={classes.root}>
<Table className={`${classes.table} ${tableClassName}`}>
<TableHead>
<TableRow>
{ _.map(tableColumns, c => (
<TableCell
key={c.key}
numeric={c.isNumeric}>{c.title}
</TableCell>
))
}
</TableRow>
</TableHead>
<TableBody>
{
_.map(tableRows, d => {
let key = !rowKey ? d.key : rowKey(d);
return (
<TableRow key={key}>
{ _.map(tableColumns, c => (
<TableCell
key={`table-${key}-${c.key}`}
numeric={c.isNumeric}>{c.render(d)}
</TableCell>
))
}
</TableRow>
);
})}
</TableBody>
</Table>
</Paper>
);
}
BaseTable.propTypes = {
classes: PropTypes.shape({}).isRequired,
rowKey: PropTypes.func,
tableClassName: PropTypes.string,
tableColumns: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
isNumeric: PropTypes.bool,
render: PropTypes.func
})).isRequired,
tableRows: PropTypes.arrayOf(PropTypes.shape({}))
};
BaseTable.defaultProps = {
rowKey: null,
tableClassName: "",
tableRows: []
};
export default withStyles(styles)(BaseTable);

View File

@ -1,12 +1,10 @@
import _ from 'lodash';
import { friendlyTitle, isResource, singularResource } from "./util/Utils.js";
import PropTypes from "prop-types";
import React from 'react';
import ReactRouterPropTypes from 'react-router-prop-types';
import _ from 'lodash';
import { withContext } from './util/AppContext.jsx';
import { Breadcrumb, Layout } from 'antd';
import { friendlyTitle, isResource, singularResource } from "./util/Utils.js";
import './../../css/breadcrumb-header.css';
const routeToCrumbTitle = {
"servicemesh": "Service Mesh",
@ -16,7 +14,6 @@ const routeToCrumbTitle = {
};
class BreadcrumbHeader extends React.Component {
static propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
@ -73,11 +70,11 @@ class BreadcrumbHeader extends React.Component {
}
}
renderBreadcrumbSegment(segment, index) {
renderBreadcrumbSegment(segment, shouldPluralizeFirstSegment) {
let isMeshResource = isResource(segment);
if (isMeshResource) {
if (index === 0) {
if (!shouldPluralizeFirstSegment) {
return friendlyTitle(segment).singular;
}
return this.segmentToFriendlyTitle(segment, true);
@ -85,46 +82,23 @@ class BreadcrumbHeader extends React.Component {
return this.segmentToFriendlyTitle(segment, false);
}
renderBreadcrumbs(breadcrumbs) {
let PrefixedLink = this.api.PrefixedLink;
if (breadcrumbs.length === 1) {
// Check for a single segment so that we can pluralize it.
let singleBreadcrumb = breadcrumbs[0];
return (
<Breadcrumb.Item key={singleBreadcrumb.segment}>
<PrefixedLink
to={singleBreadcrumb.link}>
{this.renderBreadcrumbSegment(singleBreadcrumb.segment)}
</PrefixedLink>
</Breadcrumb.Item>
);
} else {
return _.map(breadcrumbs, (pathSegment, index) => {
return (
<Breadcrumb.Item key={pathSegment.segment}>
<PrefixedLink
to={pathSegment.link}>
{this.renderBreadcrumbSegment(pathSegment.segment, index)}
</PrefixedLink>
</Breadcrumb.Item>
);
});
}
}
render() {
let prefix = this.props.pathPrefix;
let PrefixedLink = this.api.PrefixedLink;
let breadcrumbs = this.convertURLToBreadcrumbs(this.props.location.pathname.replace(prefix, ""));
let shouldPluralizeFirstSegment = breadcrumbs.length === 1;
return (
<Layout.Header>
<Breadcrumb separator=">">
{ this.renderBreadcrumbs(breadcrumbs) }
</Breadcrumb>
</Layout.Header>
);
return _.map(breadcrumbs, (pathSegment, index) => {
return (
<span key={pathSegment.segment} className="breadcrumb-link">
<PrefixedLink
to={pathSegment.link}>
{this.renderBreadcrumbSegment(pathSegment.segment, shouldPluralizeFirstSegment && index === 0)}
</PrefixedLink>
{ index < _.size(breadcrumbs) - 1 ? " > " : null}
</span>
);
});
}
}

View File

@ -1,49 +1,73 @@
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import './../../css/cta.css';
import Step from '@material-ui/core/Step';
import StepContent from '@material-ui/core/StepContent';
import StepLabel from '@material-ui/core/StepLabel';
import Stepper from '@material-ui/core/Stepper';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import { withStyles } from '@material-ui/core/styles';
const CallToAction = ({resource, numResources}) => (
<div className="call-to-action">
<div className="action summary">The service mesh was successfully installed!</div>
const styles = theme => ({
root: {
width: '90%',
},
button: {
marginRight: theme.spacing.unit,
},
instructions: {
marginTop: theme.spacing.unit,
marginBottom: theme.spacing.unit,
},
});
<div className="action-steps">
<div className="step-container complete">
<div className="icon-container">
<i className="fa fa-check-circle" aria-hidden="true" />
</div>
<div className="message"><p>Controller successfully installed</p></div>
</div>
function getSteps(numResources, resource) {
return [
{ label: 'Controller successfully installed' },
{ label: `${numResources ? numResources : 'No'} ${resource}s detected` },
{ label: `Connect your first ${resource}`, content: incompleteMeshMessage() }
];
}
<div className="step-container complete">
<div className="icon-container">
<i className="fa fa-check-circle" aria-hidden="true" />
</div>
<div className="message">{numResources ? numResources : 'No'} {resource}s detected</div>
</div>
class CallToAction extends React.Component {
render() {
const { resource, numResources } = this.props;
const steps = getSteps(numResources, resource);
const lastStep = _.size(steps) - 1; // hardcode the last step as the active step
<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 {resource}</div>
</div>
</div>
return (
<React.Fragment>
<Typography>The service mesh was successfully installed!</Typography>
<Stepper
activeStep={lastStep}
orientation="vertical">
{
steps.map((step, i) => {
const props = {};
props.completed = i < lastStep; // select the last step as the currently active one
<div className="clearfix">
{incompleteMeshMessage()}
</div>
</div>
);
CallToAction.defaultProps = {
numResources: 0,
resource: 'resource',
};
return (
<Step key={step.label} {...props}>
<StepLabel>{step.label}</StepLabel>
<StepContent>{step.content}</StepContent>
</Step>
);
})
}
</Stepper>
</React.Fragment>
);
}
}
CallToAction.propTypes = {
numResources: PropTypes.number,
resource: PropTypes.string,
resource: PropTypes.string.isRequired,
};
export default CallToAction;
CallToAction.defaultProps = {
numResources: null
};
export default withStyles(styles)(CallToAction);

View File

@ -1,63 +1,103 @@
import _ from 'lodash';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import PropTypes from 'prop-types';
import React from 'react';
import { Col, Row } from 'antd';
import Snackbar from '@material-ui/core/Snackbar';
import WarningIcon from '@material-ui/icons/Warning';
import _ from 'lodash';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import classNames from 'classnames';
import { withStyles } from '@material-ui/core/styles';
class ErrorMessage extends React.Component {
static defaultProps = {
message: {
status: null,
statusText: "An error occured",
url: "",
error: ""
},
onHideMessage: _.noop
}
const defaultMessage = "An error has occurred.";
static propTypes = {
message: apiErrorPropType,
onHideMessage: PropTypes.func
}
const styles = theme => ({
close: {
padding: theme.spacing.unit / 2,
},
error: {
backgroundColor: theme.palette.error.dark,
},
backgroundColor: theme.palette.error.dark,
iconVariant: {
opacity: 0.9,
marginRight: theme.spacing.unit,
},
margin: {
margin: theme.spacing.unit,
},
message: {
display: 'flex',
alignItems: 'center',
},
});
constructor(props) {
super(props);
this.hideMessage = this.hideMessage.bind(this);
this.state = {
visible: true
};
}
class ErrorSnackbar extends React.Component {
state = {
open: true,
};
componentWillReceiveProps(newProps) {
if (!_.isEmpty(newProps.message)) {
this.setState({ visible :true });
}
}
hideMessage() {
this.props.onHideMessage();
this.setState({
visible: false
});
}
handleClose = () => {
this.setState({ open: false });
};
render() {
const { classes } = this.props;
const { statusText, error, url, status } = this.props.message;
return !this.state.visible ? null : (
<Row gutter={0}>
<div className="error-message-container">
<Col span={20}>
{ !status && !statusText ? null : <div>{status} {statusText}</div> }
return (
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={this.state.open}
autoHideDuration={6000}
onClose={this.handleClose}
ContentProps={{
'aria-describedby': 'message-id',
headlineMapping: { // https://github.com/mui-org/material-ui/issues/13144
body1: "div",
body2: "div"
},
className: classNames(classes.error, classes.margin)
}}
message={(
<div id="message-id" >
<div className={classes.message}>
<WarningIcon className={classNames(classes.icon, classes.iconVariant)} />
{ !status ? null : status + " " }{ _.isEmpty(statusText) ? defaultMessage : statusText }
</div>
{ !error ? null : <div>{error}</div> }
{ !url ? null : <div>{url}</div> }
</Col>
<Col span={4}>
<div className="dismiss" onClick={this.hideMessage} role="presentation">Dismiss X</div>
</Col>
</div>
</Row>
</div>
)}
action={[
<IconButton
key="close"
aria-label="Close"
color="inherit"
className={classes.close}
onClick={this.handleClose}>
<CloseIcon />
</IconButton>,
]} />
);
}
}
export default ErrorMessage;
ErrorSnackbar.propTypes = {
classes: PropTypes.shape({}).isRequired,
message: apiErrorPropType,
};
ErrorSnackbar.defaultProps = {
message: {
status: null,
statusText: defaultMessage,
url: "",
error: ""
}
};
export default withStyles(styles)(ErrorSnackbar);

View File

@ -1,41 +1,36 @@
import _ from 'lodash';
import { friendlyTitle } from './util/Utils.js';
import Button from '@material-ui/core/Button';
import CircularProgress from '@material-ui/core/CircularProgress';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import ErrorIcon from '@material-ui/icons/Error';
import Grid from '@material-ui/core/Grid';
import PropTypes from 'prop-types';
import React from 'react';
import { Button, Icon, Modal, Switch, Tooltip } from 'antd';
import Switch from '@material-ui/core/Switch';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
import { friendlyTitle } from './util/Utils.js';
// max characters we display for error messages before truncating them
const maxErrorLength = 500;
export default class ErrorModal extends React.Component {
static propTypes = {
errors: PropTypes.shape({}).isRequired,
resourceName: PropTypes.string.isRequired,
resourceType: PropTypes.string.isRequired
}
const maxErrorLength = 50;
class ErrorModal extends React.Component {
state = {
visible: false,
truncateErrors: true
}
truncateErrors: true,
open: false,
scroll: 'paper',
};
showModal = () => {
this.setState({
visible: true,
});
}
handleClickOpen = () => {
this.setState({ open: true });
};
handleOk = () => {
this.setState({
visible: false,
});
}
handleCancel = () => {
this.setState({
visible: false,
});
}
handleClose = () => {
this.setState({ open: false });
};
toggleTruncateErrors = () => {
this.setState({
@ -85,10 +80,20 @@ export default class ErrorModal extends React.Component {
return _.map(errorsByContainer, (errors, container) => (
<div key={`error-${container}`} className="container-error">
<div className="clearfix">
<span className="pull-left" title="container name">{container}</span>
<span className="pull-right" title="docker image">{_.get(errors, [0, "image"])}</span>
</div>
<Grid
container
direction="row"
justify="space-between"
alignItems="center">
<Grid item>
<Typography variant="subtitle1" gutterBottom>{container}</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" gutterBottom align="right">
{_.get(errors, [0, "image"])}
</Typography>
</Grid>
</Grid>
<div className="error-text">
{
@ -117,14 +122,14 @@ export default class ErrorModal extends React.Component {
return _.map(errors, err => {
return (
<div className="controller-pod-error" key={err.pod}>
<h3 title="pod name">{err.pod}</h3>
<Typography variant="title" gutterBottom>{err.pod}</Typography>
{this.renderContainerErrors(err.pod, err.byContainer)}
</div>
);
});
}
renderIconStatus = errors => {
renderStatusIcon = errors => {
let showInit = true;
_.each(errors.byPodAndContainer, container => {
@ -137,41 +142,60 @@ export default class ErrorModal extends React.Component {
if (showInit) {
return (
<Tooltip
title="Pods are initializing"
overlayStyle={{ fontSize: "12px" }}>
<Icon type="loading" theme="outlined" className="controller-init-icon" />
</Tooltip>
<Tooltip title="Pods are initializing"><CircularProgress size={20} thickness={4} /></Tooltip>
);
} else {
return <Icon type="warning" className="controller-error-icon" onClick={this.showModal} />;
return <ErrorIcon onClick={this.handleClickOpen} />;
}
}
render() {
let errors = this.processErrorData(this.props.errors);
return (
<React.Fragment>
{this.renderIconStatus(errors)}
<Modal
className="controller-pod-error-modal"
title={`Errors in ${friendlyTitle(this.props.resourceType).singular} ${this.props.resourceName}`}
visible={this.state.visible}
onOk={this.handleOk}
onCancel={this.handleCancel}
footer={<Button key="modal-ok" type="primary" onClick={this.handleOk}>OK</Button>}
width="800px">
<React.Fragment>
<div>
{this.renderStatusIcon(errors)}
<Dialog
open={this.state.open}
onClose={this.handleClose}
scroll={this.state.scroll}
aria-labelledby="scroll-dialog-title">
<DialogTitle id="scroll-dialog-title">Errors in {friendlyTitle(this.props.resourceType).singular} {this.props.resourceName}</DialogTitle>
<DialogContent>
{
errors.shouldTruncate ?
<React.Fragment>
Some of these error messages are very long. Show full error text? <Switch onChange={this.toggleTruncateErrors} />
</React.Fragment> : null
!errors.shouldTruncate ? null :
<React.Fragment>
Some of these error messages are very long. Show full error text?
<Switch
checked={!this.state.truncateErrors}
onChange={this.toggleTruncateErrors}
color="primary" />
</React.Fragment>
}
{this.renderPodErrors(errors.byPodAndContainer)}
</React.Fragment>
</Modal>
</React.Fragment>
</DialogContent>
<DialogActions>
<Button onClick={this.handleClose} color="primary">Close</Button>
</DialogActions>
</Dialog>
</div>
);
}
}
ErrorModal.propTypes = {
errors: PropTypes.shape({}),
resourceName: PropTypes.string.isRequired,
resourceType: PropTypes.string.isRequired
};
ErrorModal.defaultProps = {
errors: {}
};
export default ErrorModal;

View File

@ -0,0 +1,137 @@
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import IconButton from '@material-ui/core/IconButton';
import Paper from '@material-ui/core/Paper';
import PropTypes from 'prop-types';
import React from 'react';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import _ from 'lodash';
import { withStyles } from '@material-ui/core/styles';
const styles = theme => ({
root: {
width: '100%',
marginTop: theme.spacing.unit * 3,
overflowX: 'auto',
},
table: {
minWidth: 700
},
});
class ExpandableTable extends React.Component {
constructor(props) {
super(props);
this.state = {
open: false,
datum: {}
};
}
handleDialogOpen = d => () => {
this.setState({ open: true, datum: d });
}
handleDialogClose = () => {
this.setState({ open: false, datum: {} });
};
render() {
const { expandedRowRender, classes, tableRows, tableColumns, tableClassName } = this.props;
let columns = _.concat([{
title: " ",
key: "expansion",
render: d => {
return (
<IconButton onClick={this.handleDialogOpen(d)}><ExpandMoreIcon /></IconButton>
);
}
}], tableColumns);
return (
<Paper className={classes.root}>
<Table className={`${classes.table} ${tableClassName}`}>
<TableHead>
<TableRow>
{
_.map(columns, c => (
<TableCell
key={c.key}
numeric={c.isNumeric}>{c.title}
</TableCell>
)
)
}
</TableRow>
</TableHead>
<TableBody>
{ _.map(tableRows, d => {
return (
<React.Fragment key={"frag-" + d.key}>
<TableRow
key={d.key}
onClick={this.handleClick}
ref={ref => {
this.container = ref;
}}>
{
_.map(columns, c => (
<TableCell
key={`table-${d.key}-${c.key}`}
numeric={c.isNumeric}>
{c.render(d)}
</TableCell>
))
}
</TableRow>
</React.Fragment>
);
}
)}
</TableBody>
</Table>
<Dialog
maxWidth="md"
open={this.state.open}
onClose={this.handleDialogClose}
aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Request Details</DialogTitle>
<DialogContent>
{expandedRowRender(this.state.datum)}
</DialogContent>
<DialogActions>
<Button onClick={this.handleDialogClose} color="primary">Close</Button>
</DialogActions>
</Dialog>
</Paper>
);
}
}
ExpandableTable.propTypes = {
classes: PropTypes.shape({}).isRequired,
expandedRowRender: PropTypes.func.isRequired,
tableClassName: PropTypes.string,
tableColumns: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
isNumeric: PropTypes.bool,
render: PropTypes.func
})).isRequired,
tableRows: PropTypes.arrayOf(PropTypes.shape({}))
};
ExpandableTable.defaultProps = {
tableClassName: "",
tableRows: []
};
export default withStyles(styles)(ExpandableTable);

View File

@ -1,6 +1,6 @@
import { grafanaIcon } from './util/SvgWrappers.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { grafanaIcon } from './util/SvgWrappers.jsx';
const GrafanaLink = ({PrefixedLink, name, namespace, resource}) => {
return (

View File

@ -0,0 +1,94 @@
import BaseTable from './BaseTable.jsx';
import ErrorModal from './ErrorModal.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { StyledProgress } from './util/Progress.jsx';
import Tooltip from '@material-ui/core/Tooltip';
import _ from 'lodash';
import { withContext } from './util/AppContext.jsx';
const getClassification = (meshedPodCount, failedPodCount) => {
if (failedPodCount > 0) {
return "poor";
} else if (meshedPodCount === 0) {
return "neutral";
} else {
return "good";
}
};
const namespacesColumns = PrefixedLink => [
{
title: "Namespace",
key: "namespace",
render: d => {
return (
<React.Fragment>
<PrefixedLink to={"/namespaces/" + d.namespace}>{d.namespace}</PrefixedLink>
{ _.isEmpty(d.errors) ? null :
<ErrorModal errors={d.errors} resourceName={d.namespace} resourceType="namespace" />
}
</React.Fragment>
);
}
},
{
title: "Meshed pods",
key: "meshedPodsStr",
isNumeric: true,
render: d => d.meshedPodsStr
},
{
title: "Meshed Status",
key: "meshification",
render: row => {
let percent = row.meshedPercent.get();
let barType = _.isEmpty(row.errors) ?
getClassification(row.meshedPods, row.failedPods) : "poor";
let Progress = StyledProgress(barType);
let percentMeshedMsg = "";
if (row.meshedPercent.get() >= 0) {
percentMeshedMsg = `(${row.meshedPercent.prettyRate()})`;
}
return (
<Tooltip
title={(
<div>
<div>
{`${row.meshedPods} out of ${row.totalPods} running or pending pods are in the mesh ${percentMeshedMsg}`}
</div>
{row.failedPods === 0 ? null : <div>{ `${row.failedPods} failed pods` }</div>}
</div>
)}>
<Progress variant="determinate" value={Math.round(percent * 100)} />
</Tooltip>
);
}
}
];
class MeshedStatusTable extends React.Component {
render() {
return (
<BaseTable
tableClassName="metric-table mesh-completion-table"
tableRows={this.props.tableRows}
tableColumns={namespacesColumns(this.props.api.PrefixedLink)}
rowKey={d => d.namespace} />
);
}
}
MeshedStatusTable.propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired
}).isRequired,
tableRows: PropTypes.arrayOf(PropTypes.shape({}))
};
MeshedStatusTable.defaultProps = {
tableRows: []
};
export default withContext(MeshedStatusTable);

View File

@ -1,17 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
const Metric = ({className, title, value}) => (
<div className={`metric ${className || ""}`}>
<div className="metric-title">{title}</div>
<div className="metric-value">{value}</div>
</div>
);
Metric.propTypes = {
className: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
};
export default Metric;

View File

@ -1,70 +1,31 @@
import _ from 'lodash';
import { friendlyTitle, metricToFormatter } from './util/Utils.js';
import { processedMetricsPropType, successRateWithMiniChart } from './util/MetricUtils.jsx';
import BaseTable from './BaseTable.jsx';
import ErrorModal from './ErrorModal.jsx';
import GrafanaLink from './GrafanaLink.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { Tooltip } from 'antd';
import _ from 'lodash';
import { withContext } from './util/AppContext.jsx';
import {
friendlyTitle,
metricToFormatter,
numericSort
} from './util/Utils.js';
import { processedMetricsPropType, successRateWithMiniChart } from './util/MetricUtils.jsx';
/*
Table to display Success Rate, Requests and Latency in tabs.
Expects rollup and timeseries data.
*/
const smMetricColWidth = "70px";
const formatTitle = (title, tooltipText) => {
if (!tooltipText) {
return title;
} else {
return (
<Tooltip
title={tooltipText}
overlayStyle={{ fontSize: "12px" }}>
{title}
</Tooltip>
);
}
};
const meshedColumn = {
title: formatTitle("Meshed"),
dataIndex: "pods",
key: "pods",
className: "numeric",
sorter: (a, b) => numericSort(a.pods.totalPods, b.pods.totalPods),
render: p => p.meshedPods + "/" + p.totalPods
};
const columnDefinitions = (resource, namespaces, onFilterClick, showNamespaceColumn, PrefixedLink) => {
const columnDefinitions = (resource, showNamespaceColumn, PrefixedLink) => {
let isAuthorityTable = resource === "authority";
let nsColumn = [
{
title: formatTitle("Namespace"),
title: "Namespace",
key: "namespace",
dataIndex: "namespace",
filters: namespaces,
onFilterDropdownVisibleChange: onFilterClick,
onFilter: (value, row) => row.namespace.indexOf(value) === 0,
sorter: (a, b) => (a.namespace || "").localeCompare(b.namespace),
render: ns => !ns ? "---" : <PrefixedLink to={"/namespaces/" + ns}>{ns}</PrefixedLink>
isNumeric: false,
render: d => !d.namespace ? "---" : <PrefixedLink to={"/namespaces/" + d.namespace}>{d.namespace}</PrefixedLink>
}
];
let grafanaLinkColumn = [
{
title: formatTitle("Dash", "Grafana Dashboard"),
title: "Grafana Dashboard",
key: "grafanaDashboard",
className: "numeric",
width: smMetricColWidth,
isNumeric: true,
render: row => !row.added || _.get(row, "pods.totalPods") === "0" ? null : (
<GrafanaLink
name={row.name}
@ -75,90 +36,78 @@ const columnDefinitions = (resource, namespaces, onFilterClick, showNamespaceCol
}
];
let meshedColumn = {
title: "Meshed",
key: "meshed",
isNumeric: true,
render: d => !d.pods ? null : d.pods.meshedPods + "/" + d.pods.totalPods
};
let columns = [
{
title: formatTitle(friendlyTitle(resource).singular),
key: "name",
defaultSortOrder: 'ascend',
sorter: (a, b) => (a.name || "").localeCompare(b.name),
render: row => {
title: friendlyTitle(resource).singular,
key: "resource-title",
isNumeric: false,
render: d => {
let nameContents;
if (resource === "namespace") {
nameContents = <PrefixedLink to={"/namespaces/" + row.name}>{row.name}</PrefixedLink>;
} else if (!row.added || isAuthorityTable) {
nameContents = row.name;
nameContents = <PrefixedLink to={"/namespaces/" + d.name}>{d.name}</PrefixedLink>;
} else if (!d.added || isAuthorityTable) {
nameContents = d.name;
} else {
nameContents = (
<PrefixedLink to={"/namespaces/" + row.namespace + "/" + resource + "s/" + row.name}>
{row.name}
<PrefixedLink to={"/namespaces/" + d.namespace + "/" + resource + "s/" + d.name}>
{d.name}
</PrefixedLink>
);
}
return (
<React.Fragment>
{nameContents}
{ _.isEmpty(row.errors) ? null : <ErrorModal errors={row.errors} resourceName={row.name} resourceType={resource} /> }
{ _.isEmpty(d.errors) ? null : <ErrorModal errors={d.errors} resourceName={d.name} resourceType={resource} /> }
</React.Fragment>
);
}
},
{
title: formatTitle("SR", "Success Rate"),
dataIndex: "successRate",
key: "successRateRollup",
className: "numeric",
width: "120px",
sorter: (a, b) => numericSort(a.successRate, b.successRate),
render: successRateWithMiniChart
title: "Success Rate",
key: "success-rate",
isNumeric: true,
render: d => successRateWithMiniChart(d.successRate)
},
{
title: formatTitle("RPS", "Request Rate"),
dataIndex: "requestRate",
key: "requestRateRollup",
className: "numeric",
width: smMetricColWidth,
sorter: (a, b) => numericSort(a.requestRate, b.requestRate),
render: metricToFormatter["NO_UNIT"]
title: "Request Rate",
key: "request-rate",
isNumeric: true,
render: d => metricToFormatter["NO_UNIT"](d.requestRate)
},
{
title: formatTitle("P50", "P50 Latency"),
dataIndex: "P50",
key: "p50LatencyRollup",
className: "numeric",
width: smMetricColWidth,
sorter: (a, b) => numericSort(a.P50, b.P50),
render: metricToFormatter["LATENCY"]
title: "P50 Latency",
key: "p50_latency",
isNumeric: true,
render: d => metricToFormatter["LATENCY"](d.P50)
},
{
title: formatTitle("P95", "P95 Latency"),
dataIndex: "P95",
key: "p95LatencyRollup",
className: "numeric",
width: smMetricColWidth,
sorter: (a, b) => numericSort(a.P95, b.P95),
render: metricToFormatter["LATENCY"]
title: "P95 Latency",
key: "p95_latency",
isNumeric: true,
render: d => metricToFormatter["LATENCY"](d.P95)
},
{
title: formatTitle("P99", "P99 Latency"),
dataIndex: "P99",
key: "p99LatencyRollup",
className: "numeric",
width: smMetricColWidth,
sorter: (a, b) => numericSort(a.P99, b.P99),
render: metricToFormatter["LATENCY"]
title: "P99 Latency",
key: "p99_latency",
isNumeric: true,
render: d => metricToFormatter["LATENCY"](d.P99)
},
{
title: formatTitle("TLS", "Percentage of TLS Traffic"),
key: "tlsTraffic",
dataIndex: "tlsRequestPercent",
className: "numeric",
width: smMetricColWidth,
sorter: (a, b) => numericSort(a.tlsRequestPercent.get(), b.tlsRequestPercent.get()),
render: d => _.isNil(d) || d.get() === -1 ? "---" : d.prettyRate()
title: "TLS",
key: "has_tls",
isNumeric: true,
render: d => _.isNil(d.tlsRequestPercent) || d.tlsRequestPercent.get() === -1 ? "---" : d.tlsRequestPercent.prettyRate()
}
];
// don't add the meshed column on a Authority MetricsTable
// don't add the meshed column on a Authority MetricsTable
if (!isAuthorityTable) {
columns.splice(1, 0, meshedColumn);
columns = _.concat(columns, grafanaLinkColumn);
@ -171,13 +120,20 @@ const columnDefinitions = (resource, namespaces, onFilterClick, showNamespaceCol
}
};
/** @extends React.Component */
export class MetricsTableBase extends BaseTable {
static defaultProps = {
showNamespaceColumn: true,
metrics: []
}
const preprocessMetrics = metrics => {
let tableData = _.cloneDeep(metrics);
_.each(tableData, datum => {
_.each(datum.latency, (value, quantile) => {
datum[quantile] = value;
});
});
return tableData;
};
class MetricsTable extends React.Component {
static propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
@ -185,81 +141,27 @@ export class MetricsTableBase extends BaseTable {
metrics: PropTypes.arrayOf(processedMetricsPropType),
resource: PropTypes.string.isRequired,
showNamespaceColumn: PropTypes.bool
}
};
constructor(props) {
super(props);
this.api = this.props.api;
this.onFilterDropdownVisibleChange = this.onFilterDropdownVisibleChange.bind(this);
this.state = {
preventTableUpdates: false
};
}
shouldComponentUpdate() {
// prevent the table from updating if the filter dropdown menu is open
// this is because if the table updates, the filters will reset which
// makes it impossible to select a filter
return !this.state.preventTableUpdates;
}
onFilterDropdownVisibleChange(dropdownVisible) {
this.setState({ preventTableUpdates: dropdownVisible});
}
preprocessMetrics() {
let tableData = _.cloneDeep(this.props.metrics);
let namespaces = [];
_.each(tableData, datum => {
namespaces.push(datum.namespace);
_.each(datum.latency, (value, quantile) => {
datum[quantile] = value;
});
});
return {
rows: tableData,
namespaces: _.uniq(namespaces)
};
}
static defaultProps = {
showNamespaceColumn: true,
metrics: []
};
render() {
let tableData = this.preprocessMetrics();
let namespaceFilterText = _.map(tableData.namespaces, ns => {
return { text: ns, value: ns };
});
const { metrics, resource, showNamespaceColumn, api } = this.props;
let resource = this.props.resource;
let showNsColumn = this.props.showNamespaceColumn;
if (resource === "namespace") {
showNsColumn = false;
}
let columns = _.compact(columnDefinitions(
resource,
namespaceFilterText,
this.onFilterDropdownVisibleChange,
showNsColumn,
this.api.PrefixedLink
));
let locale = {
emptyText: `No ${friendlyTitle(resource).plural} detected.`
};
let showNsColumn = resource === "namespace" ? false : showNamespaceColumn;
let columns = columnDefinitions(resource, showNsColumn, api.PrefixedLink);
let rows = preprocessMetrics(metrics);
return (
<BaseTable
dataSource={tableData.rows}
columns={columns}
pagination={false}
className="metric-table"
rowKey={r => `${r.namespace}/${r.name}`}
locale={locale}
size="middle" />
tableRows={rows}
tableColumns={columns}
tableClassName="metric-table" />
);
}
}
export default withContext(MetricsTableBase);
export default withContext(MetricsTable);

View File

@ -1,74 +1,64 @@
import _ from 'lodash';
import ApiHelpers from './util/ApiHelpers.jsx';
import BaseTable from './BaseTable.jsx';
import { MetricsTableBase } from './MetricsTable.jsx';
import React from 'react';
import { shallow } from 'enzyme';
import MetricsTable from './MetricsTable.jsx';
import { routerWrap } from '../../test/testHelpers.jsx';
import { mount } from 'enzyme';
describe('Tests for <MetricsTableBase>', () => {
describe('Tests for <MetricsTable>', () => {
const defaultProps = {
api: ApiHelpers(''),
};
it('renders the table with all columns', () => {
const component = shallow(
<MetricsTableBase
{...defaultProps}
metrics={[{
name: 'web',
namespace: 'default',
totalRequests: 0,
}]}
resource="deployment" />
);
let extraProps = _.merge({}, defaultProps, {
metrics: [{
name: 'web',
namespace: 'default',
key: 'web-default-deploy',
totalRequests: 0,
}],
resource: "deployment"
});
const component = mount(routerWrap(MetricsTable, extraProps));
const table = component.find(BaseTable);
const table = component.find("BaseTable");
expect(table).toHaveLength(1);
expect(table.props().dataSource).toHaveLength(1);
expect(table.props().columns).toHaveLength(10);
expect(table).toBeDefined();
expect(table.props().tableRows).toHaveLength(1);
expect(table.props().tableColumns).toHaveLength(10);
});
it('omits the namespace column for the namespace resource', () => {
const component = shallow(
<MetricsTableBase
{...defaultProps}
metrics={[]}
resource="namespace" />
);
let extraProps = _.merge({}, defaultProps, { metrics: [], resource: "namespace"});
const component = mount(routerWrap(MetricsTable, extraProps));
const table = component.find(BaseTable);
const table = component.find("BaseTable");
expect(table).toHaveLength(1);
expect(table.props().columns).toHaveLength(9);
expect(table).toBeDefined();
expect(table.props().tableColumns).toHaveLength(9);
});
it('omits the namespace column when showNamespaceColumn is false', () => {
const component = shallow(
<MetricsTableBase
{...defaultProps}
metrics={[]}
resource="deployment"
showNamespaceColumn={false} />
);
let extraProps = _.merge({}, defaultProps, {
metrics: [],
resource: "deployment",
showNamespaceColumn: false
});
const component = mount(routerWrap(MetricsTable, extraProps));
const table = component.find(BaseTable);
const table = component.find("BaseTable");
expect(table).toHaveLength(1);
expect(table.props().columns).toHaveLength(9);
expect(table).toBeDefined();
expect(table.props().tableColumns).toHaveLength(9);
});
it('omits meshed column and grafana column for authority resource', () => {
const component = shallow(
<MetricsTableBase
{...defaultProps}
metrics={[]}
resource="authority" />
);
let extraProps = _.merge({}, defaultProps, { metrics: [], resource: "authority"});
const component = mount(routerWrap(MetricsTable, extraProps));
const table = component.find(BaseTable);
const table = component.find("BaseTable");
expect(table).toHaveLength(1);
expect(table.props().columns).toHaveLength(8);
expect(table).toBeDefined();
expect(table.props().tableColumns).toHaveLength(8);
});
});

View File

@ -1,14 +1,15 @@
import _ from 'lodash';
import 'whatwg-fetch';
import ErrorBanner from './ErrorBanner.jsx';
import { friendlyTitle } from './util/Utils.js';
import MetricsTable from './MetricsTable.jsx';
import NetworkGraph from './NetworkGraph.jsx';
import { processMultiResourceRollup } from './util/MetricUtils.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { Spin } from 'antd';
import Spinner from './util/Spinner.jsx';
import _ from 'lodash';
import { friendlyTitle } from './util/Utils.js';
import { processMultiResourceRollup } from './util/MetricUtils.jsx';
import { withContext } from './util/AppContext.jsx';
import 'whatwg-fetch';
class Namespaces extends React.Component {
static defaultProps = {
@ -60,12 +61,6 @@ class Namespaces extends React.Component {
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
componentWillReceiveProps(newProps) {
// React won't unmount this component when switching resource pages so we need to clear state
this.api.cancelCurrentRequests();
this.setState(this.getInitialState(newProps.match.params));
}
componentWillUnmount() {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
@ -127,7 +122,7 @@ class Namespaces extends React.Component {
return (
<div className="page-content">
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <Spin size="large" /> : (
{ !this.state.loaded ? <Spinner /> : (
<div>
{ noMetrics ? <div>No resources detected.</div> : null}
{

View File

@ -1,18 +1,23 @@
import _ from 'lodash';
import ErrorBanner from './ErrorBanner.jsx';
import { friendlyTitle } from './util/Utils.js';
import MetricsTable from './MetricsTable.jsx';
import PageHeader from './PageHeader.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { withContext } from './util/AppContext.jsx';
import { Collapse, Icon, Spin, Tooltip } from 'antd';
import { processMultiResourceRollup, processSingleResourceRollup } from './util/MetricUtils.jsx';
import 'whatwg-fetch';
import { processMultiResourceRollup, processSingleResourceRollup } from './util/MetricUtils.jsx';
import Accordion from './util/Accordion.jsx';
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import ErrorBanner from './ErrorBanner.jsx';
import MetricsTable from './MetricsTable.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import Spinner from './util/Spinner.jsx';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
import { friendlyTitle } from './util/Utils.js';
import { withContext } from './util/AppContext.jsx';
const isMeshedTooltip = (
<Tooltip placement="right" title="Namespace is meshed" overlayStyle={{ fontSize: "12px" }}>
<Icon className="status-ok" type="check-circle" />
<Tooltip title="Namespace is meshed" placement="right-start">
<CheckCircleIcon />
</Tooltip>
);
class NamespaceLanding extends React.Component {
@ -57,7 +62,7 @@ class NamespaceLanding extends React.Component {
this.api.cancelCurrentRequests();
}
onNsSelect = ns => {
onNamespaceChange = ns => {
this.setState({
selectedNs: ns
});
@ -120,7 +125,8 @@ class NamespaceLanding extends React.Component {
}
return (
<div className="page-section">
<h3>{friendlyTitle(resource).plural}</h3>
<br />
<Typography variant="h5">{friendlyTitle(resource).plural}</Typography>
<MetricsTable
resource={resource}
metrics={metrics}
@ -131,7 +137,7 @@ class NamespaceLanding extends React.Component {
renderNamespaceSection(namespace) {
if (!_.has(this.state.metricsByNs, namespace)) {
return <Spin size="large" className="short-spin-section" />;
return <Spinner />;
}
let metrics = this.state.metricsByNs[namespace] || {};
@ -139,7 +145,7 @@ class NamespaceLanding extends React.Component {
return (
<div>
<h2>Namespace: {namespace}</h2>
<Typography variant="h4">Namespace: {namespace}</Typography>
{ noMetrics ? <div>No resources detected.</div> : null}
{this.renderResourceSection("deployment", metrics.deployment)}
{this.renderResourceSection("replicationcontroller", metrics.replicationcontroller)}
@ -150,31 +156,21 @@ class NamespaceLanding extends React.Component {
}
renderAccordion() {
let panelData = _.map(this.state.namespaces, ns => {
return {
id: ns.name,
header: <React.Fragment>{ns.name} {!ns.added ? null : isMeshedTooltip}</React.Fragment>,
body: ns.name === this.state.selectedNs || ns.name === this.state.defaultOpenNs.name ?
this.renderNamespaceSection(ns.name) : null
};
});
return (
<React.Fragment>
<PageHeader header="Namespaces" />
<Collapse
accordion={true}
defaultActiveKey={_.get(this.state.defaultOpenNs, 'name', null)}
onChange={this.onNsSelect}>
{
_.map(this.state.namespaces, ns => {
let header = (
<React.Fragment>{ns.name} {!ns.added ? null : isMeshedTooltip}</React.Fragment>
);
return (
<Collapse.Panel
showArrow={false}
header={header}
key={ns.name}>
{ns.name === this.state.selectedNs || ns.name === this.state.defaultOpenNs.name ?
this.renderNamespaceSection(ns.name) : null}
</Collapse.Panel>
);
})
}
</Collapse>
<Accordion
onChange={this.onNamespaceChange}
panels={panelData}
defaultOpenPanel={_.get(this.state.defaultOpenNs, 'name', null)} />
</React.Fragment>
);
}
@ -183,7 +179,7 @@ class NamespaceLanding extends React.Component {
return (
<div className="page-content">
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <Spin size="large" /> : this.renderAccordion() }
{ !this.state.loaded ? <Spinner /> : this.renderAccordion() }
</div>);
}
}

View File

@ -0,0 +1,329 @@
import {
AppBar,
Collapse,
Divider,
Drawer,
IconButton,
ListItem,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
Toolbar,
Typography
} from '@material-ui/core';
import { githubIcon, linkerdWordLogo, slackIcon } from './util/SvgWrappers.jsx';
import BreadcrumbHeader from './BreadcrumbHeader.jsx';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import CloudQueueIcon from '@material-ui/icons/CloudQueue';
import EmailIcon from '@material-ui/icons/Email';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import HelpIcon from '@material-ui/icons/HelpOutline';
import HomeIcon from '@material-ui/icons/Home';
import LibraryBooksIcon from '@material-ui/icons/LibraryBooks';
import { Link } from 'react-router-dom';
import MenuIcon from '@material-ui/icons/Menu';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';
import NetworkCheckIcon from '@material-ui/icons/NetworkCheck';
import PropTypes from 'prop-types';
import React from 'react';
import ReactRouterPropTypes from 'react-router-prop-types';
import Version from './Version.jsx';
import ViewListIcon from '@material-ui/icons/ViewList';
import VisibilityIcon from '@material-ui/icons/Visibility';
import classNames from 'classnames';
import { withContext } from './util/AppContext.jsx';
import { withStyles } from '@material-ui/core/styles';
const drawerWidth = 250;
const styles = theme => ({
root: {
flexGrow: 1,
height: "100vh",
width: "100%",
zIndex: 1,
overflowX: 'scroll',
position: 'relative',
display: 'flex',
},
appBar: {
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
menuButton: {
marginLeft: 12,
marginRight: 36,
},
hide: {
display: 'none',
},
drawerPaper: {
position: 'relative',
whiteSpace: 'nowrap',
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
drawerPaperClose: {
overflowX: 'hidden',
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing.unit * 7,
[theme.breakpoints.up('sm')]: {
width: theme.spacing.unit * 9,
},
},
toolbar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: '0 8px',
...theme.mixins.toolbar,
},
content: {
flexGrow: 1,
width: `calc(100% - ${drawerWidth}px)`,
backgroundColor: theme.palette.background.default,
padding: theme.spacing.unit * 3,
},
linkerdLogoContainer: {
backgroundColor: theme.palette.primary.dark,
},
linkerdNavLogo: {
minWidth: "180px",
},
navMenuItem: {
paddingLeft: "24px",
paddingRight: "24px",
},
});
class NavigationBase extends React.Component {
constructor(props) {
super(props);
this.api = this.props.api;
this.handleApiError = this.handleApiError.bind(this);
this.state = this.getInitialState();
}
getInitialState() {
return {
drawerOpen: true,
resourceMenuOpen: false,
helpMenuOpen: false,
latestVersion: '',
isLatest: true,
namespaceFilter: "all"
};
}
componentDidMount() {
this.fetchVersion();
}
fetchVersion() {
let versionUrl = `https://versioncheck.linkerd.io/version.json?version=${this.props.releaseVersion}&uuid=${this.props.uuid}&source=web`;
this.versionPromise = fetch(versionUrl, { credentials: 'include' })
.then(rsp => rsp.json())
.then(versionRsp => {
let latestVersion;
let parts = this.props.releaseVersion.split("-", 2);
if (parts.length === 2) {
latestVersion = versionRsp[parts[0]];
}
this.setState({
latestVersion,
isLatest: latestVersion === this.props.releaseVersion
});
}).catch(this.handleApiError);
}
handleApiError(e) {
this.setState({
error: e
});
}
handleDrawerOpen = () => {
this.setState({ drawerOpen: true });
};
handleDrawerClose = () => {
this.setState({ drawerOpen: false });
};
handleResourceMenuClick = () => {
this.setState(state => ({ resourceMenuOpen: !state.resourceMenuOpen }));
};
handleHelpMenuClick = () => {
this.setState(state => ({ helpMenuOpen: !state.helpMenuOpen }));
}
menuItem(path, title, icon) {
const { classes, api } = this.props;
let normalizedPath = this.props.location.pathname.replace(this.props.pathPrefix, "");
let isCurrentPage = path => path === normalizedPath;
return (
<MenuItem
component={Link}
to={api.prefixLink(path)}
className={classes.navMenuItem}
selected={isCurrentPage(path)}>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={title} />
</MenuItem>
);
}
render() {
const { classes, ChildComponent } = this.props;
return (
<div className={classes.root}>
<AppBar
position="absolute"
className={classNames(classes.appBar, {[classes.appBarShift]: this.state.drawerOpen} )}>
<Toolbar disableGutters={!this.state.drawerOpen}>
<IconButton
color="inherit"
aria-label="Open drawer"
onClick={this.handleDrawerOpen}
className={classNames(classes.menuButton, {[classes.hide]: this.state.drawerOpen} )}>
<MenuIcon />
</IconButton>
<Typography variant="h6" color="inherit" noWrap>
<BreadcrumbHeader {...this.props} />
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
classes={{
paper: classNames(classes.drawerPaper, {[classes.drawerPaperClose]: !this.state.drawerOpen} ),
}}
open={this.state.drawerOpen}>
<div className={classNames(classes.linkerdLogoContainer, classes.toolbar)}>
<div className={classes.linkerdNavLogo}>
{linkerdWordLogo}
</div>
<IconButton className="drawer-toggle-btn" onClick={this.handleDrawerClose}>
<ChevronLeftIcon />
</IconButton>
</div>
<Divider />
<MenuList>
{ this.menuItem("/overview", "Overview", <HomeIcon />) }
{ this.menuItem("/tap", "Tap", <VisibilityIcon />) }
{ this.menuItem("/top", "Top", <NetworkCheckIcon />) }
{ this.menuItem("/servicemesh", "Service Mesh", <CloudQueueIcon />) }
<MenuItem
className={classes.navMenuItem}
button
onClick={this.handleResourceMenuClick}>
<ListItemIcon><ViewListIcon /></ListItemIcon>
<ListItemText inset primary="Resources" />
{this.state.resourceMenuOpen ? <ExpandLess /> : <ExpandMore />}
</MenuItem>
<Collapse in={this.state.resourceMenuOpen} timeout="auto" unmountOnExit>
<MenuList dense component="div" disablePadding>
{ this.menuItem("/authorities", "Authorities", <NavigateNextIcon />) }
{ this.menuItem("/deployments", "Deployments", <NavigateNextIcon />) }
{ this.menuItem("/namespaces", "Namespaces", <NavigateNextIcon />) }
{ this.menuItem("/pods", "Pods", <NavigateNextIcon />) }
{ this.menuItem("/replicationcontrollers", "Replication Controllers", <NavigateNextIcon />) }
</MenuList>
</Collapse>
</MenuList>
<Divider />
<MenuList>
<ListItem component="a" href="https://linkerd.io/2/overview/" target="_blank">
<ListItemIcon><LibraryBooksIcon /></ListItemIcon>
<ListItemText primary="Documentation" />
</ListItem>
<MenuItem
className={classes.navMenuItem}
button
onClick={this.handleHelpMenuClick}>
<ListItemIcon><HelpIcon /></ListItemIcon>
<ListItemText inset primary="Help" />
{this.state.helpMenuOpen ? <ExpandLess /> : <ExpandMore />}
</MenuItem>
<Collapse in={this.state.helpMenuOpen} timeout="auto" unmountOnExit>
<MenuList dense component="div" disablePadding>
<ListItem component="a" href="https://lists.cncf.io/g/cncf-linkerd-users" target="_blank">
<ListItemIcon><EmailIcon /></ListItemIcon>
<ListItemText primary="Join the mailing list" />
</ListItem>
<ListItem component="a" href="https://slack.linkerd.io" target="_blank">
<ListItemIcon>{slackIcon}</ListItemIcon>
<ListItemText primary="Join us on slack" />
</ListItem>
<ListItem component="a" href="https://github.com/linkerd/linkerd2/issues/new/choose" target="_blank">
<ListItemIcon>{githubIcon}</ListItemIcon>
<ListItemText primary="File an issue" />
</ListItem>
</MenuList>
</Collapse>
</MenuList>
{
!this.state.drawerOpen ? null : <Version
isLatest={this.state.isLatest}
latestVersion={this.state.latestVersion}
releaseVersion={this.props.releaseVersion}
error={this.state.error}
uuid={this.props.uuid} />
}
</Drawer>
<main className={classes.content}>
<div className={classes.toolbar} />
<div className="main-content"><ChildComponent {...this.props} /></div>
</main>
</div>
);
}
}
NavigationBase.propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
}).isRequired,
ChildComponent: PropTypes.func.isRequired,
classes: PropTypes.shape({}).isRequired,
location: ReactRouterPropTypes.location.isRequired,
pathPrefix: PropTypes.string.isRequired,
releaseVersion: PropTypes.string.isRequired,
theme: PropTypes.shape({}).isRequired,
uuid: PropTypes.string.isRequired,
};
export default withContext(withStyles(styles, { withTheme: true })(NavigationBase));

View File

@ -1,7 +1,7 @@
import ApiHelpers from './util/ApiHelpers.jsx';
import { BrowserRouter } from 'react-router-dom';
import React from 'react';
import Sidebar from './Sidebar.jsx';
import Navigation from './Navigation.jsx';
import sinon from 'sinon';
import sinonStubPromise from 'sinon-stub-promise';
import { mount } from 'enzyme';
@ -15,15 +15,17 @@ const loc = {
search: '',
};
describe('Version', () => {
describe('Navigation', () => {
let curVer = "edge-1.2.3";
let newVer = "edge-2.3.4";
let component, fetchStub;
let apiHelpers = ApiHelpers("");
const childComponent = () => null;
function withPromise(fn) {
return component.find("Sidebar").instance().serverPromise.then(fn);
return component.find("NavigationBase").instance().versionPromise.then(fn);
}
beforeEach(() => {
@ -37,7 +39,7 @@ describe('Version', () => {
const expandSidebar = component => {
// click trigger to expand the sidebar
component.find(".ant-layout-sider-trigger").simulate('click');
component.find("button.drawer-toggle-btn").simulate('click', () => {});
};
it('is hidden when the sidebar is collapsed', () => {
@ -48,7 +50,10 @@ describe('Version', () => {
component = mount(
<BrowserRouter>
<Sidebar
<Navigation
ChildComponent={childComponent}
classes={{}}
theme={{}}
location={loc}
api={apiHelpers}
releaseVersion={curVer}
@ -72,7 +77,10 @@ describe('Version', () => {
component = mount(
<BrowserRouter>
<Sidebar
<Navigation
ChildComponent={childComponent}
classes={{}}
theme={{}}
location={loc}
api={apiHelpers}
releaseVersion={curVer}
@ -94,7 +102,10 @@ describe('Version', () => {
component = mount(
<BrowserRouter>
<Sidebar
<Navigation
ChildComponent={childComponent}
classes={{}}
theme={{}}
location={loc}
api={apiHelpers}
releaseVersion={curVer}
@ -111,17 +122,20 @@ describe('Version', () => {
it('renders error when version check fails', () => {
let errMsg = "Fake error";
fetchStub.resolves({
fetchStub.rejects({
ok: false,
json: () => Promise.resolve({
error: errMsg
error: {},
}),
statusText: errMsg,
});
component = mount(
<BrowserRouter>
<Sidebar
<Navigation
ChildComponent={childComponent}
classes={{}}
theme={{}}
location={loc}
api={apiHelpers}
releaseVersion={curVer}

View File

@ -1,12 +1,13 @@
import _ from 'lodash';
import { metricsPropType } from './util/MetricUtils.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { withContext } from './util/AppContext.jsx';
import withREST from './util/withREST.jsx';
import * as d3 from 'd3';
import 'whatwg-fetch';
import * as d3 from 'd3';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { metricsPropType } from './util/MetricUtils.jsx';
import { withContext } from './util/AppContext.jsx';
import withREST from './util/withREST.jsx';
const defaultSvgWidth = 524;
const defaultSvgHeight = 325;

View File

@ -1,28 +1,22 @@
import _ from 'lodash';
import { displayName, metricToFormatter } from './util/Utils.js';
import { getSuccessRateClassification, srArcClassLabels } from './util/MetricUtils.jsx' ;
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import Grid from '@material-ui/core/Grid';
import OctopusArms from './util/OctopusArms.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { Col, Progress, Row } from 'antd';
import { displayName, metricToFormatter } from './util/Utils.js';
import { getSuccessRateClassification, srArcClassLabels } from './util/MetricUtils.jsx' ;
import './../../css/octopus.css';
import { StyledProgress } from './util/Progress.jsx';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableRow from '@material-ui/core/TableRow';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
const maxNumNeighbors = 6; // max number of neighbor nodes to show in the octopus graph
const Metric = ({title, value, className}) => {
return (
<Row type="flex" justify="center" className={`octopus-metric ${className}`}>
<Col span={12} className="octopus-metric-title"><div>{title}</div></Col>
<Col span={12} className="octopus-metric-value"><div>{value}</div></Col>
</Row>
);
};
Metric.defaultProps = { className: "" };
Metric.propTypes = {
className: PropTypes.string,
title: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
};
export default class Octopus extends React.Component {
static defaultProps = {
@ -74,54 +68,68 @@ export default class Octopus extends React.Component {
linkText={display} />;
}
renderResourceSummary(resource, type) {
renderResourceCard(resource, type) {
let display = displayName(resource);
let classification = getSuccessRateClassification(resource.successRate, srArcClassLabels);
let Progress = StyledProgress(classification);
return (
<div key={resource.name} className={`octopus-body ${type}`} title={display}>
<div className={`octopus-title ${type}-title`}>
{ this.linkedResourceTitle(resource, display) }
</div>
<div>
<div className="octopus-sr-gauge">
<Progress
className={`success-rate-arc ${getSuccessRateClassification(resource.successRate, srArcClassLabels)}`}
type="dashboard"
format={() => metricToFormatter["SUCCESS_RATE"](resource.successRate)}
width={type === "main" ? 132 : 64}
percent={resource.successRate * 100}
gapDegree={180} />
</div>
<Metric title="RPS" value={metricToFormatter["REQUEST_RATE"](resource.requestRate)} />
<Metric title="P99" value={metricToFormatter["LATENCY"](_.get(resource, "latency.P99"))} />
</div>
</div>
<Grid item key={resource.name} >
<Card className={`octopus-body ${type}`} title={display}>
<CardContent>
<Typography variant={type === "neighbor" ? "h6" : "h4"} align="center">
{ this.linkedResourceTitle(resource, display) }
</Typography>
<Progress variant="determinate" value={resource.successRate * 100} />
<Table>
<TableBody>
<TableRow>
<TableCell><Typography>RPS</Typography></TableCell>
<TableCell numeric={true}><Typography>{metricToFormatter["NO_UNIT"](resource.requestRate)}</Typography></TableCell>
</TableRow>
<TableRow>
<TableCell><Typography>P99</Typography></TableCell>
<TableCell numeric={true}><Typography>{metricToFormatter["LATENCY"](_.get(resource, "latency.P99"))}</Typography></TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</Grid>
);
}
renderUnmeshedResources = unmeshedResources => {
return (
<div key="unmeshed-resources" className="octopus-body neighbor unmeshed">
<div className="octopus-title neighbor-title">Unmeshed</div>
{
_.map(unmeshedResources, r => {
let display = displayName(r);
return <div key={display} title={display}>{display}</div>;
})
}
</div>
<Grid item>
<Card key="unmeshed-resources">
<CardContent>
<Typography variant="h6">Unmeshed</Typography>
{
_.map(unmeshedResources, r => {
let display = displayName(r);
return <Typography key={display} variant="body2" title={display}>{display}</Typography>;
})
}
</CardContent>
</Card>
</Grid>
);
}
renderCollapsedNeighbors = neighbors => {
return (
<div key="unmeshed-resources" className="octopus-body neighbor collapsed">
<Grid item key="unmeshed-resources">
{
_.map(neighbors, r => {
let display = displayName(r);
return <div className="octopus-title neighbor-title" key={display}>{this.linkedResourceTitle(r, display)}</div>;
return <div key={display}>{this.linkedResourceTitle(r, display)}</div>;
})
}
</div>
</Grid>
);
}
@ -177,30 +185,51 @@ export default class Octopus extends React.Component {
return (
<div className="octopus-graph">
<Row type="flex" justify="center" align="middle">
<Col span={6} className={`octopus-col ${hasUpstreams ? "resource-col" : ""}`}>
{_.map(display.upstreams.displayed, n => this.renderResourceSummary(n, "neighbor"))}
<Grid
container
direction="row"
justify="center"
alignItems="center">
<Grid
container
spacing={24}
direction="column"
justify="center"
alignItems="center"
item
xs={3}
className={`octopus-col ${hasUpstreams ? "resource-col" : ""}`}>
{_.map(display.upstreams.displayed, n => this.renderResourceCard(n, "neighbor"))}
{_.isEmpty(unmeshedSources) ? null : this.renderUnmeshedResources(unmeshedSources)}
{_.isEmpty(display.upstreams.collapsed) ? null : this.renderCollapsedNeighbors(display.upstreams.collapsed)}
</Col>
</Grid>
<Col span={2} className="octopus-col">
<Grid item xs={1} className="octopus-col">
{this.renderArrowCol(numUpstreams, false)}
</Col>
</Grid>
<Col span={8} className="octopus-col resource-col">
{this.renderResourceSummary(resource, "main")}
</Col>
<Grid item xs={4} className="octopus-col resource-col">
{this.renderResourceCard(resource, "main")}
</Grid>
<Col span={2} className="octopus-col">
<Grid item xs={1} className="octopus-col">
{this.renderArrowCol(numDownstreams, true)}
</Col>
</Grid>
<Col span={6} className={`octopus-col ${hasDownstreams ? "resource-col" : ""}`}>
{_.map(display.downstreams.displayed, n => this.renderResourceSummary(n, "neighbor"))}
<Grid
container
spacing={24}
direction="column"
justify="center"
alignItems="center"
item
xs={3}
className={`octopus-col ${hasDownstreams ? "resource-col" : ""}`}>
{_.map(display.downstreams.displayed, n => this.renderResourceCard(n, "neighbor"))}
{_.isEmpty(display.downstreams.collapsed) ? null : this.renderCollapsedNeighbors(display.downstreams.collapsed)}
</Col>
</Row>
</Grid>
</Grid>
</div>
);
}

View File

@ -1,82 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { withContext } from './util/AppContext.jsx';
import { Col, Radio, Row } from 'antd';
class PageHeader extends React.Component {
static defaultProps = {
hideButtons: false,
subHeader: null,
subHeaderTitle: null,
subMessage: null,
}
static propTypes = {
api: PropTypes.shape({
getMetricsWindow: PropTypes.func.isRequired,
getValidMetricsWindows: PropTypes.func.isRequired,
setMetricsWindow: PropTypes.func.isRequired,
}).isRequired,
header: PropTypes.string.isRequired,
hideButtons: PropTypes.bool,
subHeader: PropTypes.string,
subHeaderTitle: PropTypes.string,
subMessage: PropTypes.string,
}
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});
}
// varying the time window on the web UI is currently hidden
renderMetricWindowButtons() {
if (this.props.hideButtons) {return null;}
const buttons = _.map(this.props.api.getValidMetricsWindows(), (w, i) => {
return <Radio.Button key={`metrics-window-btn-${i}`} value={w}>{w}</Radio.Button>;
});
return (
<Col span={6}>
<Radio.Group
className="time-window-btns"
value={this.state.selectedWindow}
onChange={this.onTimeWindowClick} >
{buttons}
</Radio.Group>
</Col>
);
}
render() {
const {header, subHeader, subHeaderTitle, subMessage} = this.props;
return (
<div className="page-header">
<Row>
<Col span={18}>
{!header ? null : <h1>{header}</h1>}
{!subHeaderTitle ? null : <div className="subsection-header">{subHeaderTitle}</div>}
{!subHeader ? null : <h1>{subHeader}</h1>}
</Col>
{/* {this.renderMetricWindowButtons()} */}
</Row>
{!subMessage ? null : <div>{subMessage}</div>}
</div>
);
}
}
export default withContext(PageHeader);

View File

@ -0,0 +1,74 @@
/* eslint-disable jsx-a11y/mouse-events-have-key-events */
import Popover from '@material-ui/core/Popover';
import PropTypes from 'prop-types';
import React from 'react';
import { withStyles } from '@material-ui/core/styles';
const styles = theme => ({
popover: {
pointerEvents: 'none',
},
paper: {
padding: theme.spacing.unit,
},
});
class MouseOverPopover extends React.Component {
state = {
anchorEl: null,
};
handlePopoverOpen = event => {
this.setState({ anchorEl: event.currentTarget });
};
handlePopoverClose = () => {
this.setState({ anchorEl: null });
};
render() {
const { classes, baseContent, popoverContent } = this.props;
const { anchorEl } = this.state;
const open = Boolean(anchorEl);
return (
<div>
<div
aria-owns={open ? 'mouse-over-popover' : null}
aria-haspopup="true"
onMouseEnter={this.handlePopoverOpen}
onMouseLeave={this.handlePopoverClose}>
{baseContent}
</div>
<Popover
id="mouse-over-popover"
className={classes.popover}
classes={{
paper: classes.paper,
}}
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onClose={this.handlePopoverClose}
disableRestoreFocus>
{popoverContent}
</Popover>
</div>
);
}
}
MouseOverPopover.propTypes = {
baseContent: PropTypes.node.isRequired,
classes: PropTypes.shape({}).isRequired,
popoverContent: PropTypes.node.isRequired
};
export default withStyles(styles)(MouseOverPopover);

View File

@ -1,17 +1,19 @@
import _ from 'lodash';
import 'whatwg-fetch';
import { emptyMetric, processSingleResourceRollup } from './util/MetricUtils.jsx';
import { resourceTypeToCamelCase, singularResource } from './util/Utils.js';
import AddResources from './AddResources.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import MetricsTable from './MetricsTable.jsx';
import Octopus from './Octopus.jsx';
import { processNeighborData } from './util/TapUtils.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { Spin } from 'antd';
import Spinner from './util/Spinner.jsx';
import TopModule from './TopModule.jsx';
import _ from 'lodash';
import { processNeighborData } from './util/TapUtils.jsx';
import { withContext } from './util/AppContext.jsx';
import { emptyMetric, processSingleResourceRollup } from './util/MetricUtils.jsx';
import { resourceTypeToCamelCase, singularResource } from './util/Utils.js';
import 'whatwg-fetch';
const getResourceFromUrl = (match, pathPrefix) => {
let resource = {
@ -34,7 +36,9 @@ export class ResourceDetailBase extends React.Component {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
}).isRequired,
match: PropTypes.shape({}).isRequired,
match: PropTypes.shape({
url: PropTypes.string.isRequired
}).isRequired,
pathPrefix: PropTypes.string.isRequired
}
@ -74,11 +78,13 @@ export class ResourceDetailBase extends React.Component {
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
componentWillReceiveProps(newProps) {
// React won't unmount this component when switching resource pages so we need to clear state
this.api.cancelCurrentRequests();
this.unmeshedSources = {};
this.setState(this.getInitialState(newProps.match, newProps.pathPrefix));
componentDidUpdate(prevProps) {
if (!_.isEqual(prevProps.match.url, this.props.match.url)) {
// React won't unmount this component when switching resource pages so we need to clear state
this.api.cancelCurrentRequests();
this.unmeshedSources = {};
this.setState(this.getInitialState(this.props.match, this.props.pathPrefix));
}
}
componentWillUnmount() {
@ -185,7 +191,7 @@ export class ResourceDetailBase extends React.Component {
content = () => {
if (!this.state.loaded && !this.state.error) {
return <Spin size="large" />;
return <Spinner />;
}
let topQuery = {

View File

@ -1,13 +1,15 @@
import _ from 'lodash';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import 'whatwg-fetch';
import { metricsPropType, processSingleResourceRollup } from './util/MetricUtils.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import MetricsTable from './MetricsTable.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { Spin } from 'antd';
import Spinner from './util/Spinner.jsx';
import _ from 'lodash';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import withREST from './util/withREST.jsx';
import { metricsPropType, processSingleResourceRollup } from './util/MetricUtils.jsx';
import 'whatwg-fetch';
export class ResourceListBase extends React.Component {
static defaultProps = {
@ -35,7 +37,7 @@ export class ResourceListBase extends React.Component {
const {data, loading, error} = this.props;
if (loading && !error) {
return <Spin size="large" />;
return <Spinner />;
}
let processedMetrics = [];
@ -44,9 +46,11 @@ export class ResourceListBase extends React.Component {
}
return (
<MetricsTable
resource={this.props.resource}
metrics={processedMetrics} />
<React.Fragment>
<MetricsTable
resource={this.props.resource}
metrics={processedMetrics} />
</React.Fragment>
);
}

View File

@ -3,7 +3,7 @@ import ErrorBanner from './ErrorBanner.jsx';
import MetricsTable from './MetricsTable.jsx';
import React from 'react';
import { ResourceListBase } from './ResourceList.jsx';
import { Spin } from 'antd';
import Spinner from './util/Spinner.jsx';
import { shallow } from 'enzyme';
describe('Tests for <ResourceListBase>', () => {
@ -24,7 +24,7 @@ describe('Tests for <ResourceListBase>', () => {
const err = component.find(ErrorBanner);
expect(err).toHaveLength(1);
expect(component.find(Spin)).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(component.find(MetricsTable)).toHaveLength(1);
expect(err.props().message.statusText).toEqual(msg);
});
@ -38,7 +38,7 @@ describe('Tests for <ResourceListBase>', () => {
);
expect(component.find(ErrorBanner)).toHaveLength(0);
expect(component.find(Spin)).toHaveLength(1);
expect(component.find(Spinner)).toHaveLength(1);
expect(component.find(MetricsTable)).toHaveLength(0);
});
@ -51,7 +51,7 @@ describe('Tests for <ResourceListBase>', () => {
);
expect(component.find(ErrorBanner)).toHaveLength(0);
expect(component.find(Spin)).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(component.find(MetricsTable)).toHaveLength(1);
});
@ -68,7 +68,7 @@ describe('Tests for <ResourceListBase>', () => {
const metrics = component.find(MetricsTable);
expect(component.find(ErrorBanner)).toHaveLength(0);
expect(component.find(Spin)).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(metrics).toHaveLength(1);
expect(metrics.props().resource).toEqual(resource);

View File

@ -1,43 +1,35 @@
import _ from 'lodash';
import BaseTable from './BaseTable.jsx';
import CallToAction from './CallToAction.jsx';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import ErrorBanner from './ErrorBanner.jsx';
import ErrorModal from './ErrorModal.jsx';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import Metric from './Metric.jsx';
import moment from 'moment';
import { numericSort } from './util/Utils.js';
import Grid from '@material-ui/core/Grid';
import MeshedStatusTable from './MeshedStatusTable.jsx';
import Percentage from './util/Percentage.js';
import PropTypes from 'prop-types';
import React from 'react';
import Spinner from './util/Spinner.jsx';
import StatusTable from './StatusTable.jsx';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import moment from 'moment';
import { withContext } from './util/AppContext.jsx';
import { Col, Row, Spin, Table, Tooltip } from 'antd';
import './../../css/service-mesh.css';
const serviceMeshDetailsColumns = [
{
title: "Name",
dataIndex: "name",
key: "name"
key: "name",
render: d => d.name
},
{
title: "Value",
dataIndex: "value",
key: "value",
className: "numeric"
isNumeric: true,
render: d => d.value
}
];
const getClassification = (meshedPodCount, failedPodCount) => {
if (failedPodCount > 0) {
return "poor";
} else if (meshedPodCount === 0) {
return "neutral";
} else {
return "good";
}
};
const getPodClassification = pod => {
if (pod.status === "Running") {
return "good";
@ -48,66 +40,6 @@ const getPodClassification = pod => {
}
};
const namespacesColumns = PrefixedLink => [
{
title: "Namespace",
key: "namespace",
defaultSortOrder: "ascend",
sorter: (a, b) => (a.namespace || "").localeCompare(b.namespace),
render: d => {
return (
<React.Fragment>
<PrefixedLink to={"/namespaces/" + d.namespace}>{d.namespace}</PrefixedLink>
{ _.isEmpty(d.errors) ? null :
<ErrorModal errors={d.errors} resourceName={d.namespace} resourceType="namespace" />
}
</React.Fragment>
);
}
},
{
title: "Meshed pods",
dataIndex: "meshedPodsStr",
key: "meshedPodsStr",
className: "numeric",
sorter: (a, b) => numericSort(a.totalPods, b.totalPods),
},
{
title: "Meshed Status",
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 = _.isEmpty(row.errors) ?
getClassification(row.meshedPods, row.failedPods) : "poor";
let percentMeshedMsg = "";
if (row.meshedPercent.get() >= 0) {
percentMeshedMsg = `(${row.meshedPercent.prettyRate()})`;
}
return (
<Tooltip
overlayStyle={{ fontSize: "12px" }}
title={(
<div>
<div>
{`${row.meshedPods} out of ${row.totalPods} running or pending pods are in the mesh ${percentMeshedMsg}`}
</div>
{row.failedPods === 0 ? null : <div>{ `${row.failedPods} failed pods` }</div>}
</div>
)}>
<div className={"container-bar " + barType} style={{width: containerWidth}}>
<div className={"inner-bar " + barType} style={{width: barWidth}}>&nbsp;</div>
</div>
</Tooltip>
);
}
}
];
const componentsToDeployNames = {
"Destination": "controller",
"Grafana" : "grafana",
@ -266,87 +198,63 @@ class ServiceMesh extends React.Component {
renderControlPlaneDetails() {
return (
<div className="mesh-section">
<div className="clearfix header-with-metric">
<div className="subsection-header">Control plane</div>
<Metric title="Components" value={this.componentCount()} className="metric-large" />
</div>
<React.Fragment>
<Grid container justify="space-between">
<Grid item xs={3}>
<Typography variant="h6">Control plane</Typography>
</Grid>
<Grid item xs={3}>
<Typography align="right">Components</Typography>
<Typography align="right">{this.componentCount()}</Typography>
</Grid>
</Grid>
<StatusTable
data={this.state.components}
statusColumnTitle="Pod Status"
shouldLink={false}
api={this.api} />
</div>
</React.Fragment>
);
}
renderServiceMeshDetails() {
return (
<div className="mesh-section">
<div className="clearfix header-with-metric">
<div className="subsection-header">Service mesh details</div>
</div>
<React.Fragment>
<Typography variant="h6">Service mesh details</Typography>
<div className="service-mesh-table">
<Table
className="metric-table"
dataSource={this.getServiceMeshDetails()}
columns={serviceMeshDetailsColumns}
pagination={false}
size="middle" />
</div>
</div>
<BaseTable
tableClassName="metric-table"
tableRows={this.getServiceMeshDetails()}
tableColumns={serviceMeshDetailsColumns}
rowKey={d => d.key} />
</React.Fragment>
);
}
renderAddResourcesMessage() {
let message = "";
let numUnadded = 0;
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">
All namespaces have a {this.props.productName} install.
</div>
);
message = "No resources detected.";
} else {
return (
<div className="mesh-completion-message">
{numUnadded} {numUnadded === 1 ? "namespace has" : "namespaces have"} no meshed resources.
{incompleteMeshMessage()}
</div>
);
let meshedCount = _.countBy(this.state.nsStatuses, pod => {
return pod.meshedPercent.get() > 0;
});
numUnadded = meshedCount["false"] || 0;
message = numUnadded === 0 ? `All namespaces have a ${this.props.productName} install.` :
`${numUnadded} ${numUnadded === 1 ? "namespace has" : "namespaces have"} no meshed resources.`;
}
}
renderNamespaceStatusTable() {
let rowCn = row => {
return row.meshedPercent.get() > 0.9 ? "good" : "neutral";
};
return (
<div className="mesh-section">
<Row gutter={16}>
<Col span={16}>
<Table
className="metric-table service-mesh-table mesh-completion-table"
dataSource={this.state.nsStatuses}
columns={namespacesColumns(this.api.PrefixedLink)}
rowKey="namespace"
rowClassName={rowCn}
pagination={false}
size="middle" />
</Col>
<Col span={8}>{this.renderAddResourcesMessage()}</Col>
</Row>
</div>
<Card>
<CardContent>
<Typography>{message}</Typography>
{ numUnadded > 0 ? incompleteMeshMessage() : null }
</CardContent>
</Card>
);
}
@ -354,19 +262,24 @@ class ServiceMesh extends React.Component {
return (
<div className="page-content">
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
{ !this.state.loaded ? <Spin size="large" /> : (
{ !this.state.loaded ? <Spinner /> : (
<div>
{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>
<Grid container spacing={24}>
<Grid item xs={8} container direction="column" >
<Grid item>{this.renderControlPlaneDetails()}</Grid>
<Grid item><MeshedStatusTable tableRows={_.sortBy(this.state.nsStatuses, "namespace")} /></Grid>
</Grid>
{this.renderNamespaceStatusTable()}
<Grid item xs={4} container direction="column" spacing={24}>
<Grid item>{this.renderServiceMeshDetails()}</Grid>
<Grid item>{this.renderAddResourcesMessage()}</Grid>
</Grid>
</Grid>
</div>
)}
</div>

View File

@ -5,6 +5,7 @@ import { routerWrap } from '../../test/testHelpers.jsx';
import ServiceMesh from './ServiceMesh.jsx';
import sinon from 'sinon';
import sinonStubPromise from 'sinon-stub-promise';
import Spinner from './util/Spinner.jsx';
import { mount } from 'enzyme';
sinonStubPromise(sinon);
@ -12,6 +13,9 @@ sinonStubPromise(sinon);
describe('ServiceMesh', () => {
let component, fetchStub;
// https://material-ui.com/style/typography/#migration-to-typography-v2
window.__MUI_USE_NEXT_TYPOGRAPHY_VARIANTS__ = true;
function withPromise(fn) {
return component.find("ServiceMesh").instance().serverPromise.then(fn);
}
@ -46,7 +50,7 @@ describe('ServiceMesh', () => {
it("renders the spinner before metrics are loaded", () => {
component = mount(routerWrap(ServiceMesh));
expect(component.find(".ant-spin")).toHaveLength(1);
expect(component.find(Spinner)).toHaveLength(1);
expect(component.find("ServiceMesh")).toHaveLength(1);
expect(component.find("CallToAction")).toHaveLength(0);
});
@ -60,9 +64,9 @@ describe('ServiceMesh', () => {
return withPromise(() => {
component.update();
// console.log(component.find("Spin").debug());
expect(component.find("ServiceMesh")).toHaveLength(1);
expect(component.find(".ant-spin")).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(component.find("CallToAction")).toHaveLength(1);
});
});
@ -77,7 +81,7 @@ describe('ServiceMesh', () => {
return withPromise(() => {
component.update();
expect(component.find("ServiceMesh")).toHaveLength(1);
expect(component.find(".ant-spin")).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
});
});
@ -91,7 +95,7 @@ describe('ServiceMesh', () => {
return withPromise(() => {
component.update();
expect(component.find("ServiceMesh")).toHaveLength(1);
expect(component.find(".ant-spin")).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(component).toIncludeText("Service mesh details");
expect(component).toIncludeText("ShinyProductName version");
});
@ -107,7 +111,7 @@ describe('ServiceMesh', () => {
return withPromise(() => {
component.update();
expect(component.find("ServiceMesh")).toHaveLength(1);
expect(component.find(".ant-spin")).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(component).toIncludeText("Control plane");
});
});
@ -122,7 +126,7 @@ describe('ServiceMesh', () => {
return withPromise(() => {
component.update();
expect(component.find("ServiceMesh")).toHaveLength(1);
expect(component.find(".ant-spin")).toHaveLength(0);
expect(component.find(Spinner)).toHaveLength(0);
expect(component).toIncludeText("Data plane");
});
});
@ -136,7 +140,7 @@ describe('ServiceMesh', () => {
component = mount(routerWrap(ServiceMesh));
return withPromise(() => {
expect(component).toIncludeText("No resources detected");
expect(component).toIncludeText("No namespaces detected");
});
});

View File

@ -1,351 +0,0 @@
import _ from 'lodash';
import { friendlyTitle } from './util/Utils.js';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import React from 'react';
import ReactRouterPropTypes from 'react-router-prop-types';
import Version from './Version.jsx';
import { withContext } from './util/AppContext.jsx';
import { Badge, Form, Icon, Layout, Menu, Select } from 'antd';
import {
excludeResourcesFromRollup,
getSuccessRateClassification,
processMultiResourceRollup,
processSingleResourceRollup
} from './util/MetricUtils.jsx';
import {linkerdLogoOnly, linkerdWordLogo} from './util/SvgWrappers.jsx';
import './../../css/sidebar.css';
import 'whatwg-fetch';
const classificationLabels = {
good: "success",
neutral: "warning",
bad: "error",
default: "default"
};
class Sidebar extends React.Component {
static defaultProps = {
productName: 'controller'
}
static propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
}).isRequired,
location: ReactRouterPropTypes.location.isRequired,
pathPrefix: PropTypes.string.isRequired,
productName: PropTypes.string,
releaseVersion: PropTypes.string.isRequired,
uuid: PropTypes.string.isRequired,
}
constructor(props) {
super(props);
this.api = this.props.api;
this.toggleCollapse = this.toggleCollapse.bind(this);
this.loadFromServer = this.loadFromServer.bind(this);
this.handleApiError = this.handleApiError.bind(this);
this.handleNamespaceSelector = this.handleNamespaceSelector.bind(this);
this.state = this.getInitialState();
}
getInitialState() {
return {
pollingInterval: 12000,
initialCollapse: false,
collapsed: true,
error: null,
latestVersion: '',
isLatest: true,
pendingRequests: false,
namespaceFilter: "all"
};
}
componentDidMount() {
this.fetchVersion();
this.startServerPolling();
}
componentWillUnmount() {
// the Sidebar never unmounts, but if something ever does, we should take care of it
this.stopServerPolling();
}
startServerPolling() {
this.loadFromServer();
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
stopServerPolling() {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
}
fetchVersion() {
let versionUrl = `https://versioncheck.linkerd.io/version.json?version=${this.props.releaseVersion}&uuid=${this.props.uuid}&source=web`;
fetch(versionUrl, { credentials: 'include' })
.then(rsp => rsp.json())
.then(versionRsp => {
let latestVersion;
let parts = this.props.releaseVersion.split("-", 2);
if (parts.length === 2) {
latestVersion = versionRsp[parts[0]];
}
this.setState({
latestVersion,
isLatest: latestVersion === this.props.releaseVersion
});
}).catch(this.handleApiError);
}
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([
this.api.fetchMetrics(this.api.urlsForResource("all")),
this.api.fetchMetrics(this.api.urlsForResource("namespace"))
]);
// expose serverPromise for testing
this.serverPromise = Promise.all(this.api.getCurrentPromises())
.then(([allRsp, nsRsp]) => {
let allResourceGroups = processMultiResourceRollup(allRsp);
let finalResourceGroups = excludeResourcesFromRollup(allResourceGroups, ["authority", "service"]);
let nsStats = processSingleResourceRollup(nsRsp);
let namespaces = _(nsStats).map('name').sortBy().value();
this.setState({
finalResourceGroups,
namespaces,
pendingRequests: false,
});
}).catch(this.handleApiError);
}
handleApiError(e) {
this.setState({
pendingRequests: false,
error: e
});
}
toggleCollapse() {
if (this.state.initialCollapse) {
// fix weird situation where toggleCollapsed is called on pageload,
// causing the toggle states to be inconsistent. Don't toggle on the
// very first call to toggleCollapse()
this.setState({ initialCollapse: false});
} else {
this.setState({ collapsed: !this.state.collapsed });
}
}
handleNamespaceSelector(value) {
this.setState({
namespaceFilter: value
});
}
filterResourcesByNamespace(resources, namespace) {
let resourceFilter = namespace === "all" ? r => r.added :
r => r.namespace === namespace && r.added;
return _.mapValues(resources, o => _.filter(o, resourceFilter));
}
render() {
let normalizedPath = this.props.location.pathname.replace(this.props.pathPrefix, "");
let PrefixedLink = this.api.PrefixedLink;
let namespaces = [
{value: "all", name: "All Namespaces"}
].concat(_.map(this.state.namespaces, ns => {
return {value: ns, name: ns};
}));
let sidebarComponents = this.filterResourcesByNamespace(this.state.finalResourceGroups, this.state.namespaceFilter);
return (
<Layout.Sider
width="260px"
breakpoint="lg"
collapsed={this.state.collapsed}
collapsible={true}
onCollapse={this.toggleCollapse}>
<div className="sidebar">
<div className={`sidebar-menu-header ${this.state.collapsed ? "collapsed" : ""}`}>
<PrefixedLink to="/overview">
{this.state.collapsed ? linkerdLogoOnly : linkerdWordLogo}
</PrefixedLink>
</div>
<Menu
className="sidebar-menu"
theme="dark"
selectedKeys={[normalizedPath]}>
<Menu.Item className="sidebar-menu-item" key="/overview">
<PrefixedLink to="/overview">
<Icon type="home" />
<span>Overview</span>
</PrefixedLink>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/tap">
<PrefixedLink to="/tap">
<Icon><i className="fas fa-microscope" /></Icon>
<span>Tap</span>
</PrefixedLink>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/top">
<PrefixedLink to="/top">
<Icon><i className="fas fa-stream" /></Icon>
<span>Top</span>
</PrefixedLink>
</Menu.Item>
<Menu.Item className="sidebar-menu-item" key="/servicemesh">
<PrefixedLink to="/servicemesh">
<Icon type="cloud" />
<span>Service mesh</span>
</PrefixedLink>
</Menu.Item>
<Menu.SubMenu
className="sidebar-menu-item"
key="byresource"
title={<span className="sidebar-title"><Icon type="bars" />{this.state.collapsed ? "" : "Resources"}</span>}>
<Menu.Item><PrefixedLink to="/authorities">Authorities</PrefixedLink></Menu.Item>
<Menu.Item><PrefixedLink to="/deployments">Deployments</PrefixedLink></Menu.Item>
<Menu.Item><PrefixedLink to="/namespaces">Namespaces</PrefixedLink></Menu.Item>
<Menu.Item><PrefixedLink to="/pods">Pods</PrefixedLink></Menu.Item>
<Menu.Item><PrefixedLink to="/replicationcontrollers">Replication Controllers</PrefixedLink></Menu.Item>
</Menu.SubMenu>
<Menu.Divider />
</Menu>
<Menu
className="sidebar-menu"
theme="dark"
mode="inline"
selectedKeys={[normalizedPath]}>
{
this.state.collapsed ? null : (
<Menu.Item className="sidebar-menu-item" key="/namespace-selector">
<Form layout="inline">
<Form.Item>
<Select
defaultValue={this.state.namespaceFilter || "All Namespaces"}
dropdownMatchSelectWidth={true}
onChange={this.handleNamespaceSelector}>
{
_.map(namespaces, label => {
return (
<Select.Option key={label.value} value={label.value}>{label.name}</Select.Option>
);
})
}
</Select>
</Form.Item>
</Form>
</Menu.Item>
)
}
{
this.state.collapsed ? null :
_.map(_.keys(sidebarComponents).sort(), resourceName => {
return (
<Menu.SubMenu
className="sidebar-menu-item"
key={resourceName}
title={friendlyTitle(resourceName).plural}
disabled={_.isEmpty(sidebarComponents[resourceName])}>
{
_.map(_.sortBy(sidebarComponents[resourceName], r => `${r.namespace}/${r.name}`), r => {
// only display resources that have been meshed
return (
<Menu.Item
className="sidebar-submenu-item"
title={`${r.namespace}/${r.name}`}
key={this.api.generateResourceURL(r)}>
<div>
<PrefixedLink
to={this.api.generateResourceURL(r)}>
{`${r.namespace}/${r.name}`}
</PrefixedLink>
<Badge status={getSuccessRateClassification(r.successRate, classificationLabels)} />
</div>
</Menu.Item>
);
})
}
</Menu.SubMenu>
);
})
}
<Menu.Item className="sidebar-menu-item" key="/docs">
<Link to="https://linkerd.io/2/overview/" target="_blank">
<Icon type="file-text" />
<span>Documentation</span>
</Link>
</Menu.Item>
<Menu.SubMenu
className="sidebar-menu-item"
key="help"
title={<span className="sidebar-title"><Icon type="question-circle" />{this.state.collapsed ? "" : "Help"}</span>}>
<Menu.Item>
<Link to="https://lists.cncf.io/g/cncf-linkerd-users" target="_blank">
Join the mailing list
</Link>
</Menu.Item>
<Menu.Item>
<Link to="https://slack.linkerd.io" target="_blank">
Join the slack
</Link>
</Menu.Item>
<Menu.Item>
<Link to="https://github.com/linkerd/linkerd2/issues/new/choose" target="_blank">
File an issue
</Link>
</Menu.Item>
</Menu.SubMenu>
{
this.state.isLatest ? null : (
<Menu.Item className="sidebar-menu-item" key="/update">
<Link to="https://versioncheck.linkerd.io/update" target="_blank">
<Icon type="exclamation-circle-o" className="update" />
<span>Update {this.props.productName}</span>
</Link>
</Menu.Item>
)
}
</Menu>
{
this.state.collapsed ? null : (
<Version
isLatest={this.state.isLatest}
latestVersion={this.state.latestVersion}
releaseVersion={this.props.releaseVersion}
error={this.state.error}
uuid={this.props.uuid} />
)
}
</div>
</Layout.Sider>
);
}
}
export default withContext(Sidebar);

View File

@ -1,70 +0,0 @@
import ApiHelpers from "./util/ApiHelpers.jsx";
import { BrowserRouter } from 'react-router-dom';
import namespaceFixtures from '../../test/fixtures/namespaces.json';
import React from "react";
import { Select } from 'antd';
import Sidebar from "./Sidebar.jsx";
import sinon from "sinon";
import sinonStubPromise from "sinon-stub-promise";
import { mount } from "enzyme";
sinonStubPromise(sinon);
const loc = {
pathname: '',
hash: '',
pathPrefix: '',
search: '',
};
describe('Sidebar', () => {
let curVer = "v1.2.3";
let component, fetchStub;
let apiHelpers = ApiHelpers("");
const openNamespaceSelector = component => {
// click trigger to expand the namespace selector
component.find(Select).simulate('click');
};
function withPromise(fn) {
return component.find("Sidebar").instance().serverPromise.then(fn);
}
beforeEach(() => {
fetchStub = sinon.stub(window, 'fetch');
});
afterEach(() => {
component = null;
window.fetch.restore();
});
it("namespace selector has options", () => {
fetchStub.resolves({
ok: true,
json: () => Promise.resolve(namespaceFixtures)
});
component = mount(
<BrowserRouter>
<Sidebar
location={loc}
api={apiHelpers}
releaseVersion={curVer}
pathPrefix=""
uuid="fakeuuid" />
</BrowserRouter>
);
return withPromise(() => {
openNamespaceSelector(component);
// number of namespaces in api result
expect(namespaceFixtures.ok.statTables[0].podGroup.rows).toHaveLength(5);
// plus "All namespaces" option
expect(component.find(".ant-select-dropdown-menu-item")).toHaveLength(6);
});
});
});

View File

@ -1,7 +1,8 @@
import _ from 'lodash';
import BaseTable from './BaseTable.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import { Table, Tooltip } from 'antd';
import Tooltip from '@material-ui/core/Tooltip';
import _ from 'lodash';
const columnConfig = {
"Pod Status": {
@ -36,10 +37,9 @@ const StatusDot = ({status, multilineDots, columnName}) => (
<div>{_.get(columnConfig, [columnName, "dotExplanation"])(status)}</div>
<div>Uptime: {status.uptime} ({status.uptimeSec}s)</div>
</div>
)}
overlayStyle={{ fontSize: "12px" }}>
)}>
<div
className={`status-dot status-dot-${status.value} ${multilineDots ? 'dot-multiline': ''}`}
className={`status-table-dot status-dot status-dot-${status.value} ${multilineDots ? 'dot-multiline': ''}`}
key={status.name}>&nbsp;
</div>
</Tooltip>
@ -57,22 +57,23 @@ StatusDot.propTypes = {
const columns = {
resourceName: {
title: "Deployment",
dataIndex: "name",
key: "name"
key: "name",
render: d => d.name
},
pods: {
title: "Pods",
dataIndex: "numEntities"
key: "pods",
isNumeric: true,
render: d => d.numEntities
},
status: name => {
return {
title: name,
dataIndex: "pods",
width: columnConfig[name].width,
render: statuses => {
let multilineDots = _.size(statuses) > columnConfig[name].wrapDotsAt;
key: "status",
render: d => {
let multilineDots = _.size(d.pods) > columnConfig[name].wrapDotsAt;
return _.map(statuses, (status, i) => {
return _.map(d.pods, (status, i) => {
return (
<StatusDot
status={status}
@ -114,13 +115,11 @@ class StatusTable extends React.Component {
let tableData = this.getTableData();
return (
<Table
dataSource={tableData}
columns={tableCols}
pagination={false}
className="metric-table"
rowKey={r => r.name}
size="middle" />
<BaseTable
tableRows={tableData}
tableColumns={tableCols}
tableClassName="metric-table"
rowKey={r => r.name} />
);
}
}

View File

@ -1,14 +1,13 @@
import _ from 'lodash';
import { UrlQueryParamTypes, addUrlProps } from 'react-url-query';
import { emptyTapQuery, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
import ErrorBanner from './ErrorBanner.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 _ from 'lodash';
import { withContext } from './util/AppContext.jsx';
import { addUrlProps, UrlQueryParamTypes } from 'react-url-query';
import { emptyTapQuery, processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
import './../../css/tap.css';
const urlPropsQueryConfig = {
autostart: { type: UrlQueryParamTypes.string }
@ -169,6 +168,7 @@ class Tap extends React.Component {
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].key = d.id;
resultIndex[d.id].lastUpdated = Date.now();
}
@ -313,8 +313,6 @@ class Tap extends React.Component {
updateQuery={this.updateQuery}
query={this.state.query} />
<TapQueryCliCmd cmdName="tap" query={this.state.query} />
<TapEventTable
resource={this.state.query.resource}
tableRows={tableRows} />

View File

@ -1,11 +1,21 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { withContext } from './util/AppContext.jsx';
import { Col, Icon, Row, Table } from 'antd';
import { directionColumn, srcDstColumn } from './util/TapUtils.jsx';
import { formatLatencySec, formatWithComma } from './util/Utils.js';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CircularProgress from '@material-ui/core/CircularProgress';
import ExpandableTable from './ExpandableTable.jsx';
import Grid from '@material-ui/core/Grid';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import PropTypes from 'prop-types';
import React from 'react';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
import { withContext } from './util/AppContext.jsx';
import { withStyles } from '@material-ui/core/styles';
// https://godoc.org/google.golang.org/grpc/codes#Code
const grpcStatusCodes = {
0: "OK",
@ -27,59 +37,66 @@ const grpcStatusCodes = {
16: "Unauthenticated"
};
const smallMetricColWidth = "120px";
const spinnerStyles = theme => ({
progress: {
margin: theme.spacing.unit * 2,
},
});
const SpinnerBase = () => <CircularProgress size={20} />;
const Spinner = withStyles(spinnerStyles)(SpinnerBase);
const httpStatusCol = {
title: "HTTP status",
key: "http-status",
width: smallMetricColWidth,
dataIndex: "responseInit.http.responseInit",
render: d => !d ? <Icon type="loading" /> : d.httpStatus
render: datum => {
let d = _.get(datum, "responseInit.http.responseInit");
return !d ? <Spinner /> : d.httpStatus;
}
};
const responseInitLatencyCol = {
title: "Latency",
key: "rsp-latency",
width: smallMetricColWidth,
dataIndex: "responseInit.http.responseInit",
render: d => !d ? <Icon type="loading" /> : formatTapLatency(d.sinceRequestInit)
isNumeric: true,
render: datum => {
let d = _.get(datum, "responseInit.http.responseInit");
return !d ? <Spinner /> : formatTapLatency(d.sinceRequestInit);
}
};
const grpcStatusCol = {
title: "GRPC status",
key: "grpc-status",
width: smallMetricColWidth,
dataIndex: "responseEnd.http.responseEnd",
render: d => !d ? <Icon type="loading" /> :
_.isNull(d.eos) ? "---" : grpcStatusCodes[_.get(d, "eos.grpcStatusCode")]
render: datum => {
let d = _.get(datum, "responseEnd.http.responseEnd");
return !d ? <Spinner /> :
_.isNull(d.eos) ? "---" : grpcStatusCodes[_.get(d, "eos.grpcStatusCode")];
}
};
const pathCol = {
title: "Path",
key: "path",
dataIndex: "requestInit.http.requestInit",
render: d => !d ? <Icon type="loading" /> : d.path
render: datum => {
let d = _.get(datum, "requestInit.http.requestInit");
return !d ? <Spinner /> : d.path;
}
};
const methodCol = {
title: "Method",
key: "method",
dataIndex: "requestInit.http.requestInit",
render: d => !d ? <Icon type="loading" /> : _.get(d, "method.registered")
render: datum => {
let d = _.get(datum, "requestInit.http.requestInit");
return !d ? <Spinner /> : _.get(d, "method.registered");
}
};
const topLevelColumns = (resourceType, ResourceLink) => [
{
title: "Direction",
key: "direction",
dataIndex: "base.proxyDirection",
width: "98px",
filters: [
{ text: "FROM", value: "INBOUND" },
{ text: "TO", value: "OUTBOUND" }
],
render: directionColumn,
onFilter: (value, row) => _.get(row, "base.proxyDirection").includes(value)
render: d => directionColumn(d.base.proxyDirection)
},
{
title: "Name",
@ -108,72 +125,73 @@ const formatTapLatency = str => {
return formatLatencySec(str.replace("s", ""));
};
const itemDisplay = (title, value) => {
return (
<ListItem disableGutters>
<ListItemText primary={title} secondary={value} />
</ListItem>
);
};
const requestInitSection = d => (
<React.Fragment>
<Row gutter={8} className="tap-info-section">
<h3>Request Init</h3>
<Row gutter={8} className="expand-section-header">
<Col span={8}>Authority</Col>
<Col span={9}>Path</Col>
<Col span={2}>Scheme</Col>
<Col span={2}>Method</Col>
<Col span={3}>TLS</Col>
</Row>
<Row gutter={8}>
<Col span={8}>{_.get(d, "requestInit.http.requestInit.authority")}</Col>
<Col span={9}>{_.get(d, "requestInit.http.requestInit.path")}</Col>
<Col span={2}>{_.get(d, "requestInit.http.requestInit.scheme.registered")}</Col>
<Col span={2}>{_.get(d, "requestInit.http.requestInit.method.registered")}</Col>
<Col span={3}>{_.get(d, "base.tls")}</Col>
</Row>
</Row>
<Typography variant="subtitle2">Request Init</Typography>
<br />
<List dense>
{itemDisplay("Authority", _.get(d, "requestInit.http.requestInit.authority"))}
{itemDisplay("Path", _.get(d, "requestInit.http.requestInit.path"))}
{itemDisplay("Scheme", _.get(d, "requestInit.http.requestInit.scheme.registered"))}
{itemDisplay("Method", _.get(d, "requestInit.http.requestInit.method.registered"))}
{itemDisplay("TLS", _.get(d, "base.tls"))}
</List>
</React.Fragment>
);
const responseInitSection = d => _.isEmpty(d.responseInit) ? null : (
<React.Fragment>
<hr />
<Row gutter={8} className="tap-info-section">
<h3>Response Init</h3>
<Row gutter={8} className="expand-section-header">
<Col span={4}>HTTP Status</Col>
<Col span={4}>Latency</Col>
</Row>
<Row gutter={8}>
<Col span={4}>{_.get(d, "responseInit.http.responseInit.httpStatus")}</Col>
<Col span={4}>{formatTapLatency(_.get(d, "responseInit.http.responseInit.sinceRequestInit"))}</Col>
</Row>
</Row>
<Typography variant="subtitle2">Response Init</Typography>
<br />
<List dense>
{itemDisplay("HTTP Status", _.get(d, "responseInit.http.responseInit.httpStatus"))}
{itemDisplay("Latency", formatTapLatency(_.get(d, "responseInit.http.responseInit.sinceRequestInit")))}
</List>
</React.Fragment>
);
const responseEndSection = d => _.isEmpty(d.responseEnd) ? null : (
<React.Fragment>
<hr />
<Row gutter={8} className="tap-info-section">
<h3>Response End</h3>
<Row gutter={8} className="expand-section-header">
<Col span={4}>GRPC Status</Col>
<Col span={4}>Latency</Col>
<Col span={4}>Response Length (B)</Col>
</Row>
<Row gutter={8}>
<Col span={4}>{_.isNull(_.get(d, "responseEnd.http.responseEnd.eos")) ? "N/A" : grpcStatusCodes[_.get(d, "responseEnd.http.responseEnd.eos.grpcStatusCode")]}</Col>
<Col span={4}>{formatTapLatency(_.get(d, "responseEnd.http.responseEnd.sinceResponseInit"))}</Col>
<Col span={4}>{formatWithComma(_.get(d, "responseEnd.http.responseEnd.responseBytes"))}</Col>
</Row>
</Row>
<Typography variant="subtitle2">Response End</Typography>
<br />
<List dense>
{itemDisplay("GRPC Status", _.isNull(_.get(d, "responseEnd.http.responseEnd.eos")) ? "N/A" : grpcStatusCodes[_.get(d, "responseEnd.http.responseEnd.eos.grpcStatusCode")])}
{itemDisplay("Latency", formatTapLatency(_.get(d, "responseEnd.http.responseEnd.sinceResponseInit")))}
{itemDisplay("Response Length (B)", formatWithComma(_.get(d, "responseEnd.http.responseEnd.responseBytes")))}
</List>
</React.Fragment>
);
// hide verbose information
const expandedRowRender = d => {
return (
<div className="tap-more-info">
{requestInitSection(d)}
{responseInitSection(d)}
{responseEndSection(d)}
</div>
<Grid container spacing={16} className="tap-more-info">
<Grid item xs={4}>
<Card>
<CardContent>{requestInitSection(d)}</CardContent>
</Card>
</Grid>
<Grid item xs={4}>
<Card>
<CardContent>{responseInitSection(d)}</CardContent>
</Card>
</Grid>
<Grid item xs={4}>
<Card>
<CardContent>{responseEndSection(d)}</CardContent>
</Card>
</Grid>
</Grid>
);
};
@ -192,17 +210,16 @@ class TapEventTable extends React.Component {
}
render() {
let resourceType = this.props.resource.split("/")[0];
const { tableRows, resource, api } = this.props;
let resourceType = resource.split("/")[0];
let columns = tapColumns(resourceType, api.ResourceLink);
return (
<Table
dataSource={this.props.tableRows}
columns={tapColumns(resourceType, this.props.api.ResourceLink)}
expandedRowRender={expandedRowRender} // hide extra info in expandable row
expandRowByClick={true} // click anywhere on row to expand
rowKey={r => r.base.id}
pagination={false}
className="tap-event-table"
size="middle" />
<ExpandableTable
tableRows={tableRows}
tableColumns={columns}
expandedRowRender={expandedRowRender}
tableClassName="metric-table" />
);
}
}

View File

@ -1,6 +1,6 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
const TapLink = ({PrefixedLink, namespace, resource, toNamespace, toResource, path}) => {
let params = {

View File

@ -1,6 +1,11 @@
import _ from 'lodash';
import {
CardContent,
Typography
} from '@material-ui/core';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { tapQueryPropType } from './util/TapUtils.jsx';
/*
@ -45,27 +50,27 @@ export default class TapQueryCliCmd extends React.Component {
} = this.props.query;
return (
<div className="tap-query">
{
_.isEmpty(resource) ? null :
<React.Fragment>
<div>Current {_.startCase(this.props.cmdName)} query:</div>
<code>
_.isEmpty(resource) ? null :
<CardContent className="tap-query">
<Typography variant="caption" gutterBottom>
Current {_.startCase(this.props.cmdName)} query
</Typography>
<code>
linkerd {this.props.cmdName} {resource}
{ resource.indexOf("namespace") === 0 ? null : this.renderCliItem("--namespace", namespace) }
{ this.renderCliItem("--to", toResource) }
{ _.isEmpty(toResource) || toResource.indexOf("namespace") === 0 ? null :
{ resource.indexOf("namespace") === 0 ? null : this.renderCliItem("--namespace", namespace) }
{ this.renderCliItem("--to", toResource) }
{
_.isEmpty(toResource) || toResource.indexOf("namespace") === 0 ? null :
this.renderCliItem("--to-namespace", toNamespace)
}
{ this.renderCliItem("--method", method) }
{ this.renderCliItem("--scheme", scheme) }
{ this.renderCliItem("--authority", authority) }
{ this.renderCliItem("--path", path) }
{ this.renderCliItem("--max-rps", maxRps) }
</code>
</React.Fragment>
}
</div>
}
{ this.renderCliItem("--method", method) }
{ this.renderCliItem("--scheme", scheme) }
{ this.renderCliItem("--authority", authority) }
{ this.renderCliItem("--path", path) }
{ this.renderCliItem("--max-rps", maxRps) }
</code>
</CardContent>
);
}
}

View File

@ -1,27 +1,34 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { addUrlProps, UrlQueryParamTypes } from 'react-url-query';
import {
AutoComplete,
Button,
Col,
Form,
Icon,
Input,
Row,
Select
} from 'antd';
Card,
CardContent,
ExpansionPanel,
ExpansionPanelDetails,
ExpansionPanelSummary,
FormControl,
FormHelperText,
Grid,
InputLabel,
MenuItem,
Select,
TextField,
Typography
} from '@material-ui/core';
import { UrlQueryParamTypes, addUrlProps } from 'react-url-query';
import {
defaultMaxRps,
emptyTapQuery,
httpMethods,
tapQueryProps,
tapQueryPropType
tapQueryPropType,
tapQueryProps
} from './util/TapUtils.jsx';
const colSpan = 5;
const rowGutter = 16;
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import PropTypes from 'prop-types';
import React from 'react';
import TapQueryCliCmd from './TapQueryCliCmd.jsx';
import _ from 'lodash';
import { withStyles } from '@material-ui/core/styles';
// you can also tap resources to tap all pods in the resource
const resourceTypes = [
@ -40,8 +47,44 @@ const urlPropsQueryConfig = _.mapValues(tapQueryProps, () => {
return { type: UrlQueryParamTypes.string };
});
const styles = theme => ({
root: {
display: 'flex',
flexWrap: 'wrap',
},
formControl: {
margin: theme.spacing.unit,
minWidth: 200,
},
selectEmpty: {
'margin-top': '32px'
},
card: {
maxWidth: "100%",
},
actions: {
display: 'flex',
'padding-left': '32px'
},
expand: {
transform: 'rotate(0deg)',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
marginLeft: 'auto',
[theme.breakpoints.up('sm')]: {
marginRight: -8,
},
},
expandOpen: {
transform: 'rotate(180deg)',
}
});
class TapQueryForm extends React.Component {
static propTypes = {
classes: PropTypes.shape({}).isRequired,
enableAdvancedForm: PropTypes.bool,
handleTapClear: PropTypes.func,
handleTapStart: PropTypes.func.isRequired,
@ -58,31 +101,6 @@ class TapQueryForm extends React.Component {
tapIsClosing: false
}
constructor(props) {
super(props);
let query = _.merge({}, props.query, _.pick(this.props, _.keys(tapQueryProps)));
props.updateQuery(query);
let showAdvancedForm = _.some(
_.omit(query, ['namespace', 'resource']),
v => !_.isEmpty(v));
this.state = {
query,
showAdvancedForm,
authoritiesByNs: {},
resourcesByNs: {},
autocomplete: {
namespace: [],
resource: [],
toNamespace: [],
toResource: [],
authority: []
},
};
}
static getDerivedStateFromProps(props, state) {
if (!_.isEqual(props.resourcesByNs, state.resourcesByNs)) {
let resourcesByNs = props.resourcesByNs;
@ -108,29 +126,49 @@ class TapQueryForm extends React.Component {
}
}
toggleAdvancedForm = show => {
this.setState({
showAdvancedForm: show
});
constructor(props) {
super(props);
let query = _.merge({}, props.query, _.pick(this.props, _.keys(tapQueryProps)));
props.updateQuery(query);
let advancedFormExpanded = _.some(
_.omit(query, ['namespace', 'resource']),
v => !_.isEmpty(v));
this.state = {
query,
advancedFormExpanded,
authoritiesByNs: {},
resourcesByNs: {},
autocomplete: {
namespace: [],
resource: [],
toNamespace: [],
toResource: [],
authority: []
},
};
}
handleFormChange = (name, scopeResource, shouldScopeAuthority) => {
handleFormChange = (name, scopeResource) => {
let state = {
query: this.state.query,
autocomplete: this.state.autocomplete
};
return formVal => {
let shouldScopeAuthority = name === "namespace";
return event => {
let formVal = event.target.value;
state.query[name] = formVal;
this.handleUrlUpdate(name, formVal);
if (!_.isNil(scopeResource)) {
// scope the available typeahead resources to the selected namespace
state.autocomplete[scopeResource] = this.state.resourcesByNs[formVal];
if (_.isEmpty(state.query[scopeResource]) || state.query[scopeResource].indexOf("namespace") !== -1) {
state.query[scopeResource] = `namespace/${formVal}`;
this.handleUrlUpdate(scopeResource, `namespace/${formVal}`);
}
state.query[scopeResource] = `namespace/${formVal}`;
this.handleUrlUpdate(scopeResource, `namespace/${formVal}`);
}
if (shouldScopeAuthority) {
@ -162,6 +200,10 @@ class TapQueryForm extends React.Component {
};
}
handleAdvancedFormExpandClick = () => {
this.setState(state => ({advancedFormExpanded: !state.advancedFormExpanded}));
}
autoCompleteData = name => {
return _(this.state.autocomplete[name])
.filter(d => d.indexOf(this.state.query[name]) !== -1)
@ -183,106 +225,10 @@ class TapQueryForm extends React.Component {
this.props.handleTapClear();
}
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")}
value={this.state.query.toNamespace}>
{
_.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>
{this.renderResourceSelect("toResource", "toNamespace")}
</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"
value={this.state.query.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")}
value={this.state.query.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")}
value={this.state.query.scheme} />
</Form.Item>
</Col>
<Col span={colSpan}>
<Form.Item
extra={`Maximum requests per second to tap (default ${defaultMaxRps})`}>
<Input
placeholder="Max RPS"
onChange={this.handleFormEvent("maxRps")}
value={this.state.query.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")}
value={this.state.query.method}>
{
_.map(httpMethods, m =>
<Select.Option key={`method-select-${m}`} value={m}>{m}</Select.Option>
)
}
</Select>
</Form.Item>
</Col>
</Row>
</React.Fragment>
);
}
renderResourceSelect = (resourceKey, namespaceKey) => {
let selectedNs = this.state.query[namespaceKey];
let nsEmpty = _.isNil(selectedNs) || _.isEmpty(selectedNs);
let { classes } = this.props;
let resourceOptions = _.concat(
resourceTypes,
@ -291,36 +237,53 @@ class TapQueryForm extends React.Component {
);
return (
<Select
showSearch
allowClear
disabled={nsEmpty}
value={nsEmpty ? _.startCase(resourceKey) : this.state.query[resourceKey]}
placeholder={_.startCase(resourceKey)}
optionFilterProp="children"
onChange={this.handleFormChange(resourceKey)}>
{
_.map(_.sortBy(resourceOptions), resource => (
<Select.Option
key={`${resourceKey}-${resource}`}
value={resource}>{resource}
</Select.Option>
)
)
}
</Select>
<React.Fragment>
<InputLabel htmlFor={resourceKey}>{_.startCase(resourceKey)}</InputLabel>
<Select
value={nsEmpty ? _.startCase(resourceKey) : this.state.query[resourceKey]}
onChange={this.handleFormChange(resourceKey)}
inputProps={{ name: resourceKey, id: resourceKey }}
className={classes.selectEmpty}>
{
_.map(_.sortBy(resourceOptions), resource => (
<MenuItem key={`${namespaceKey}-${resourceKey}-${resource}`} value={resource}>{resource}</MenuItem>
))
}
</Select>
</React.Fragment>
);
}
renderNamespaceSelect = (title, namespaceKey, resourceKey) => {
let { classes } = this.props;
return (
<React.Fragment>
<InputLabel htmlFor={namespaceKey}>{title}</InputLabel>
<Select
value={this.state.query[namespaceKey]}
onChange={this.handleFormChange(namespaceKey, resourceKey)}
inputProps={{ name: namespaceKey, id: namespaceKey }}
className={classes.selectEmpty}>
{
_.map(this.state.autocomplete[namespaceKey], (n, i) => (
<MenuItem key={`ns-dr-${i}`} value={n}>{n}</MenuItem>
))
}
</Select>
</React.Fragment>
);
}
renderTapButton = (tapInProgress, tapIsClosing) => {
if (tapIsClosing) {
return (<Button type="primary" className="tap-ctrl tap-stop" disabled={true}>Stop</Button>);
return (<Button variant="outlined" color="primary" className="tap-ctrl tap-stop" disabled={true}>Stop</Button>);
} else if (tapInProgress) {
return (<Button type="primary" className="tap-ctrl tap-stop" onClick={this.props.handleTapStop}>Stop</Button>);
return (<Button variant="outlined" color="primary" className="tap-ctrl tap-stop" onClick={this.props.handleTapStop}>Stop</Button>);
} else {
return (
<Button
type="primary"
color="primary"
variant="outlined"
className="tap-ctrl tap-start"
disabled={!this.state.query.namespace || !this.state.query.resource}
onClick={this.props.handleTapStart}>
@ -329,59 +292,143 @@ class TapQueryForm extends React.Component {
}
}
render() {
renderTextInput = (title, key, helperText) => {
let { classes } = this.props;
return (
<Form className="tap-form">
<Row gutter={rowGutter}>
<Col span={colSpan}>
<Form.Item>
<TextField
id={key}
label={title}
className={classes.textField}
value={this.state.query[key]}
onChange={this.handleFormEvent(key)}
helperText={helperText}
margin="normal" />
);
}
renderAdvancedTapFormContent() {
const { classes } = this.props;
return (
<Grid container spacing={24}>
<Grid item xs={6} md={3}>
<FormControl className={classes.formControl}>
{this.renderNamespaceSelect("To Namespace", "toNamespace", "toResource")}
</FormControl>
</Grid>
<Grid item xs={6} md={3}>
<FormControl className={classes.formControl} disabled={_.isEmpty(this.state.query.toNamespace)}>
{this.renderResourceSelect("toResource", "toNamespace")}
</FormControl>
</Grid>
<Grid container spacing={24}>
<Grid item xs={6} md={3}>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="authority">Authority</InputLabel>
<Select
showSearch
allowClear
placeholder="Namespace"
optionFilterProp="children"
onChange={this.handleFormChange("namespace", "resource", true)}
value={this.state.query.namespace}>
value={this.state.query.authority}
onChange={this.handleFormChange("authority")}
inputProps={{ name: 'authority', id: 'authority' }}
className={classes.selectEmpty}>
{
_.map(this.state.autocomplete.namespace, (n, i) => (
<Select.Option key={`ns-dr-${i}`} value={n}>{n}</Select.Option>
_.map(this.state.autocomplete.authority, (d, i) => (
<MenuItem key={`authority-${i}`} value={d}>{d}</MenuItem>
))
}
</Select>
</Form.Item>
</Col>
<FormHelperText>Display requests with this :authority</FormHelperText>
</FormControl>
</Grid>
<Col span={colSpan}>
<Form.Item>
{this.renderResourceSelect("resource", "namespace")}
</Form.Item>
</Col>
<Grid item xs={6} md={3}>
{ this.renderTextInput("Path", "path", "Display requests with paths that start with this prefix") }
</Grid>
</Grid>
<Col span={colSpan}>
<Form.Item>
<Grid container spacing={24}>
<Grid item xs={6} md={3}>
{ this.renderTextInput("Scheme", "scheme", "Display requests with this scheme") }
</Grid>
<Grid item xs={6} md={3}>
{ this.renderTextInput("Max RPS", "maxRps", `Maximum requests per second to tap. Default ${defaultMaxRps}`) }
</Grid>
<Grid item xs={6} md={3}>
<FormControl className={classes.formControl}>
<InputLabel htmlFor="method">HTTP method</InputLabel>
<Select
value={this.state.query.method}
onChange={this.handleFormChange("method")}
inputProps={{ name: 'method', id: 'method' }}
className={classes.selectEmpty}>
{
_.map(httpMethods, d => (
<MenuItem key={`method-${d}`} value={d}>{d}</MenuItem>
))
}
</Select>
<FormHelperText>Display requests with this HTTP method</FormHelperText>
</FormControl>
</Grid>
</Grid>
</Grid>
);
}
renderAdvancedTapForm() {
return (
<ExpansionPanel expanded={this.state.advancedFormExpanded} onChange={this.handleAdvancedFormExpandClick}>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="caption" gutterBottom>
{this.state.advancedFormExpanded ? "Hide filters" : "Show more filters"}
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails>
{this.renderAdvancedTapFormContent()}
</ExpansionPanelDetails>
</ExpansionPanel>
);
}
render() {
const { classes } = this.props;
return (
<Card className={classes.card}>
<CardContent>
<Grid container spacing={24}>
<Grid item xs={6} md={3}>
<FormControl className={classes.formControl}>
{this.renderNamespaceSelect("Namespace", "namespace", "resource")}
</FormControl>
</Grid>
<Grid item xs={6} md={3}>
<FormControl className={classes.formControl} disabled={_.isEmpty(this.state.query.namespace)}>
{this.renderResourceSelect("resource", "namespace")}
</FormControl>
</Grid>
<Grid item xs={4} md={1}>
{ this.renderTapButton(this.props.tapRequestInProgress, this.props.tapIsClosing) }
</Grid>
<Grid item xs={4} md={1}>
<Button onClick={this.resetTapForm} disabled={this.props.tapRequestInProgress}>Reset</Button>
</Form.Item>
</Col>
</Row>
</Grid>
</Grid>
</CardContent>
{
!this.props.enableAdvancedForm ? null :
<React.Fragment>
<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>
<TapQueryCliCmd cmdName="tap" query={this.state.query} />
{ !this.state.showAdvancedForm ? null : this.renderAdvancedTapForm() }
</React.Fragment>
}
</Form>
{ !this.props.enableAdvancedForm ? null : this.renderAdvancedTapForm() }
</Card>
);
}
}
export default addUrlProps({ urlPropsQueryConfig })(TapQueryForm);
export default addUrlProps({ urlPropsQueryConfig })(withStyles(styles)(TapQueryForm));

View File

@ -1,13 +1,11 @@
import _ from 'lodash';
import { emptyTapQuery } from './util/TapUtils.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import TapQueryCliCmd from './TapQueryCliCmd.jsx';
import TapQueryForm from './TapQueryForm.jsx';
import TopModule from './TopModule.jsx';
import _ from 'lodash';
import { emptyTapQuery } from './util/TapUtils.jsx';
import { withContext } from './util/AppContext.jsx';
import './../../css/tap.css';
class Top extends React.Component {
static propTypes = {
@ -167,7 +165,6 @@ class Top extends React.Component {
updateQuery={this.updateQuery}
query={this.state.query} />
<TapQueryCliCmd cmdName="top" query={this.state.query} />
<TopModule
pathPrefix={this.props.pathPrefix}
query={this.state.query}

View File

@ -1,82 +1,68 @@
import { directionColumn, srcDstColumn, tapLink } from './util/TapUtils.jsx';
import BaseTable from './BaseTable.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { formatLatencySec } from './util/Utils.js';
import { successRateWithMiniChart } from './util/MetricUtils.jsx';
import { Table } from 'antd';
import { withContext } from './util/AppContext.jsx';
import { directionColumn, srcDstColumn, tapLink } from './util/TapUtils.jsx';
import { formatLatencySec, numericSort } from './util/Utils.js';
const topMetricColWidth = "85px";
const topColumns = (resourceType, ResourceLink, PrefixedLink) => [
{
title: " ",
key: "direction",
dataIndex: "direction",
width: "60px",
render: directionColumn
render: d => directionColumn(d.direction)
},
{
title: "Name",
key: "src-dst",
width: "180px",
render: d => srcDstColumn(d, resourceType, ResourceLink)
},
{
title: "Method",
dataIndex: "httpMethod",
width: "95px",
sorter: (a, b) => a.httpMethod.localeCompare(b.httpMethod),
key: "httpMethod",
render: d => d.httpMethod,
},
{
title: "Path",
dataIndex: "path",
sorter: (a, b) => a.path.localeCompare(b.path),
key: "path",
render: d => d.path
},
{
title: "Count",
dataIndex: "count",
className: "numeric",
width: topMetricColWidth,
defaultSortOrder: "descend",
sorter: (a, b) => numericSort(a.count, b.count),
key: "count",
isNumeric: true,
render: d => d.count
},
{
title: "Best",
dataIndex: "best",
className: "numeric",
width: topMetricColWidth,
sorter: (a, b) => numericSort(a.best, b.best),
render: formatLatencySec
key: "best",
isNumeric: true,
render: d => formatLatencySec(d.best)
},
{
title: "Worst",
dataIndex: "worst",
className: "numeric",
width: topMetricColWidth,
sorter: (a, b) => numericSort(a.worst, b.worst),
render: formatLatencySec
key: "worst",
isNumeric: true,
render: d => formatLatencySec(d.worst)
},
{
title: "Last",
dataIndex: "last",
className: "numeric",
width: topMetricColWidth,
sorter: (a, b) => numericSort(a.last, b.last),
render: formatLatencySec
key: "last",
isNumeric: true,
render: d => formatLatencySec(d.last)
},
{
title: "Success Rate",
dataIndex: "successRate",
className: "numeric",
width: "128px",
sorter: (a, b) => numericSort(a.successRate.get(), b.successRate.get()),
render: d => successRateWithMiniChart(d.get())
key: "successRate",
isNumeric: true,
render: d => _.isNil(d) || !d.get ? "---" : successRateWithMiniChart(d.get())
},
{
title: "Tap",
key: "tap",
className: "numeric",
width: "30px",
isNumeric: true,
render: d => tapLink(d, resourceType, PrefixedLink)
}
];
@ -85,27 +71,19 @@ class TopEventTable extends React.Component {
static propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
ResourceLink: PropTypes.func.isRequired,
}).isRequired,
resourceType: PropTypes.string.isRequired,
tableRows: PropTypes.arrayOf(PropTypes.shape({})),
}
tableRows: PropTypes.arrayOf(PropTypes.shape({}))
};
static defaultProps = {
tableRows: []
};
static defaultProps = {
tableRows: []
}
render() {
return (
<Table
dataSource={this.props.tableRows}
columns={topColumns(this.props.resourceType, this.props.api.ResourceLink, this.props.api.PrefixedLink)}
rowKey="key"
pagination={false}
className="top-event-table metric-table"
size="middle" />
);
}
render() {
const { tableRows, resourceType, api } = this.props;
let columns = topColumns(resourceType, api.ResourceLink, api.PrefixedLink);
return <BaseTable tableRows={tableRows} tableColumns={columns} tableClassName="metric-table" />;
}
}
export default withContext(TopEventTable);

View File

@ -1,11 +1,12 @@
import _ from 'lodash';
import { processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import Percentage from './util/Percentage.js';
import PropTypes from 'prop-types';
import React from 'react';
import TopEventTable from './TopEventTable.jsx';
import _ from 'lodash';
import { withContext } from './util/AppContext.jsx';
import { processTapEvent, setMaxRps, wsCloseCodes } from './util/TapUtils.jsx';
class TopModule extends React.Component {
static propTypes = {

View File

@ -1,7 +1,7 @@
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import { Link } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import PropTypes from 'prop-types';
import React from 'react';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import { withContext } from './util/AppContext.jsx';
class Version extends React.Component {
@ -52,13 +52,14 @@ class Version extends React.Component {
return (
<div>
A new version ({this.numericVersion(latestVersion)}) is available.<br />
<Link
to="https://versioncheck.linkerd.io/update"
className="button primary"
target="_blank">
<div className="new-version-text">A new version ({this.numericVersion(latestVersion)}) is available.</div>
<Button
variant="contained"
color="primary"
target="_blank"
href="https://versioncheck.linkerd.io/update">
Update Now
</Link>
</Button>
</div>
);
}

View File

@ -0,0 +1,83 @@
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExpansionPanel from '@material-ui/core/ExpansionPanel';
import ExpansionPanelDetails from '@material-ui/core/ExpansionPanelDetails';
import ExpansionPanelSummary from '@material-ui/core/ExpansionPanelSummary';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { withStyles } from '@material-ui/core/styles';
const styles = theme => ({
root: {
width: '100%',
},
heading: {
fontSize: theme.typography.pxToRem(15),
flexBasis: '33.33%',
flexShrink: 0,
},
secondaryHeading: {
fontSize: theme.typography.pxToRem(15),
color: theme.palette.text.secondary,
},
});
class Accordion extends React.Component {
state = {
expanded: this.props.defaultOpenPanel || null,
};
handlePanelSelect = panel => (_event, expanded) => {
this.setState({
expanded: expanded ? panel : false,
});
if (expanded) {
this.props.onChange(panel);
}
};
render() {
const { classes, panels } = this.props;
const { expanded } = this.state;
return (
<div className={classes.root}>
{
_.map(panels, panel => (
<ExpansionPanel
expanded={expanded === panel.id}
onChange={this.handlePanelSelect(panel.id)}
key={panel.id}>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />}>
{panel.header}
</ExpansionPanelSummary>
<ExpansionPanelDetails>
{panel.body || "No data present."}
</ExpansionPanelDetails>
</ExpansionPanel>
)
)
}
</div>
);
}
}
Accordion.propTypes = {
classes: PropTypes.shape({}).isRequired,
defaultOpenPanel: PropTypes.string,
onChange: PropTypes.func.isRequired,
panels: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
header: PropTypes.node,
body: PropTypes.node
}))
};
Accordion.defaultProps = {
defaultOpenPanel: null,
panels: []
};
export default withStyles(styles)(Accordion);

View File

@ -1,8 +1,9 @@
import _ from 'lodash';
import 'whatwg-fetch';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import React from 'react';
import 'whatwg-fetch';
import _ from 'lodash';
const checkFetchOk = resp => {
if (resp.ok) {
@ -132,11 +133,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
}
render() {
let prefix = pathPrefix;
if (!_.isEmpty(this.props.deployment)) {
prefix = prefix.replace("/web:", "/"+this.props.deployment+":");
}
let url = `${prefix}${this.props.to}`;
let url = prefixLink(this.props.to, this.props.deployment);
return (
<Link
@ -148,7 +145,20 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
}
}
const prefixLink = (to, controllerDeployment) => {
let prefix = pathPrefix;
if (!_.isEmpty(controllerDeployment)) { // add field for grafana deployment
prefix = prefix.replace("/web:", "/" + controllerDeployment + ":");
}
return `${prefix}${to}`;
};
const generateResourceURL = r => {
if (r.type === "namespace") {
return "/namespaces/" + (r.namespace || r.name);
}
return "/namespaces/" + r.namespace + "/" + r.type + "s/" + r.name;
};
@ -183,6 +193,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
getMetricsWindowDisplayText,
urlsForResource,
PrefixedLink,
prefixLink,
ResourceLink,
setCurrentRequests,
getCurrentPromises,

View File

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

View File

@ -1,17 +1,17 @@
import _ from 'lodash';
import { metricToFormatter } from './Utils.js';
import Grid from '@material-ui/core/Grid';
import Percentage from './Percentage.js';
import { Progress } from 'antd';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { metricToFormatter } from './Utils.js';
const getPodCategorization = pod => {
if (pod.added && pod.status === "Running") {
return "good";
} else if (pod.status === "Pending" || pod.status === "Running") {
return "neutral";
return "default";
} else if (pod.status === "Failed") {
return "bad";
return "poor";
}
return ""; // Terminating | Succeeded | Unknown
};
@ -22,7 +22,7 @@ export const getSuccessRateClassification = (rate, successRateLabels) => {
}
if (rate < 0.9) {
return successRateLabels.bad;
return successRateLabels.poor;
} else if (rate < 0.95) {
return successRateLabels.neutral;
} else {
@ -31,24 +31,17 @@ export const getSuccessRateClassification = (rate, successRateLabels) => {
};
export const srArcClassLabels = {
good: "status-good",
neutral: "status-ok",
bad: "status-poor",
default: "status-ok"
good: "good",
neutral: "neutral",
poor: "poor",
default: "default"
};
export const successRateWithMiniChart = sr => (
<div>
<span className="metric-table-sr">{metricToFormatter["SUCCESS_RATE"](sr)}</span>
<Progress
className={`success-rate-arc ${getSuccessRateClassification(sr, srArcClassLabels)} metric-table-sr-chart`}
type="dashboard"
showInfo={false}
width={32}
strokeWidth={12}
percent={sr === 0 ? 100 : sr * 100} // if success rate is 0, we want a red chart, not a gray chart
gapDegree={180} />
</div>
<Grid container spacing={8}>
<Grid item>{metricToFormatter["SUCCESS_RATE"](sr)}</Grid>
<Grid item>{_.isNil(sr) ? null : <div className={`success-rate-dot status-dot status-dot-${getSuccessRateClassification(sr, srArcClassLabels)}`} />}</Grid>
</Grid>
);
const getTotalRequests = row => {
@ -150,6 +143,7 @@ const processStatTable = table => {
let runningPodCount = parseInt(row.runningPodCount, 10);
let meshedPodCount = parseInt(row.meshedPodCount, 10);
return {
key: `${row.resource.namespace}-${row.resource.type}-${row.resource.name}`,
name: row.resource.name,
namespace: row.resource.namespace,
type: row.resource.type,
@ -210,6 +204,7 @@ export const excludeResourcesFromRollup = (rollupMetrics, resourcesToExclude) =>
};
export const emptyMetric = {
key: "",
name: "",
namespace: "",
type: "",

View File

@ -17,6 +17,7 @@ describe('MetricUtils', () => {
name: 'voting',
namespace: 'emojivoto',
type: 'deployment',
key: "emojivoto-deployment-voting",
requestRate: 2.5,
successRate: 0.9,
totalRequests: 150,

View File

@ -0,0 +1,33 @@
import LinearProgress from '@material-ui/core/LinearProgress';
import { withStyles } from '@material-ui/core/styles';
const colorLookup = {
good: {
colorPrimary: '#c8e6c9', // background bar color (lighter)
barColorPrimary: '#388e3c', // inner bar color (darker)
},
neutral: {
colorPrimary: '#ffcc80',
barColorPrimary: '#ef6c00',
},
poor: {
colorPrimary: '#ffebee',
barColorPrimary: '#d32f2f',
},
default: {
colorPrimary: '#e8eaf6',
barColorPrimary: '#3f51b5',
}
};
export const StyledProgress = (classification = "default") => withStyles({
root: {
flexGrow: 1,
},
colorPrimary: {
backgroundColor: colorLookup[classification].colorPrimary,
},
barColorPrimary: {
backgroundColor: colorLookup[classification].barColorPrimary,
},
})(LinearProgress);

View File

@ -0,0 +1,28 @@
import CircularProgress from '@material-ui/core/CircularProgress';
import Grid from '@material-ui/core/Grid';
import PropTypes from 'prop-types';
import React from 'react';
import { withStyles } from '@material-ui/core/styles';
const styles = () => ({
progress: {
"margin": "auto",
},
});
function CircularIndeterminate(props) {
const { classes } = props;
return (
<Grid container justify="center">
<div >
<CircularProgress className={classes.progress} style={{ color: "#26E99D" }} />
</div>
</Grid>
);
}
CircularIndeterminate.propTypes = {
classes: PropTypes.shape({}).isRequired,
};
export default withStyles(styles)(CircularIndeterminate);

View File

@ -1,6 +1,19 @@
import React from 'react';
import './../../../css/svg-wrappers.css';
import React from 'react';
export const slackIcon = (
<svg style={{"width":"24px", "height":"24px"}} viewBox="0 0 24 24">
<path fill="#757575" d="M10.23,11.16L12.91,10.27L13.77,12.84L11.09,13.73L10.23,11.16M17.69,13.71C18.23,13.53 18.5,12.94 18.34,12.4C18.16,11.86 17.57,11.56 17.03,11.75L15.73,12.18L14.87,9.61L16.17,9.17C16.71,9 17,8.4 16.82,7.86C16.64,7.32 16.05,7 15.5,7.21L14.21,7.64L13.76,6.3C13.58,5.76 13,5.46 12.45,5.65C11.91,5.83 11.62,6.42 11.8,6.96L12.25,8.3L9.57,9.19L9.12,7.85C8.94,7.31 8.36,7 7.81,7.2C7.27,7.38 7,7.97 7.16,8.5L7.61,9.85L6.31,10.29C5.77,10.47 5.5,11.06 5.66,11.6C5.8,12 6.19,12.3 6.61,12.31L6.97,12.25L8.27,11.82L9.13,14.39L7.83,14.83C7.29,15 7,15.6 7.18,16.14C7.32,16.56 7.71,16.84 8.13,16.85L8.5,16.79L9.79,16.36L10.24,17.7C10.38,18.13 10.77,18.4 11.19,18.41L11.55,18.35C12.09,18.17 12.38,17.59 12.2,17.04L11.75,15.7L14.43,14.81L14.88,16.15C15,16.57 15.41,16.84 15.83,16.85L16.19,16.8C16.73,16.62 17,16.03 16.84,15.5L16.39,14.15L17.69,13.71M21.17,9.25C23.23,16.12 21.62,19.1 14.75,21.17C7.88,23.23 4.9,21.62 2.83,14.75C0.77,7.88 2.38,4.9 9.25,2.83C16.12,0.77 19.1,2.38 21.17,9.25Z" />
</svg>
);
export const githubIcon = (
<svg style={{"width":"24px", "height":"24px"}} viewBox="0 0 24 24">
<path fill="#757575" d="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z" />
</svg>
);
export const grafanaIcon = (
<svg
version="1.1"

View File

@ -1,9 +1,12 @@
import _ from 'lodash';
import { podOwnerLookup, toShortResourceName } from './Utils.js';
import BaseTable from '../BaseTable.jsx';
import Popover from '../Popover.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import TapLink from '../TapLink.jsx';
import { podOwnerLookup, toShortResourceName } from './Utils.js';
import { Popover, Table, Tooltip } from 'antd';
import Tooltip from '@material-ui/core/Tooltip';
import _ from 'lodash';
export const httpMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"];
@ -192,9 +195,9 @@ const resourceShortLink = (resourceType, labels, ResourceLink) => (
const displayLimit = 3; // how many upstreams/downstreams to display in the popover table
const popoverSrcDstColumns = [
{ title: "Source", dataIndex: "source", key: "source" },
{ title: "", render: () => <i className="fas fa-long-arrow-alt-right" /> },
{ title: "Destination", dataIndex: "destination", key: "destination" }
{ title: "Source", key: "source", render: d => d.source },
{ title: "", key: "arrow", render: () => <i className="fas fa-long-arrow-alt-right" /> },
{ title: "Destination", key: "destination", render: d => d.destination }
];
const getPodOwner = (labels, ResourceLink) => {
@ -266,11 +269,10 @@ const popoverResourceTable = (d, ResourceLink) => {
];
return (
<Table
columns={popoverSrcDstColumns}
dataSource={tableData}
className="metric-table"
pagination={false} />
<BaseTable
tableColumns={popoverSrcDstColumns}
tableRows={tableData}
tableClassName="metric-table" />
);
};
@ -285,10 +287,8 @@ export const extractPodOwner = labels => {
};
export const directionColumn = d => (
<Tooltip
title={d}
overlayStyle={{ fontSize: "12px" }}>
{d === "INBOUND" ? "FROM" : "TO"}
<Tooltip title={d} placement="right">
<span>{d === "INBOUND" ? "FROM" : "TO"}</span>
</Tooltip>
);
@ -304,14 +304,16 @@ export const srcDstColumn = (d, resourceType, ResourceLink) => {
labels = d.destinationLabels;
}
let baseContent = (
<div className="src-dst-name">
{ !_.isEmpty(labels[resourceType]) ? resourceShortLink(resourceType, labels, ResourceLink) : display.str }
</div>
);
return (
<Popover
content={popoverResourceTable(d, ResourceLink)}
trigger="hover">
<div className="src-dst-name">
{ !_.isEmpty(labels[resourceType]) ? resourceShortLink(resourceType, labels, ResourceLink) : display.str }
</div>
</Popover>
popoverContent={popoverResourceTable(d, ResourceLink)}
baseContent={baseContent} />
);
};

View File

@ -1,5 +1,5 @@
import _ from 'lodash';
import * as d3 from 'd3';
import _ from 'lodash';
/*
* Display grid constants
@ -111,11 +111,6 @@ export const toClassName = name => {
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);
/*
Nicely readable names for the stat resources
*/

View File

@ -1,6 +1,6 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { withContext } from './AppContext.jsx';
/**
@ -44,10 +44,10 @@ const withREST = (WrappedComponent, componentPromises, options={}) => {
this.startServerPolling(this.props);
}
componentWillReceiveProps(newProps) {
componentDidUpdate(prevProps) {
const changed = _.filter(
localOptions.resetProps,
prop => _.get(newProps, prop) !== _.get(this.props, prop),
prop => _.get(prevProps, prop) !== _.get(this.props, prop),
);
if (_.isEmpty(changed)) { return; }
@ -55,7 +55,7 @@ const withREST = (WrappedComponent, componentPromises, options={}) => {
// React won't unmount this component when switching resource pages so we need to clear state
this.stopServerPolling();
this.setState(this.getInitialState());
this.startServerPolling(newProps);
this.startServerPolling(this.props);
}
componentWillUnmount() {

View File

@ -1,9 +1,15 @@
import './../css/styles.css';
import './../img/favicon.png'; // needs to be referenced somewhere so webpack bundles it
import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles';
import ApiHelpers from './components/util/ApiHelpers.jsx';
import AppContext from './components/util/AppContext.jsx';
import BreadcrumbHeader from './components/BreadcrumbHeader.jsx';
import { Layout } from 'antd';
import CssBaseline from '@material-ui/core/CssBaseline';
import Namespace from './components/Namespace.jsx';
import NamespaceLanding from './components/NamespaceLanding.jsx';
import Navigation from './components/Navigation.jsx';
import NoMatch from './components/NoMatch.jsx';
import React from 'react';
import ReactDOM from 'react-dom';
@ -11,12 +17,9 @@ import ResourceDetail from './components/ResourceDetail.jsx';
import ResourceList from './components/ResourceList.jsx';
import { RouterToUrlQuery } from 'react-url-query';
import ServiceMesh from './components/ServiceMesh.jsx';
import Sidebar from './components/Sidebar.jsx';
import Tap from './components/Tap.jsx';
import Top from './components/Top.jsx';
import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom';
import './../css/styles.css';
import './../img/favicon.png'; // needs to be referenced somewhere so webpack bundles it
import green from '@material-ui/core/colors/green';
let appMain = document.getElementById('main');
let appData = !appMain ? {} : appMain.dataset;
@ -34,50 +37,72 @@ const context = {
productName: "Linkerd"
};
const theme = createMuiTheme({
palette: {
primary: green
},
typography: {
useNextVariants: true,
suppressDeprecationWarnings: true // https://github.com/mui-org/material-ui/issues/13175
}
});
let applicationHtml = (
<AppContext.Provider value={context}>
<BrowserRouter>
<RouterToUrlQuery>
<Layout>
<Route component={Sidebar} />
<Layout>
<Route component={BreadcrumbHeader} />
<Layout.Content style={{ margin: '60px 0', padding: 0, background: '#fff' }}>
<div className="main-content">
<Switch>
<Redirect exact from={`${pathPrefix}/`} to={`${pathPrefix}/overview`} />
<Route path={`${pathPrefix}/overview`} component={NamespaceLanding} />
<Route path={`${pathPrefix}/servicemesh`} component={ServiceMesh} />
<Route exact path={`${pathPrefix}/namespaces/:namespace`} component={Namespace} />
<Route path={`${pathPrefix}/namespaces/:namespace/pods/:pod`} component={ResourceDetail} />
<Route path={`${pathPrefix}/namespaces/:namespace/deployments/:deployment`} component={ResourceDetail} />
<Route path={`${pathPrefix}/namespaces/:namespace/replicationcontrollers/:replicationcontroller`} component={ResourceDetail} />
<Route path={`${pathPrefix}/tap`} component={Tap} />
<Route path={`${pathPrefix}/top`} component={Top} />
<Route
path={`${pathPrefix}/namespaces`}
render={() => <ResourceList resource="namespace" />} />
<Route
path={`${pathPrefix}/deployments`}
render={() => <ResourceList resource="deployment" />} />
<Route
path={`${pathPrefix}/replicationcontrollers`}
render={() => <ResourceList resource="replicationcontroller" />} />
<Route
path={`${pathPrefix}/pods`}
render={() => <ResourceList resource="pod" />} />
<Route
path={`${pathPrefix}/authorities`}
render={() => <ResourceList resource="authority" />} />
<Route component={NoMatch} />
</Switch>
</div>
</Layout.Content>
</Layout>
</Layout>
</RouterToUrlQuery>
</BrowserRouter>
</AppContext.Provider>
<React.Fragment>
<CssBaseline />
<MuiThemeProvider theme={theme}>
<AppContext.Provider value={context}>
<BrowserRouter>
<RouterToUrlQuery>
<Switch>
<Redirect exact from={`${pathPrefix}/`} to={`${pathPrefix}/overview`} />
<Route
path={`${pathPrefix}/overview`}
render={props => <Navigation {...props} ChildComponent={NamespaceLanding} />} />
<Route
path={`${pathPrefix}/servicemesh`}
render={props => <Navigation {...props} ChildComponent={ServiceMesh} />} />
<Route
exact
path={`${pathPrefix}/namespaces/:namespace`}
render={props => <Navigation {...props} ChildComponent={Namespace} />} />
<Route
path={`${pathPrefix}/namespaces/:namespace/pods/:pod`}
render={props => <Navigation {...props} ChildComponent={ResourceDetail} />} />
<Route
path={`${pathPrefix}/namespaces/:namespace/deployments/:deployment`}
render={props => <Navigation {...props} ChildComponent={ResourceDetail} />} />
<Route
path={`${pathPrefix}/namespaces/:namespace/replicationcontrollers/:replicationcontroller`}
render={props => <Navigation {...props} ChildComponent={ResourceDetail} />} />
<Route
path={`${pathPrefix}/tap`}
render={props => <Navigation {...props} ChildComponent={Tap} />} />
<Route
path={`${pathPrefix}/top`}
render={props => <Navigation {...props} ChildComponent={Top} />} />
<Route
path={`${pathPrefix}/namespaces`}
render={props => <Navigation {...props} ChildComponent={ResourceList} resource="namespace" />} />
<Route
path={`${pathPrefix}/deployments`}
render={props => <Navigation {...props} ChildComponent={ResourceList} resource="deployment" />} />
<Route
path={`${pathPrefix}/replicationcontrollers`}
render={props => <Navigation {...props} ChildComponent={ResourceList} resource="replicationcontroller" />} />
<Route
path={`${pathPrefix}/pods`}
render={props => <Navigation {...props} ChildComponent={ResourceList} resource="pod" />} />
<Route
path={`${pathPrefix}/authorities`}
render={props => <Navigation {...props} ChildComponent={ResourceList} resource="authority" />} />
<Route component={NoMatch} />
</Switch>
</RouterToUrlQuery>
</BrowserRouter>
</AppContext.Provider>
</MuiThemeProvider>
</React.Fragment>
);
ReactDOM.render(applicationHtml, appMain);

View File

@ -4,58 +4,61 @@
"main": "index.js",
"license": "MIT",
"dependencies": {
"antd": "^3.7.2",
"d3": "^4.11.0",
"lodash": "^4.17.10",
"moment": "^2.18.1",
"path": "^0.12.7",
"prop-types": "^15.6.1",
"react": "^16.5.0",
"react-dom": "^16.5.0",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-router-prop-types": "^1.0.3",
"react-url-query": "^1.4.0",
"whatwg-fetch": "^2.0.3"
"@material-ui/core": "3.2.2",
"@material-ui/icons": "3.0.1",
"classnames": "2.2.6",
"d3": "4.11.0",
"lodash": "4.17.10",
"moment": "2.18.1",
"path": "0.12.7",
"prop-types": "15.6.1",
"react": "16.5.0",
"react-dom": "16.5.0",
"react-router": "4.2.0",
"react-router-dom": "4.2.2",
"react-router-prop-types": "^1.0.4",
"react-url-query": "1.4.0",
"whatwg-fetch": "2.0.3"
},
"devDependencies": {
"babel-core": "^6.26.0",
"babel-eslint": "^8.0.3",
"babel-jest": "^23.6.0",
"babel-loader": "^7.1.4",
"babel-plugin-import": "^1.7.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react-app": "^3.1.1",
"css-loader": "^0.28.7",
"enzyme": "^3.7.0",
"enzyme-adapter-react-16": "^1.6.0",
"eslint": "^4.12.1",
"eslint-config-airbnb": "^16.1.0",
"eslint-loader": "^2.0.0",
"eslint-plugin-import": "^2.12.0",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-promise": "^3.6.0",
"eslint-plugin-react": "^7.5.1",
"file-loader": "^1.1.5",
"history": "^4.7.2",
"jest": "^23.6.0",
"jest-dot-reporter": "^1.0.7",
"jest-enzyme": "^7.0.0",
"less": "^3.8.1",
"less-loader": "^4.1.0",
"less-vars-to-js": "^1.3.0",
"postcss": "^6.0.22",
"postcss-cssnext": "^3.1.0",
"postcss-import": "^11.1.0",
"postcss-loader": "^2.1.5",
"react-test-renderer": "^16.5.2",
"sinon": "^7.0.0",
"sinon-stub-promise": "^4.0.0",
"style-loader": "^0.21.0",
"url-loader": "^1.0.1",
"webpack": "^4.20.2",
"webpack-cli": "^3.0.7",
"webpack-dev-server": "^3.1.9"
"babel-core": "6.26.0",
"babel-eslint": "8.0.3",
"babel-jest": "23.6.0",
"babel-loader": "7.1.4",
"babel-plugin-import": "1.7.0",
"babel-preset-env": "1.7.0",
"babel-preset-react-app": "3.1.1",
"babel-runtime": "^6.26.0",
"css-loader": "0.28.7",
"enzyme": "3.7.0",
"enzyme-adapter-react-16": "1.6.0",
"eslint": "4.12.1",
"eslint-config-airbnb": "16.1.0",
"eslint-loader": "2.0.0",
"eslint-plugin-import": "2.12.0",
"eslint-plugin-jsx-a11y": "6.0.3",
"eslint-plugin-promise": "3.6.0",
"eslint-plugin-react": "7.11.1",
"file-loader": "2.0.0",
"history": "4.7.2",
"jest": "23.6.0",
"jest-dot-reporter": "1.0.7",
"jest-enzyme": "7.0.0",
"less": "3.8.1",
"less-loader": "4.1.0",
"less-vars-to-js": "1.3.0",
"postcss": "6.0.22",
"postcss-cssnext": "3.1.0",
"postcss-import": "11.1.0",
"postcss-loader": "2.1.5",
"react-test-renderer": "16.5.2",
"sinon": "7.0.0",
"sinon-stub-promise": "4.0.0",
"style-loader": "0.21.0",
"url-loader": "1.0.1",
"webpack": "4.20.2",
"webpack-cli": "3.0.7",
"webpack-dev-server": "3.1.9"
},
"jest": {
"testEnvironment": "enzyme",

View File

@ -62,7 +62,7 @@ module.exports = {
loader: 'less-loader',
options: {
modifyVars: {
'font-family': '\'Lato\', helvetica, arial, sans-serif'
'font-family': '\'Roboto\', \'Lato\', helvetica, arial, sans-serif'
},
javascriptEnabled: true
}

File diff suppressed because it is too large Load Diff