mirror of https://github.com/linkerd/linkerd2.git
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:
parent
27e52a6cc0
commit
097632a2f0
|
@ -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);
|
||||
|
||||
|
|
|
@ -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> </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> </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" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
Loading…
Reference in New Issue