Add kubernetes style sidebar (#1500)

Linkerd CLI's "look and feel" is similar to Kubernetes kubectl CLI. Linkerd's dashboard can be extended to match Kubernetes dashboard UI.

This PR serves as a starting point for this work. The new sidebar shows all resources from all namespaces on initial page load. Resources can be filtered to show only items in a given namespace. The sidebar displays authority, deployment, service and, pod resources. We may need to think about whether it is necessary to show all resources types. Some resources, i.e. authorities, contain a large cardinality of resource details and may not be very useful to a user.

fixes #1449

Signed-off-by: Dennis Adjei-Baah <dennis@buoyant.io>
This commit is contained in:
Dennis Adjei-Baah 2018-08-27 12:59:37 -07:00 committed by GitHub
parent 27e52a6cc0
commit 097632a2f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 227 additions and 49 deletions

View File

@ -13,6 +13,7 @@ ul.ant-menu.ant-menu-sub {
& .ant-menu-submenu-title, & .anticon{
/* override ant transition animation */
transition: none;
font-size: 16px;
}
}
@ -32,13 +33,18 @@ ul.ant-menu.ant-menu-sub {
& .update {
color: var(--siennared);
}
& .ant-select-selection {
width: 220px;
}
}
& .sidebar-submenu-item {
font-size: 14px;
font-weight: 500;
& a {
color: var(--silver);
color: var(--white);
}
}
@ -46,6 +52,23 @@ ul.ant-menu.ant-menu-sub {
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;
}
@ -68,7 +91,7 @@ ul.ant-menu.ant-menu-sub {
}
& .sidebar-menu-footer {
position: fixed;
margin-top: 24px;
bottom: 120px;
margin-left: calc(var(--base-width)*2);

View File

@ -1,16 +1,29 @@
import _ from 'lodash';
import ApiHelpers from './util/ApiHelpers.jsx';
import { Link } from 'react-router-dom';
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 SocialLinks from './SocialLinks.jsx';
import Version from './Version.jsx';
import { withContext } from './util/AppContext.jsx';
import { Icon, Layout, Menu } from 'antd';
import { linkerdLogoOnly, linkerdWordLogo } from './util/SvgWrappers.jsx';
import {withContext} from './util/AppContext.jsx';
import {Badge, Form, Icon, Layout, Menu, Select} from 'antd';
import {
excludeResourcesFromRollup,
getSuccessRateClassification,
processMultiResourceRollup,
processSingleResourceRollup
} from './util/MetricUtils.js';
import {linkerdLogoOnly, linkerdWordLogo} from './util/SvgWrappers.jsx';
import './../../css/sidebar.css';
const classificationLabels = {
good: "success",
neutral: "warning",
bad: "error"
};
class Sidebar extends React.Component {
static defaultProps = {
productName: 'controller'
@ -29,10 +42,11 @@ class Sidebar extends React.Component {
constructor(props) {
super(props);
this.api= this.props.api;
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();
}
@ -40,13 +54,13 @@ class Sidebar extends React.Component {
getInitialState() {
return {
pollingInterval: 12000,
maxNsItemsToShow: 8,
initialCollapse: true,
initialCollapse: false,
collapsed: true,
error: null,
latestVersion: '',
isLatest: true,
pendingRequests: false
pendingRequests: false,
namespaceFilter: "all"
};
}
@ -79,18 +93,22 @@ class Sidebar extends React.Component {
let versionUrl = `https://versioncheck.linkerd.io/version.json?version=${this.props.releaseVersion}?uuid=${this.props.uuid}`;
this.api.setCurrentRequests([
ApiHelpers("").fetch(versionUrl),
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(([versionRsp, nsRsp]) => {
let nsStats = _.get(nsRsp, ["ok", "statTables", 0, "podGroup", "rows"], []);
let namespaces = _(nsStats).map(r => r.resource.name).sortBy().value();
.then(([versionRsp, 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({
latestVersion: versionRsp.version,
isLatest: versionRsp.version === this.props.releaseVersion,
finalResourceGroups,
namespaces,
pendingRequests: false,
});
@ -115,11 +133,28 @@ class Sidebar extends React.Component {
}
}
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 numHiddenNamespaces = _.size(this.state.namespaces) - this.state.maxNsItemsToShow;
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"
@ -127,7 +162,6 @@ class Sidebar extends React.Component {
collapsed={this.state.collapsed}
collapsible={true}
onCollapse={this.toggleCollapse}>
<div className="sidebar">
<div className={`sidebar-menu-header ${this.state.collapsed ? "collapsed" : ""}`}>
@ -140,7 +174,6 @@ class Sidebar extends React.Component {
className="sidebar-menu"
theme="dark"
selectedKeys={[normalizedPath]}>
<Menu.Item className="sidebar-menu-item" key="/servicemesh">
<PrefixedLink to="/servicemesh">
<Icon type="home" />
@ -169,30 +202,6 @@ class Sidebar extends React.Component {
</PrefixedLink>
</Menu.Item>
{
_.map(_.take(this.state.namespaces, this.state.maxNsItemsToShow), ns => {
return (
<Menu.Item className="sidebar-submenu-item" key={`/namespaces/${ns}`}>
<PrefixedLink to={`/namespaces/${ns}`}>
<Icon>{this.state.collapsed ? _.take(ns, 2) : <span>&nbsp;&nbsp;</span> }</Icon>
<span>{ns} {this.state.collapsed ? "namespace" : ""}</span>
</PrefixedLink>
</Menu.Item>
);
})
}
{ // if we're hiding some namespaces, show a count
numHiddenNamespaces > 0 ?
<Menu.Item className="sidebar-submenu-item" key="extra-items">
<PrefixedLink to="/namespaces">
<Icon>{this.state.collapsed ? <span>...</span> : <span>&nbsp;&nbsp;</span> }</Icon>
<span>{numHiddenNamespaces} more namespace{numHiddenNamespaces === 1 ? "" : "s"}</span>
</PrefixedLink>
</Menu.Item>
: null
}
<Menu.SubMenu
className="sidebar-menu-item"
key="byresource"
@ -202,7 +211,67 @@ class Sidebar extends React.Component {
<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="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}
disabled={_.isEmpty(sidebarComponents[resourceName])}
title={<span>{friendlyTitle(resourceName).plural}</span>}>
{
_.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" />

View File

@ -117,6 +117,10 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
});
};
const generateResourceURL = r => {
return "/namespaces/" + r.namespace + "/" + r.type + "s/" + r.name;
};
// prefix all links in the app with `pathPrefix`
class PrefixedLink extends React.Component {
static defaultProps = {
@ -160,6 +164,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
PrefixedLink,
setCurrentRequests,
getCurrentPromises,
generateResourceURL,
cancelCurrentRequests,
// DO NOT USE makeCancelable, use fetch, this is only exposed for testing
makeCancelable

View File

@ -13,6 +13,16 @@ const getPodCategorization = pod => {
return ""; // Terminating | Succeeded | Unknown
};
export const getSuccessRateClassification = (rate, successRateLabels) => {
if (rate < 0.9) {
return successRateLabels.bad;
} else if (rate < 0.95) {
return successRateLabels.neutral;
} else {
return successRateLabels.good;
}
};
const getTotalRequests = row => {
let success = parseInt(_.get(row, ["stats", "successCount"], 0), 10);
let failure = parseInt(_.get(row, ["stats", "failureCount"], 0), 10);
@ -162,6 +172,13 @@ export const processMultiResourceRollup = rawMetrics => {
return metricsByResource;
};
export const excludeResourcesFromRollup = (rollupMetrics, resourcesToExclude) => {
_.each(resourcesToExclude, resource => {
delete rollupMetrics[resource];
});
return rollupMetrics;
};
export const metricsPropType = PropTypes.shape({
ok: PropTypes.shape({
statTables: PropTypes.arrayOf(PropTypes.shape({

View File

@ -0,0 +1,70 @@
import Adapter from "enzyme-adapter-react-16";
import ApiHelpers from "../js/components/util/ApiHelpers.jsx";
import { BrowserRouter } from 'react-router-dom';
import { expect } from 'chai';
import multiResourceRollupFixtures from './fixtures/allRollup.json';
import React from "react";
import { Select } from 'antd';
import Sidebar from "../js/components/Sidebar.jsx";
import sinon from "sinon";
import sinonStubPromise from "sinon-stub-promise";
import Enzyme, { mount } from "enzyme";
Enzyme.configure({adapter: new Adapter()});
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.returnsPromise().resolves({
ok: true,
json: () => Promise.resolve(multiResourceRollupFixtures)
});
component = mount(
<BrowserRouter>
<Sidebar
location={loc}
api={apiHelpers}
releaseVersion={curVer}
pathPrefix=""
uuid="fakeuuid" />
</BrowserRouter>
);
return withPromise(() => {
openNamespaceSelector(component);
expect(component.find(".ant-select-dropdown-menu-item")).to.have.length(2);
});
});
});

View File

@ -61,9 +61,9 @@ describe('Version', () => {
);
return withPromise(() => {
expect(component.html()).not.to.include("Linkerd is up to date");
expandSidebar(component);
expect(component.html()).to.include("Linkerd is up to date");
expandSidebar(component);
expect(component.html()).not.to.include("Linkerd is up to date");
});
});
@ -84,8 +84,6 @@ describe('Version', () => {
</BrowserRouter>
);
expandSidebar(component);
return withPromise(() => {
expect(component.html()).to.include("Linkerd is up to date");
});
@ -108,8 +106,6 @@ describe('Version', () => {
</BrowserRouter>
);
expandSidebar(component);
return withPromise(() => {
expect(component.html()).to.include("A new version (");
expect(component.html()).to.include(newVer);
@ -139,8 +135,6 @@ describe('Version', () => {
</BrowserRouter>
);
expandSidebar(component);
return withPromise(() => {
expect(component.html()).to.include("Version check failed");
expect(component.html()).to.include(errMsg);