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

View File

@ -1,16 +1,29 @@
import _ from 'lodash'; import _ from 'lodash';
import ApiHelpers from './util/ApiHelpers.jsx'; 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 PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ReactRouterPropTypes from 'react-router-prop-types'; import ReactRouterPropTypes from 'react-router-prop-types';
import SocialLinks from './SocialLinks.jsx'; import SocialLinks from './SocialLinks.jsx';
import Version from './Version.jsx'; import Version from './Version.jsx';
import { withContext } from './util/AppContext.jsx'; import {withContext} from './util/AppContext.jsx';
import { Icon, Layout, Menu } from 'antd'; import {Badge, Form, Icon, Layout, Menu, Select} from 'antd';
import { linkerdLogoOnly, linkerdWordLogo } from './util/SvgWrappers.jsx'; import {
excludeResourcesFromRollup,
getSuccessRateClassification,
processMultiResourceRollup,
processSingleResourceRollup
} from './util/MetricUtils.js';
import {linkerdLogoOnly, linkerdWordLogo} from './util/SvgWrappers.jsx';
import './../../css/sidebar.css'; import './../../css/sidebar.css';
const classificationLabels = {
good: "success",
neutral: "warning",
bad: "error"
};
class Sidebar extends React.Component { class Sidebar extends React.Component {
static defaultProps = { static defaultProps = {
productName: 'controller' productName: 'controller'
@ -29,10 +42,11 @@ class Sidebar extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.api= this.props.api; this.api = this.props.api;
this.toggleCollapse = this.toggleCollapse.bind(this); this.toggleCollapse = this.toggleCollapse.bind(this);
this.loadFromServer = this.loadFromServer.bind(this); this.loadFromServer = this.loadFromServer.bind(this);
this.handleApiError = this.handleApiError.bind(this); this.handleApiError = this.handleApiError.bind(this);
this.handleNamespaceSelector = this.handleNamespaceSelector.bind(this);
this.state = this.getInitialState(); this.state = this.getInitialState();
} }
@ -40,13 +54,13 @@ class Sidebar extends React.Component {
getInitialState() { getInitialState() {
return { return {
pollingInterval: 12000, pollingInterval: 12000,
maxNsItemsToShow: 8, initialCollapse: false,
initialCollapse: true,
collapsed: true, collapsed: true,
error: null, error: null,
latestVersion: '', latestVersion: '',
isLatest: true, 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}`; let versionUrl = `https://versioncheck.linkerd.io/version.json?version=${this.props.releaseVersion}?uuid=${this.props.uuid}`;
this.api.setCurrentRequests([ this.api.setCurrentRequests([
ApiHelpers("").fetch(versionUrl), ApiHelpers("").fetch(versionUrl),
this.api.fetchMetrics(this.api.urlsForResource("all")),
this.api.fetchMetrics(this.api.urlsForResource("namespace")) this.api.fetchMetrics(this.api.urlsForResource("namespace"))
]); ]);
// expose serverPromise for testing // expose serverPromise for testing
this.serverPromise = Promise.all(this.api.getCurrentPromises()) this.serverPromise = Promise.all(this.api.getCurrentPromises())
.then(([versionRsp, nsRsp]) => { .then(([versionRsp, allRsp, nsRsp]) => {
let nsStats = _.get(nsRsp, ["ok", "statTables", 0, "podGroup", "rows"], []); let allResourceGroups = processMultiResourceRollup(allRsp);
let namespaces = _(nsStats).map(r => r.resource.name).sortBy().value(); let finalResourceGroups = excludeResourcesFromRollup(allResourceGroups, ["authority", "service"]);
let nsStats = processSingleResourceRollup(nsRsp);
let namespaces = _(nsStats).map('name').sortBy().value();
this.setState({ this.setState({
latestVersion: versionRsp.version, latestVersion: versionRsp.version,
isLatest: versionRsp.version === this.props.releaseVersion, isLatest: versionRsp.version === this.props.releaseVersion,
finalResourceGroups,
namespaces, namespaces,
pendingRequests: false, 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() { render() {
let normalizedPath = this.props.location.pathname.replace(this.props.pathPrefix, ""); let normalizedPath = this.props.location.pathname.replace(this.props.pathPrefix, "");
let PrefixedLink = this.api.PrefixedLink; 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 ( return (
<Layout.Sider <Layout.Sider
width="260px" width="260px"
@ -127,7 +162,6 @@ class Sidebar extends React.Component {
collapsed={this.state.collapsed} collapsed={this.state.collapsed}
collapsible={true} collapsible={true}
onCollapse={this.toggleCollapse}> onCollapse={this.toggleCollapse}>
<div className="sidebar"> <div className="sidebar">
<div className={`sidebar-menu-header ${this.state.collapsed ? "collapsed" : ""}`}> <div className={`sidebar-menu-header ${this.state.collapsed ? "collapsed" : ""}`}>
@ -140,7 +174,6 @@ class Sidebar extends React.Component {
className="sidebar-menu" className="sidebar-menu"
theme="dark" theme="dark"
selectedKeys={[normalizedPath]}> selectedKeys={[normalizedPath]}>
<Menu.Item className="sidebar-menu-item" key="/servicemesh"> <Menu.Item className="sidebar-menu-item" key="/servicemesh">
<PrefixedLink to="/servicemesh"> <PrefixedLink to="/servicemesh">
<Icon type="home" /> <Icon type="home" />
@ -169,30 +202,6 @@ class Sidebar extends React.Component {
</PrefixedLink> </PrefixedLink>
</Menu.Item> </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 <Menu.SubMenu
className="sidebar-menu-item" className="sidebar-menu-item"
key="byresource" key="byresource"
@ -202,7 +211,67 @@ class Sidebar extends React.Component {
<Menu.Item><PrefixedLink to="/pods">Pods</PrefixedLink></Menu.Item> <Menu.Item><PrefixedLink to="/pods">Pods</PrefixedLink></Menu.Item>
<Menu.Item><PrefixedLink to="/replicationcontrollers">Replication Controllers</PrefixedLink></Menu.Item> <Menu.Item><PrefixedLink to="/replicationcontrollers">Replication Controllers</PrefixedLink></Menu.Item>
</Menu.SubMenu> </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"> <Menu.Item className="sidebar-menu-item" key="/docs">
<Link to="https://linkerd.io/2/overview/" target="_blank"> <Link to="https://linkerd.io/2/overview/" target="_blank">
<Icon type="file-text" /> <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` // prefix all links in the app with `pathPrefix`
class PrefixedLink extends React.Component { class PrefixedLink extends React.Component {
static defaultProps = { static defaultProps = {
@ -160,6 +164,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
PrefixedLink, PrefixedLink,
setCurrentRequests, setCurrentRequests,
getCurrentPromises, getCurrentPromises,
generateResourceURL,
cancelCurrentRequests, cancelCurrentRequests,
// DO NOT USE makeCancelable, use fetch, this is only exposed for testing // DO NOT USE makeCancelable, use fetch, this is only exposed for testing
makeCancelable makeCancelable

View File

@ -13,6 +13,16 @@ const getPodCategorization = pod => {
return ""; // Terminating | Succeeded | Unknown 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 => { const getTotalRequests = row => {
let success = parseInt(_.get(row, ["stats", "successCount"], 0), 10); let success = parseInt(_.get(row, ["stats", "successCount"], 0), 10);
let failure = parseInt(_.get(row, ["stats", "failureCount"], 0), 10); let failure = parseInt(_.get(row, ["stats", "failureCount"], 0), 10);
@ -162,6 +172,13 @@ export const processMultiResourceRollup = rawMetrics => {
return metricsByResource; return metricsByResource;
}; };
export const excludeResourcesFromRollup = (rollupMetrics, resourcesToExclude) => {
_.each(resourcesToExclude, resource => {
delete rollupMetrics[resource];
});
return rollupMetrics;
};
export const metricsPropType = PropTypes.shape({ export const metricsPropType = PropTypes.shape({
ok: PropTypes.shape({ ok: PropTypes.shape({
statTables: PropTypes.arrayOf(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(() => { 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"); 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> </BrowserRouter>
); );
expandSidebar(component);
return withPromise(() => { return withPromise(() => {
expect(component.html()).to.include("Linkerd is up to date"); expect(component.html()).to.include("Linkerd is up to date");
}); });
@ -108,8 +106,6 @@ describe('Version', () => {
</BrowserRouter> </BrowserRouter>
); );
expandSidebar(component);
return withPromise(() => { return withPromise(() => {
expect(component.html()).to.include("A new version ("); expect(component.html()).to.include("A new version (");
expect(component.html()).to.include(newVer); expect(component.html()).to.include(newVer);
@ -139,8 +135,6 @@ describe('Version', () => {
</BrowserRouter> </BrowserRouter>
); );
expandSidebar(component);
return withPromise(() => { return withPromise(() => {
expect(component.html()).to.include("Version check failed"); expect(component.html()).to.include("Version check failed");
expect(component.html()).to.include(errMsg); expect(component.html()).to.include(errMsg);