UI tweaks: sidebar collapse, latency formatting, table row spacing (#361)

- reduce row spacing on tables to make them more compact
- Rename TabbedMetricsTable to MetricsTable since it's not tabbed any more
- Format latencies greater than 1000ms as seconds
- Make sidebar collapsible 
- poll the /pods endpoint from the sidebar in order to refresh the list of deployments in the autocomplete
- display the conduit namespace in the service mesh details table
- Use floats rather than Col for more responsive layout (fixes #224)
This commit is contained in:
Risha Mars 2018-02-19 11:21:54 -08:00 committed by GitHub
parent 01e694ad71
commit 8bc7c5acde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 157 additions and 81 deletions

View File

@ -288,6 +288,7 @@ spec:
- "-static-dir=/dist"
- "-template-dir=/templates"
- "-uuid={{.UUID}}"
- "-controller-namespace={{.Namespace}}"
- "-log-level={{.ControllerLogLevel}}"
### Prometheus ###

View File

@ -272,6 +272,7 @@ spec:
- "-static-dir=/dist"
- "-template-dir=/templates"
- "-uuid=UUID"
- "-controller-namespace=Namespace"
- "-log-level=ControllerLogLevel"
### Prometheus ###

View File

@ -19,13 +19,19 @@
}
& .action-steps div {
height: 41px;
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%
}

View File

@ -1,6 +1,5 @@
import { incompleteMeshMessage } from './util/CopyUtils.jsx';
import React from 'react';
import { Col, Row } from 'antd';
import './../../css/cta.css';
export default class CallToAction extends React.Component {
@ -10,37 +9,31 @@ export default class CallToAction extends React.Component {
<div className="action summary">The service mesh was successfully installed!</div>
<div className="action-steps">
<Row gutter={0}>
<Col span={8}>
<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>
</Col>
<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>
<Col span={8}>
<div className="step-container complete">
<div className="icon-container">
<i className="fa fa-check-circle" aria-hidden="true" />
</div>
<div className="message">{this.props.numDeployments || 0} deployments detected</div>
</div>
</Col>
<div className="step-container complete">
<div className="icon-container">
<i className="fa fa-check-circle" aria-hidden="true" />
</div>
<div className="message">{this.props.numDeployments || 0} deployments detected</div>
</div>
<Col span={8}>
<div className="step-container incomplete">
<div className="icon-container">
<i className="fa fa-circle-o" aria-hidden="true" />
</div>
<div className="message">Connect your first deployment</div>
</div>
</Col>
</Row>
<div className="step-container incomplete">
<div className="icon-container">
<i className="fa fa-circle-o" aria-hidden="true" />
</div>
<div className="message">Connect your first deployment</div>
</div>
</div>
{incompleteMeshMessage()}
<div className="clearfix">
{incompleteMeshMessage()}
</div>
</div>
);
}

View File

@ -2,9 +2,9 @@ import _ from 'lodash';
import CallToAction from './CallToAction.jsx';
import ConduitSpinner from "./ConduitSpinner.jsx";
import ErrorBanner from './ErrorBanner.jsx';
import MetricsTable from './MetricsTable.jsx';
import PageHeader from './PageHeader.jsx';
import React from 'react';
import TabbedMetricsTable from './TabbedMetricsTable.jsx';
import { emptyMetric, getPodsByDeployment, processRollupMetrics } from './util/MetricUtils.js';
import './../../css/deployments.css';
import 'whatwg-fetch';
@ -88,7 +88,7 @@ export default class DeploymentsList extends React.Component {
{ _.isEmpty(this.state.metrics) ?
<CallToAction numDeployments={_.size(this.state.metrics)} /> :
<div className="deployments-list">
<TabbedMetricsTable
<MetricsTable
resource="deployment"
metrics={this.state.metrics}
api={this.api} />

View File

@ -21,6 +21,7 @@ const columnDefinitions = (sortable = true, resource, ConduitLink) => {
title: resource.title,
dataIndex: "name",
key: "name",
defaultSortOrder: 'ascend',
width: 150,
sorter: sortable ? (a, b) => (a.name || "").localeCompare(b.name) : false,
render: name => !resource.url ? name :
@ -30,7 +31,6 @@ const columnDefinitions = (sortable = true, resource, ConduitLink) => {
title: "Request Rate",
dataIndex: "requestRate",
key: "requestRateRollup",
defaultSortOrder: 'descend',
className: "numeric",
sorter: sortable ? (a, b) => numericSort(a.requestRate, b.requestRate) : false,
render: d => metricToFormatter["REQUEST_RATE"](d)
@ -72,7 +72,7 @@ const columnDefinitions = (sortable = true, resource, ConduitLink) => {
const numericSort = (a, b) => (_.isNil(a) ? -1 : a) - (_.isNil(b) ? -1 : b);
export default class TabbedMetricsTable extends React.Component {
export default class MetricsTable extends React.Component {
constructor(props) {
super(props);
this.api = this.props.api;
@ -100,6 +100,7 @@ export default class TabbedMetricsTable extends React.Component {
columns={columns}
pagination={false}
className="conduit-table"
rowKey={r => r.name} />);
rowKey={r => r.name}
size="middle" />);
}
}

View File

@ -149,10 +149,11 @@ export default class ServiceMesh extends React.Component {
getServiceMeshDetails() {
return [
{ key: 1, name: "Conduit version", value: this.props.releaseVersion },
{ key: 2, name: "Control plane components", value: this.componentCount() },
{ key: 3, name: "Added deployments", value: this.addedDeploymentCount() },
{ key: 4, name: "Unadded deployments", value: this.unaddedDeploymentCount() },
{ key: 5, name: "Data plane proxies", value: this.proxyCount() }
{ key: 2, name: "Conduit namespace", value: this.props.controllerNamespace },
{ key: 3, name: "Control plane components", value: this.componentCount() },
{ key: 4, name: "Added deployments", value: this.addedDeploymentCount() },
{ key: 5, name: "Unadded deployments", value: this.unaddedDeploymentCount() },
{ key: 6, name: "Data plane proxies", value: this.proxyCount() }
];
}
@ -264,7 +265,8 @@ export default class ServiceMesh extends React.Component {
className="conduit-table"
dataSource={this.getServiceMeshDetails()}
columns={serviceMeshDetailsColumns}
pagination={false} />
pagination={false}
size="middle" />
</div>
</div>
);

View File

@ -15,20 +15,37 @@ export default class Sidebar extends React.Component {
this.api = this.props.api;
this.filterDeployments = this.filterDeployments.bind(this);
this.onAutocompleteSelect = this.onAutocompleteSelect.bind(this);
this.loadFromServer();
this.loadFromServer = this.loadFromServer.bind(this);
this.state = {
pollingInterval: 10000, // longer, this doesn't need to be updated as often
pendingRequests: false,
autocompleteValue: '',
deployments: [],
filteredDeployments: []
};
}
componentDidMount() {
this.loadFromServer();
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
componentWillUnmount() {
window.clearInterval(this.timerId);
}
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.fetchPods().then(r => {
let deploys = _.map(getPodsByDeployment(r.pods), 'name');
this.setState({
pendingRequests: false,
deployments: deploys,
filteredDeployments: deploys
});
@ -60,6 +77,7 @@ export default class Sidebar extends React.Component {
render() {
let normalizedPath = this.props.location.pathname.replace(this.props.pathPrefix, "");
let ConduitLink = this.api.ConduitLink;
return (
<div className="sidebar">
<div className="list-container">
@ -88,11 +106,16 @@ export default class Sidebar extends React.Component {
</Menu.Item>
</Menu>
<SocialLinks />
<Version
releaseVersion={this.props.releaseVersion}
uuid={this.props.uuid} />
{
!this.props.collapsed ? (
<React.Fragment>
<SocialLinks />
<Version
releaseVersion={this.props.releaseVersion}
uuid={this.props.uuid} />
</React.Fragment>
) : null
}
</div>
</div>
);

View File

@ -95,6 +95,7 @@ export default class StatusTable extends React.Component {
columns={tableCols}
pagination={false}
className="conduit-table"
rowKey={r => r.name} />);
rowKey={r => r.name}
size="middle" />);
}
}

View File

@ -1,7 +1,7 @@
import _ from 'lodash';
import MetricsTable from './MetricsTable.jsx';
import React from 'react';
import { rowGutter } from './util/Utils.js';
import TabbedMetricsTable from './TabbedMetricsTable.jsx';
import { Col, Row } from 'antd';
export default class UpstreamDownstreamTables extends React.Component {
@ -17,7 +17,7 @@ export default class UpstreamDownstreamTables extends React.Component {
<div className="border-container border-neutral subsection-header">
<div className="border-container-content subsection-header">Upstreams</div>
</div>
<TabbedMetricsTable
<MetricsTable
resource={`upstream_${this.props.resourceType}`}
resourceName={this.props.resourceName}
metrics={this.props.upstreamMetrics}
@ -30,7 +30,7 @@ export default class UpstreamDownstreamTables extends React.Component {
<div className="border-container border-neutral subsection-header">
<div className="border-container-content subsection-header">Downstreams</div>
</div>
<TabbedMetricsTable
<MetricsTable
resource={`downstream_${this.props.resourceType}`}
resourceName={this.props.resourceName}
metrics={this.props.downstreamMetrics}

View File

@ -11,12 +11,23 @@ export const rowGutter = 3 * baseWidth;
* Number formatters
*/
const successRateFormatter = d3.format(".2%");
const latencySecFormatter = d3.format(".3f");
const latencyFormatter = d3.format(",");
const formatLatency = m => {
if (_.isNil(m)) {
return "---";
} else if (m < 1000) {
return `${latencyFormatter(m)} ms`;
} else {
return `${latencySecFormatter(m / 1000)} s`;
}
};
export const metricToFormatter = {
"REQUEST_RATE": m => _.isNil(m) ? "---" : styleNum(m, " RPS", true),
"SUCCESS_RATE": m => _.isNil(m) ? "---" : successRateFormatter(m),
"LATENCY": m => `${_.isNil(m) ? "---" : latencyFormatter(m)} ms`
"LATENCY": formatLatency
};
/*

View File

@ -21,18 +21,31 @@ if (proxyPathMatch) {
let api = ApiHelpers(pathPrefix);
ReactDOM.render((
let applicationHtml = hideSidebar => (
<BrowserRouter>
<Layout>
<Layout.Sider width="310">
<Route render={routeProps => <Sidebar {...routeProps} goVersion={appData.goVersion} releaseVersion={appData.releaseVersion} api={api} pathPrefix={pathPrefix} uuid={appData.uuid} />} />
<Layout.Sider
width="310"
breakpoint="lg"
collapsible={true}
collapsedWidth={0}
onCollapse={onSidebarCollapse}>
<Route
render={routeProps => (<Sidebar
{...routeProps}
goVersion={appData.goVersion}
releaseVersion={appData.releaseVersion}
api={api}
collapsed={hideSidebar}
pathPrefix={pathPrefix}
uuid={appData.uuid} />)} />
</Layout.Sider>
<Layout>
<Layout.Content style={{ margin: '0 0', padding: 0, background: '#fff' }}>
<div className="main-content">
<Switch>
<Redirect exact from={`${pathPrefix}/`} to={`${pathPrefix}/servicemesh`} />
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh api={api} releaseVersion={appData.releaseVersion} />} />
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh api={api} releaseVersion={appData.releaseVersion} controllerNamespace={appData.controllerNamespace} />} />
<Route path={`${pathPrefix}/deployments`} render={() => <DeploymentsList api={api} />} />
<Route path={`${pathPrefix}/deployment`} render={props => <DeploymentDetail api={api} location={props.location} />} />
<Route component={NoMatch} />
@ -42,4 +55,10 @@ ReactDOM.render((
</Layout>
</Layout>
</BrowserRouter>
), appMain);
);
const onSidebarCollapse = isHidden => {
ReactDOM.render(applicationHtml(isHidden), appMain);
};
ReactDOM.render(applicationHtml(false), appMain);

View File

@ -65,7 +65,7 @@ describe('DeploymentsList', () => {
expect(component.find("DeploymentsList").length).to.equal(1);
expect(component.find("ConduitSpinner").length).to.equal(0);
expect(component.find("CallToAction").length).to.equal(0);
expect(component.find("TabbedMetricsTable").length).to.equal(1);
expect(component.find("MetricsTable").length).to.equal(1);
});
});
});

View File

@ -56,7 +56,7 @@ describe('Utils', () => {
let undefinedMetric;
expect(metricToFormatter["REQUEST_RATE"](undefinedMetric)).to.equal('---');
expect(metricToFormatter["SUCCESS_RATE"](undefinedMetric)).to.equal('---');
expect(metricToFormatter["LATENCY"](undefinedMetric)).to.equal('--- ms');
expect(metricToFormatter["LATENCY"](undefinedMetric)).to.equal('---');
});
it('formats requests with rounding and unit', () => {
@ -68,12 +68,15 @@ describe('Utils', () => {
expect(metricToFormatter["REQUEST_RATE"](99999)).to.equal('100k RPS');
});
it('formats latency', () => {
it('formats subsecond latency as ms', () => {
expect(metricToFormatter["LATENCY"](99)).to.equal('99 ms');
expect(metricToFormatter["LATENCY"](999)).to.equal('999 ms');
expect(metricToFormatter["LATENCY"](1000)).to.equal('1,000 ms');
expect(metricToFormatter["LATENCY"](9999)).to.equal('9,999 ms');
expect(metricToFormatter["LATENCY"](99999)).to.equal('99,999 ms');
});
it('formats latency greater than 1s as s', () => {
expect(metricToFormatter["LATENCY"](1000)).to.equal('1.000 s');
expect(metricToFormatter["LATENCY"](9999)).to.equal('9.999 s');
expect(metricToFormatter["LATENCY"](99999)).to.equal('99.999 s');
});
it('formats success rate', () => {

View File

@ -27,6 +27,7 @@ func main() {
reload := flag.Bool("reload", true, "reloading set to true or false")
webpackDevServer := flag.String("webpack-dev-server", "", "use webpack to serve static assets; frontend will use this instead of static-dir")
logLevel := flag.String("log-level", log.InfoLevel.String(), "log level, must be one of: panic, fatal, error, warn, info, debug")
controllerNamespace := flag.String("controller-namespace", "", "the k8s namespace in which Conduit is running")
printVersion := version.VersionFlag()
flag.Parse()
@ -51,7 +52,7 @@ func main() {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
server := srv.NewServer(*addr, *templateDir, *staticDir, *uuid, *webpackDevServer, *reload, client)
server := srv.NewServer(*addr, *templateDir, *staticDir, *uuid, *controllerNamespace, *webpackDevServer, *reload, client)
go func() {
log.Infof("starting HTTP server on %+v", *addr)

View File

@ -13,15 +13,16 @@ type (
serveFile func(http.ResponseWriter, string, string, interface{}) error
handler struct {
render renderTemplate
serveFile serveFile
apiClient pb.ApiClient
uuid string
render renderTemplate
serveFile serveFile
apiClient pb.ApiClient
uuid string
controllerNamespace string
}
)
func (h *handler) handleIndex(w http.ResponseWriter, req *http.Request, p httprouter.Params) {
params := appParams{UUID: h.uuid}
params := appParams{UUID: h.uuid, ControllerNamespace: h.controllerNamespace}
version, err := h.apiClient.Version(req.Context(), &pb.Empty{}) // TODO: remove and call /api/version from web app
if err != nil {

View File

@ -45,11 +45,18 @@ func TestHandleIndex(t *testing.T) {
t.Errorf("Expected: %+v", header)
}
expectedVersionDiv := "<div class=\"main\" id=\"main\" data-release-version=\"0.3.3\" data-go-version=\"the best one\" data-uuid=\"\">"
actualBody := recorder.Body.String()
if !strings.Contains(actualBody, expectedVersionDiv) {
t.Fatalf("Expected string [%s] to be present in [%s]", expectedVersionDiv, actualBody)
expectedSubstrings := []string{
"<div class=\"main\" id=\"main\"",
"data-release-version=\"0.3.3\"",
"data-go-version=\"the best one\"",
"data-controller-namespace=\"\"",
"data-uuid=\"\"",
}
for _, expectedSubstring := range expectedSubstrings {
if !strings.Contains(actualBody, expectedSubstring) {
t.Fatalf("Expected string [%s] to be present in [%s]", expectedSubstring, actualBody)
}
}
}

View File

@ -37,10 +37,11 @@ type (
Contents interface{}
}
appParams struct {
Data *pb.VersionInfo
UUID string
Error bool
ErrorMessage string
Data *pb.VersionInfo
UUID string
ControllerNamespace string
Error bool
ErrorMessage string
}
)
@ -49,7 +50,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
s.router.ServeHTTP(w, req)
}
func NewServer(addr, templateDir, staticDir, uuid, webpackDevServer string, reload bool, apiClient pb.ApiClient) *http.Server {
func NewServer(addr, templateDir, staticDir, uuid, controllerNamespace, webpackDevServer string, reload bool, apiClient pb.ApiClient) *http.Server {
server := &Server{
templateDir: templateDir,
staticDir: staticDir,
@ -65,10 +66,11 @@ func NewServer(addr, templateDir, staticDir, uuid, webpackDevServer string, relo
wrappedServer := util.WithTelemetry(server)
handler := &handler{
apiClient: apiClient,
render: server.RenderTemplate,
serveFile: server.serveFile,
uuid: uuid,
apiClient: apiClient,
render: server.RenderTemplate,
serveFile: server.serveFile,
uuid: uuid,
controllerNamespace: controllerNamespace,
}
httpServer := &http.Server{

View File

@ -1,5 +1,9 @@
{{ define "content" }}
<div class="main" id="main" data-release-version="{{.Data.ReleaseVersion}}" data-go-version="{{.Data.GoVersion}}" data-uuid="{{.UUID}}">
<div class="main" id="main"
data-release-version="{{.Data.ReleaseVersion}}"
data-go-version="{{.Data.GoVersion}}"
data-controller-namespace="{{.ControllerNamespace}}"
data-uuid="{{.UUID}}">
{{ if .Error }}
<p>Failed to call public API: {{ .ErrorMessage }}</p>
{{ end }}