From 2a52c1a82ba6f031bc41b495aafb75e7fe8dcd61 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Thu, 13 Feb 2020 12:44:57 -0700 Subject: [PATCH] Core Usage Metrics (#8347) * Core usage metrics v1 (merge to side-branch) (#8238) * restructure menu layout per designs * setup new routing that will set the stage for a metrics landing page * fix formatting * Revert "fix formatting" This reverts commit e77cdec5e58cdcea49aa1b97f80238433c4f7d1e. * fix formatting * small styling changes * change request routing to metrics * rename route js file * Core usage metrics v2 (#8263) * restructure menu layout per designs * setup new routing that will set the stage for a metrics landing page * fix formatting * Revert "fix formatting" This reverts commit e77cdec5e58cdcea49aa1b97f80238433c4f7d1e. * fix formatting * small styling changes * change request routing to metrics * rename route js file * setup selectable card component and api request * add token and http request models to route and template * add entities to route and template * clean up * add breadcrumbs and some clean up work * remove unused selectable-card component * refactor to a serializer * move adapters, serializers, and models into metrics folder * remove unused file * address pr comments * address pr comments * Core Usage Metrics V3 (#8316) * restructure menu layout per designs * setup new routing that will set the stage for a metrics landing page * fix formatting * Revert "fix formatting" This reverts commit e77cdec5e58cdcea49aa1b97f80238433c4f7d1e. * fix formatting * small styling changes * change request routing to metrics * rename route js file * setup selectable card component and api request * add token and http request models to route and template * add entities to route and template * clean up * add breadcrumbs and some clean up work * remove unused selectable-card component * setup smaller http request bar chart * refactor to a serializer * move adapters, serializers, and models into metrics folder * remove unused file * setup change part of component * fix broken model * add conditional class * setting up computed properties in new component * small fixes * setup components * minor fixes * rename * clean up * firefox fix * remove shadow bars * move out of metrics folders * modify permissions to show difference between token entities and requests * make tests * fix class names and associated tests * clean up * fix text overflow in non-chrome browsers * address pr comments, specifically class names and tests * move into one component * clean up component descriptions in comments * small wording changes * fix for accessibility * address pr comments around component examples for storybook * fix test * fix failing test * fix test --- ui/app/adapters/metrics/entity.js | 14 ++ .../{requests.js => metrics/http-requests.js} | 2 +- ui/app/adapters/metrics/token.js | 14 ++ .../http-requests-bar-chart-small.js | 153 ++++++++++++++++++ .../components/selectable-card-container.js | 54 +++++++ ui/app/components/selectable-card.js | 35 ++++ ui/app/models/metrics/entity.js | 27 ++++ .../{requests.js => metrics/http-requests.js} | 0 ui/app/models/metrics/token.js | 27 ++++ ui/app/router.js | 5 +- ui/app/routes/vault/cluster/metrics.js | 8 + .../vault/cluster/metrics/http-requests.js | 7 + ui/app/routes/vault/cluster/metrics/index.js | 26 +++ ui/app/routes/vault/cluster/requests.js | 7 - ui/app/serializers/metrics.js | 11 ++ ui/app/serializers/metrics/entity.js | 3 + ui/app/serializers/metrics/token.js | 3 + ui/app/services/permissions.js | 1 + .../components/selectable-card-container.scss | 31 ++++ ui/app/styles/components/selectable-card.scss | 82 ++++++++++ ui/app/styles/core.scss | 2 + .../http-requests-bar-chart-small.hbs | 4 + .../components/selectable-card-container.hbs | 74 +++++++++ .../templates/components/selectable-card.hbs | 20 +++ ui/app/templates/partials/status/cluster.hbs | 83 +++++----- .../vault/cluster/metrics/http-requests.hbs | 21 +++ .../templates/vault/cluster/metrics/index.hbs | 15 ++ ui/app/templates/vault/cluster/requests.hbs | 9 -- .../http-requests-bar-chart-small-test.js | 26 +++ .../selectable-card-container-test.js | 73 +++++++++ .../components/selectable-card-test.js | 30 ++++ 31 files changed, 803 insertions(+), 64 deletions(-) create mode 100644 ui/app/adapters/metrics/entity.js rename ui/app/adapters/{requests.js => metrics/http-requests.js} (86%) create mode 100644 ui/app/adapters/metrics/token.js create mode 100644 ui/app/components/http-requests-bar-chart-small.js create mode 100644 ui/app/components/selectable-card-container.js create mode 100644 ui/app/components/selectable-card.js create mode 100644 ui/app/models/metrics/entity.js rename ui/app/models/{requests.js => metrics/http-requests.js} (100%) create mode 100644 ui/app/models/metrics/token.js create mode 100644 ui/app/routes/vault/cluster/metrics.js create mode 100644 ui/app/routes/vault/cluster/metrics/http-requests.js create mode 100644 ui/app/routes/vault/cluster/metrics/index.js delete mode 100644 ui/app/routes/vault/cluster/requests.js create mode 100644 ui/app/serializers/metrics.js create mode 100644 ui/app/serializers/metrics/entity.js create mode 100644 ui/app/serializers/metrics/token.js create mode 100644 ui/app/styles/components/selectable-card-container.scss create mode 100644 ui/app/styles/components/selectable-card.scss create mode 100644 ui/app/templates/components/http-requests-bar-chart-small.hbs create mode 100644 ui/app/templates/components/selectable-card-container.hbs create mode 100644 ui/app/templates/components/selectable-card.hbs create mode 100644 ui/app/templates/vault/cluster/metrics/http-requests.hbs create mode 100644 ui/app/templates/vault/cluster/metrics/index.hbs delete mode 100644 ui/app/templates/vault/cluster/requests.hbs create mode 100644 ui/tests/integration/components/http-requests-bar-chart-small-test.js create mode 100644 ui/tests/integration/components/selectable-card-container-test.js create mode 100644 ui/tests/integration/components/selectable-card-test.js diff --git a/ui/app/adapters/metrics/entity.js b/ui/app/adapters/metrics/entity.js new file mode 100644 index 000000000000..550febd659e8 --- /dev/null +++ b/ui/app/adapters/metrics/entity.js @@ -0,0 +1,14 @@ +import Application from '../application'; + +export default Application.extend({ + queryRecord() { + return this.ajax(this.urlForQuery(), 'GET').then(resp => { + resp.id = resp.request_id; + return resp; + }); + }, + + urlForQuery() { + return this.buildURL() + '/internal/counters/entities'; + }, +}); diff --git a/ui/app/adapters/requests.js b/ui/app/adapters/metrics/http-requests.js similarity index 86% rename from ui/app/adapters/requests.js rename to ui/app/adapters/metrics/http-requests.js index 5f30e8ebb83a..1bb071a40287 100644 --- a/ui/app/adapters/requests.js +++ b/ui/app/adapters/metrics/http-requests.js @@ -1,4 +1,4 @@ -import Application from './application'; +import Application from '../application'; export default Application.extend({ queryRecord() { diff --git a/ui/app/adapters/metrics/token.js b/ui/app/adapters/metrics/token.js new file mode 100644 index 000000000000..7baf6cb4f7b9 --- /dev/null +++ b/ui/app/adapters/metrics/token.js @@ -0,0 +1,14 @@ +import Application from '../application'; + +export default Application.extend({ + queryRecord() { + return this.ajax(this.urlForQuery(), 'GET').then(resp => { + resp.id = resp.request_id; + return resp; + }); + }, + + urlForQuery() { + return this.buildURL() + '/internal/counters/tokens'; + }, +}); diff --git a/ui/app/components/http-requests-bar-chart-small.js b/ui/app/components/http-requests-bar-chart-small.js new file mode 100644 index 000000000000..3aa9502d9121 --- /dev/null +++ b/ui/app/components/http-requests-bar-chart-small.js @@ -0,0 +1,153 @@ +import Component from '@ember/component'; +import d3 from 'd3-selection'; +import d3Scale from 'd3-scale'; +import d3Axis from 'd3-axis'; +import d3TimeFormat from 'd3-time-format'; +import { assign } from '@ember/polyfills'; +import { computed } from '@ember/object'; +import { run } from '@ember/runloop'; +import { task, waitForEvent } from 'ember-concurrency'; + +/** + * @module HttpRequestsBarChartSmall + * The HttpRequestsBarChartSmall is a simplified version of the HttpRequestsBarChart component. + * + * + * @example + * ```js + * + * ``` + * + * @param counters=null {Array} - A list of objects containing the total number of HTTP Requests for each month. `counters` should be the response from the `/internal/counters/requests`. + * The response is then filtered showing only the 12 most recent months of data. This property is called filteredHttpsRequests, like: + * const FILTERED_HTTPS_REQUESTS = [ + * { start_time: '2018-11-01T00:00:00Z', total: 5500 }, + * { start_time: '2018-12-01T00:00:00Z', total: 4500 }, + * { start_time: '2019-01-01T00:00:00Z', total: 5000 }, + * { start_time: '2019-02-01T00:00:00Z', total: 5000 }, + * ]; + */ + +const HEIGHT = 125; +const UI_GRAY_300 = '#bac1cc'; +const UI_GRAY_100 = '#ebeef2'; + +export default Component.extend({ + classNames: ['http-requests-bar-chart-small'], + counters: null, + margin: Object.freeze({ top: 24, right: 16, bottom: 24, left: 16 }), + padding: 0.04, + width: 0, + height() { + const { margin } = this; + return HEIGHT - margin.top - margin.bottom; + }, + parsedCounters: computed('counters', function() { + // parse the start times so bars display properly + const { counters } = this; + counters.reverse(); + return counters.map((counter, index) => { + return assign({}, counter, { + start_time: d3TimeFormat.isoParse(counter.start_time), + fill_color: index === counters.length - 1 ? UI_GRAY_300 : UI_GRAY_100, + }); + }); + }), + + yScale: computed('parsedCounters', 'height', function() { + const { parsedCounters } = this; + const height = this.height(); + const counterTotals = parsedCounters.map(c => c.total); + + return d3Scale + .scaleLinear() + .domain([0, Math.max(...counterTotals)]) + .range([height, 0]); + }), + + xScale: computed('parsedCounters', 'width', function() { + const { parsedCounters, width, margin, padding } = this; + return d3Scale + .scaleBand() + .domain(parsedCounters.map(c => c.start_time)) + .rangeRound([0, width - margin.left - margin.right], 0.05) + .paddingInner(padding) + .paddingOuter(padding); + }), + + didInsertElement() { + this._super(...arguments); + const { margin } = this; + + // set the width after the element has been rendered because the chart axes depend on it. + // this helps us avoid an arbitrary hardcoded width which causes alignment & resizing problems. + run.schedule('afterRender', this, () => { + this.set('width', this.element.clientWidth - margin.left - margin.right); + this.renderBarChart(); + }); + }, + + didUpdateAttrs() { + this.renderBarChart(); + }, + + renderBarChart() { + const { margin, width, xScale, yScale, parsedCounters, elementId } = this; + const height = this.height(); + const barChartSVG = d3.select('.http-requests-bar-chart-small'); + const barsContainer = d3.select(`#bars-container-${elementId}`); + + d3.select('.http-requests-bar-chart') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + .attr('viewBox', `0 0 ${width} ${height}`); + + const xAxis = d3Axis + .axisBottom(xScale) + .tickFormat('') + .tickValues([]) + .tickSizeOuter(0); + + barChartSVG + .select('g.x-axis') + .attr('transform', `translate(0,${height})`) + .call(xAxis); + + const bars = barsContainer.selectAll('.bar').data(parsedCounters, c => +c.start_time); + + const barsEnter = bars + .enter() + .append('rect') + .attr('class', 'bar'); + + bars + .merge(barsEnter) + .attr('x', counter => xScale(counter.start_time)) + .attr('y', () => yScale(0)) + .attr('width', xScale.bandwidth()) + .attr('height', counter => height - yScale(counter.total) - 5) // subtract 5 to provide the gap between the xAxis and the bars + .attr('y', counter => yScale(counter.total)) + .attr('fill', counter => counter.fill_color) + .attr('stroke', counter => counter.fill_color); + + bars.exit().remove(); + }, + + updateDimensions() { + const newWidth = this.element.clientWidth; + const { margin } = this; + + this.set('width', newWidth - margin.left - margin.right); + this.renderBarChart(); + }, + + waitForResize: task(function*() { + while (true) { + yield waitForEvent(window, 'resize'); + run.scheduleOnce('afterRender', this, 'updateDimensions'); + } + }) + .on('didInsertElement') + .cancelOn('willDestroyElement') + .drop(), +}); diff --git a/ui/app/components/selectable-card-container.js b/ui/app/components/selectable-card-container.js new file mode 100644 index 000000000000..4dfde541c962 --- /dev/null +++ b/ui/app/components/selectable-card-container.js @@ -0,0 +1,54 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +/** + * @module SelectableCardContainer + * SelectableCardContainer components are used to hold SelectableCard components. They act as a CSS grid container, and change grid configurations based on the boolean of @gridContainer. + * + * @example + * ```js + * + * ``` + * @param counters=null {Object} - Counters is an object that returns total entities, tokens, and an array of objects with the total https request per month. + * @param gridContainer=false {Boolean} - gridContainer is optional. If true, it's telling the container it will have a nested CSS grid. + * + * const MODEL = { + * totalEntities: 0, + * httpsRequests: [{ start_time: '2019-04-01T00:00:00Z', total: 5500 }], + * totalTokens: 1, + * }; + */ + +export default Component.extend({ + classNameBindings: ['isGridContainer'], + counters: null, + gridContainer: false, + isGridContainer: computed('counters', function() { + return this.counters.httpsRequests.length > 1 + ? 'selectable-card-container has-grid' + : 'selectable-card-container'; + }), + totalHttpRequests: computed('counters', function() { + let httpsRequestsArray = this.counters.httpsRequests || []; + return httpsRequestsArray.firstObject.total; + }), + // Limit number of months returned to the most recent 12 + filteredHttpsRequests: computed('counters', function() { + let httpsRequestsArray = this.counters.httpsRequests || []; + if (httpsRequestsArray.length > 12) { + httpsRequestsArray = httpsRequestsArray.slice(0, 12); + } + return httpsRequestsArray; + }), + percentChange: computed('counters', function() { + let httpsRequestsArray = this.counters.httpsRequests || []; + let lastTwoMonthsArray = httpsRequestsArray.slice(0, 2); + let previousMonthVal = lastTwoMonthsArray.lastObject.total; + let thisMonthVal = lastTwoMonthsArray.firstObject.total; + + let percentChange = (((previousMonthVal - thisMonthVal) / previousMonthVal) * 100).toFixed(1); + // a negative value indicates a percentage increase, so we swap the value + percentChange = -percentChange; + return percentChange; + }), +}); diff --git a/ui/app/components/selectable-card.js b/ui/app/components/selectable-card.js new file mode 100644 index 000000000000..5e1a37dcab59 --- /dev/null +++ b/ui/app/components/selectable-card.js @@ -0,0 +1,35 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +/** + * @module SelectableCard + * SelectableCard components are card-like components that display a title, total, subtotal, and anything after they yield. + * They are designed to be used in containers that act as flexbox or css grid containers. + * + * @example + * ```js + * + * ``` + * @param cardTitle='' {String} - cardTitle displays the card title + * @param total=0 {Number} - the Total number displays like a title, it's the largest text in the component + * @param subText='' {String} - subText describes the total + * @param gridContainer=false {Boolean} - Optional parameter used to display CSS grid item class. + */ + +export default Component.extend({ + cardTitle: '', + total: 0, + subText: '', + gridContainer: false, + tagName: '', // do not wrap component with div + formattedCardTitle: computed('total', function() { + const { cardTitle, total } = this; + + if (cardTitle === 'Tokens') { + return total !== 1 ? 'Tokens' : 'Token'; + } else if (cardTitle === 'Entities') { + return total !== 1 ? 'Entities' : 'Entity'; + } + + return cardTitle; + }), +}); diff --git a/ui/app/models/metrics/entity.js b/ui/app/models/metrics/entity.js new file mode 100644 index 000000000000..3a570bad299b --- /dev/null +++ b/ui/app/models/metrics/entity.js @@ -0,0 +1,27 @@ +import DS from 'ember-data'; +const { attr } = DS; + +/* sample response + +{ + "request_id": "75cbaa46-e741-3eba-2be2-325b1ba8f03f", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "counters": { + "entities": { + "total": 1 + } + } + }, + "wrap_info": null, + "warnings": null, + "auth": null +} + +*/ + +export default DS.Model.extend({ + entities: attr('object'), +}); diff --git a/ui/app/models/requests.js b/ui/app/models/metrics/http-requests.js similarity index 100% rename from ui/app/models/requests.js rename to ui/app/models/metrics/http-requests.js diff --git a/ui/app/models/metrics/token.js b/ui/app/models/metrics/token.js new file mode 100644 index 000000000000..5d112ad8931a --- /dev/null +++ b/ui/app/models/metrics/token.js @@ -0,0 +1,27 @@ +import DS from 'ember-data'; +const { attr } = DS; + +/* sample response + +{ + "request_id": "75cbaa46-e741-3eba-2be2-325b1ba8f03f", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "counters": { + "service_tokens": { + "total": 1 + } + } + }, + "wrap_info": null, + "warnings": null, + "auth": null +} + +*/ + +export default DS.Model.extend({ + service_tokens: attr('object'), +}); diff --git a/ui/app/router.js b/ui/app/router.js index 9618309e1295..2682227e0265 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -15,7 +15,10 @@ Router.map(function() { this.route('logout'); this.mount('open-api-explorer', { path: '/api-explorer' }); this.route('license'); - this.route('requests', { path: '/metrics/requests' }); + this.route('metrics', function() { + this.route('index', { path: '/' }); + this.route('http-requests'); + }); this.route('storage', { path: '/storage/raft' }); this.route('storage-restore', { path: '/storage/raft/restore' }); this.route('settings', function() { diff --git a/ui/app/routes/vault/cluster/metrics.js b/ui/app/routes/vault/cluster/metrics.js new file mode 100644 index 000000000000..669017080b75 --- /dev/null +++ b/ui/app/routes/vault/cluster/metrics.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; +import ClusterRoute from 'vault/mixins/cluster-route'; + +export default Route.extend(ClusterRoute, { + model() { + return {}; + }, +}); diff --git a/ui/app/routes/vault/cluster/metrics/http-requests.js b/ui/app/routes/vault/cluster/metrics/http-requests.js new file mode 100644 index 000000000000..e75905e3c4bc --- /dev/null +++ b/ui/app/routes/vault/cluster/metrics/http-requests.js @@ -0,0 +1,7 @@ +import ClusterRouteBase from '../cluster-route-base'; + +export default ClusterRouteBase.extend({ + model() { + return this.store.queryRecord('metrics/http-requests', {}); + }, +}); diff --git a/ui/app/routes/vault/cluster/metrics/index.js b/ui/app/routes/vault/cluster/metrics/index.js new file mode 100644 index 000000000000..177dc4013a9c --- /dev/null +++ b/ui/app/routes/vault/cluster/metrics/index.js @@ -0,0 +1,26 @@ +import Route from '@ember/routing/route'; +import ClusterRoute from 'vault/mixins/cluster-route'; +import { hash } from 'rsvp'; + +export default Route.extend(ClusterRoute, { + model() { + let totalEntities = this.store.queryRecord('metrics/entity', {}).then(response => { + return response.entities.total; + }); + + let httpsRequests = this.store.queryRecord('metrics/http-requests', {}).then(response => { + let reverseArray = response.counters.reverse(); + return reverseArray; + }); + + let totalTokens = this.store.queryRecord('metrics/token', {}).then(response => { + return response.service_tokens.total; + }); + + return hash({ + totalEntities, + httpsRequests, + totalTokens, + }); + }, +}); diff --git a/ui/app/routes/vault/cluster/requests.js b/ui/app/routes/vault/cluster/requests.js deleted file mode 100644 index 0fb4b7e58244..000000000000 --- a/ui/app/routes/vault/cluster/requests.js +++ /dev/null @@ -1,7 +0,0 @@ -import ClusterRouteBase from './cluster-route-base'; - -export default ClusterRouteBase.extend({ - model() { - return this.store.queryRecord('requests', {}); - }, -}); diff --git a/ui/app/serializers/metrics.js b/ui/app/serializers/metrics.js new file mode 100644 index 000000000000..31683a6ca595 --- /dev/null +++ b/ui/app/serializers/metrics.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalizeResponse(store, primaryModelClass, payload, id, requestType) { + const normalizedPayload = { + id: payload.id, + data: payload.data.counters, + }; + return this._super(store, primaryModelClass, normalizedPayload, id, requestType); + }, +}); diff --git a/ui/app/serializers/metrics/entity.js b/ui/app/serializers/metrics/entity.js new file mode 100644 index 000000000000..39d9fbd070f7 --- /dev/null +++ b/ui/app/serializers/metrics/entity.js @@ -0,0 +1,3 @@ +import MetricsSerializer from '../metrics'; + +export default MetricsSerializer.extend(); diff --git a/ui/app/serializers/metrics/token.js b/ui/app/serializers/metrics/token.js new file mode 100644 index 000000000000..39d9fbd070f7 --- /dev/null +++ b/ui/app/serializers/metrics/token.js @@ -0,0 +1,3 @@ +import MetricsSerializer from '../metrics'; + +export default MetricsSerializer.extend(); diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 0590025b5b39..61dd1606f67a 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -30,6 +30,7 @@ const API_PATHS = { raft: 'sys/storage/raft/configuration', }, metrics: { + dashboard: 'sys/internal/counters', requests: 'sys/internal/counters/requests', }, }; diff --git a/ui/app/styles/components/selectable-card-container.scss b/ui/app/styles/components/selectable-card-container.scss new file mode 100644 index 000000000000..bda2c163e458 --- /dev/null +++ b/ui/app/styles/components/selectable-card-container.scss @@ -0,0 +1,31 @@ +.selectable-card-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + grid-template-rows: 1fr; + grid-gap: 2rem; +} + +.selectable-card-container.has-grid { + display: grid; + grid-template-columns: 2fr 1fr; + grid-template-rows: repeat(2, 1fr); + grid-gap: 2rem; + + @include until($mobile) { + grid-template-columns: 2fr; + } + + .grid-item-http { + grid-column-start: 1; + grid-column-end: 2; + grid-row-start: 1; + grid-row-end: 3; + } + + .selectable-card.is-grid-container { + display: grid; + grid-template-columns: 2fr 0.5fr; + grid-template-rows: 1fr 2fr 0.5fr; + padding: $spacing-l 0 14px $spacing-l; // modify bottom spacing to better align with other cards + } +} diff --git a/ui/app/styles/components/selectable-card.scss b/ui/app/styles/components/selectable-card.scss new file mode 100644 index 000000000000..4413bd964b92 --- /dev/null +++ b/ui/app/styles/components/selectable-card.scss @@ -0,0 +1,82 @@ +.selectable-card { + box-shadow: 0 0 0 1px rgba($grey-dark, 0.3); + display: flex; + justify-content: space-between; + padding: $spacing-l 0 $spacing-l $spacing-l; + line-height: 0; + + &:hover { + box-shadow: 0 0 0 1px $grey-light, $box-shadow-middle; + } + + > a { + text-decoration: none; + } + + .card-details { + grid-column-start: 2; + grid-row-start: 3; + align-self: center; + justify-self: right; + padding-right: $spacing-l; + } + + .http-requests-bar-chart-small { + grid-column: 1 / span 2; + grid-row-start: 2; + align-self: end; + min-width: 100%; // necessary for Firefox + } + + .change-metric { + justify-self: right; + padding-right: $spacing-l; + display: grid; + grid-template-columns: 1fr 2fr; + grid-template-rows: 1fr 1fr; + + .hs-icon { + color: $grey-light; + align-self: center; + justify-self: right; + } + + .amount-change { + align-self: center; + justify-self: center; + + font-weight: 500; + } + .item-c { + grid-column: 1 / span 2; + align-self: start; + justify-self: end; + + font-weight: $font-weight-semibold; + white-space: nowrap; + + @include until($mobile) { + overflow: hidden; + } + } + } + + .title-number { + color: $black; + font-size: 36px; + font-weight: 500; + line-height: 1.33; + } +} + +.selectable-card.is-rounded { + border-radius: $radius; +} + +.change-metric-icon.is-decrease { + transform: rotate(135deg); +} + +.change-metric-icon.is-increase { + transform: rotate(45deg); +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index bf865e8b0af8..15bafbc0c55b 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -78,6 +78,8 @@ @import './components/raft-join'; @import './components/role-item'; @import './components/search-select'; +@import './components/selectable-card'; +@import './components/selectable-card-container.scss'; @import './components/shamir-progress'; @import './components/sidebar'; @import './components/splash-page'; diff --git a/ui/app/templates/components/http-requests-bar-chart-small.hbs b/ui/app/templates/components/http-requests-bar-chart-small.hbs new file mode 100644 index 000000000000..4677c11675a3 --- /dev/null +++ b/ui/app/templates/components/http-requests-bar-chart-small.hbs @@ -0,0 +1,4 @@ + + + + diff --git a/ui/app/templates/components/selectable-card-container.hbs b/ui/app/templates/components/selectable-card-container.hbs new file mode 100644 index 000000000000..c19b4f2fedb0 --- /dev/null +++ b/ui/app/templates/components/selectable-card-container.hbs @@ -0,0 +1,74 @@ +{{#linked-block + "vault.cluster.metrics.http-requests" + class="grid-item-http" +}} + + {{#if (eq counters.httpsRequests.length 1)}} +
+
+ + + +
+
+ {{else}} +
+
+ + {{#link-to "vault.cluster.metrics.http-requests" class="card-details"}} View Details {{/link-to}} + {{/if}} +
+{{/linked-block}} + + +
+
+ + + +
+
+
+ + diff --git a/ui/app/templates/components/selectable-card.hbs b/ui/app/templates/components/selectable-card.hbs new file mode 100644 index 000000000000..bb69888640a0 --- /dev/null +++ b/ui/app/templates/components/selectable-card.hbs @@ -0,0 +1,20 @@ +{{!-- conditional to check if SelectableCard is apart of a CSS Grid, if yes return grid item class --}} +{{#if gridContainer}} +
+
+

{{format-number total}}

+

{{formattedCardTitle}}

+

{{subText}}

+
+ {{yield}} +
+{{else}} +
+
+

{{format-number total}}

+

{{formattedCardTitle}}

+

{{subText}}

+
+ {{yield}} +
+{{/if}} diff --git a/ui/app/templates/partials/status/cluster.hbs b/ui/app/templates/partials/status/cluster.hbs index 18f9a92975b4..150e8dd0daac 100644 --- a/ui/app/templates/partials/status/cluster.hbs +++ b/ui/app/templates/partials/status/cluster.hbs @@ -65,43 +65,9 @@ {{/if}} {{/if}} {{/unless}} - {{#if (and (or - (and version.features (has-permission 'status' routeParams='license')) - (and cluster.usingRaft (has-permission 'status' routeParams='raft')) - ) (not cluster.dr.isSecondary)) - }} - -
- {{/if}} - {{#if ( and (has-permission 'metrics' routeParams='requests') (not cluster.dr.isSecondary) auth.currentToken)}} -
- - {{/if}} + {{/if}} + diff --git a/ui/app/templates/vault/cluster/metrics/http-requests.hbs b/ui/app/templates/vault/cluster/metrics/http-requests.hbs new file mode 100644 index 000000000000..225046ea6183 --- /dev/null +++ b/ui/app/templates/vault/cluster/metrics/http-requests.hbs @@ -0,0 +1,21 @@ + + + + + +

+ HTTP Request Volume +

+
+
+ + diff --git a/ui/app/templates/vault/cluster/metrics/index.hbs b/ui/app/templates/vault/cluster/metrics/index.hbs new file mode 100644 index 000000000000..d68bf7468321 --- /dev/null +++ b/ui/app/templates/vault/cluster/metrics/index.hbs @@ -0,0 +1,15 @@ + + +

+ Metrics +

+
+
+ +
+ {{#if (gt model.httpsRequests.length 1) }} + + {{else}} + + {{/if}} +
diff --git a/ui/app/templates/vault/cluster/requests.hbs b/ui/app/templates/vault/cluster/requests.hbs deleted file mode 100644 index c5d91ed5545b..000000000000 --- a/ui/app/templates/vault/cluster/requests.hbs +++ /dev/null @@ -1,9 +0,0 @@ - - -

- HTTP Request Volume -

-
-
- - diff --git a/ui/tests/integration/components/http-requests-bar-chart-small-test.js b/ui/tests/integration/components/http-requests-bar-chart-small-test.js new file mode 100644 index 000000000000..e801632c1ed4 --- /dev/null +++ b/ui/tests/integration/components/http-requests-bar-chart-small-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +const FILTERED_HTTPS_REQUESTS = [ + { start_time: '2018-11-01T00:00:00Z', total: 5500 }, + { start_time: '2018-12-01T00:00:00Z', total: 4500 }, + { start_time: '2019-01-01T00:00:00Z', total: 5000 }, + { start_time: '2019-02-01T00:00:00Z', total: 5000 }, +]; + +module('Integration | Component | http-requests-bar-chart-small', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.set('filteredHttpsRequests', FILTERED_HTTPS_REQUESTS); + }); + + test('it renders and the correct number of bars are showing', async function(assert) { + await render(hbs``); + + assert.dom('rect').exists({ count: FILTERED_HTTPS_REQUESTS.length }); + assert.dom('.http-requests-bar-chart-small').exists(); + }); +}); diff --git a/ui/tests/integration/components/selectable-card-container-test.js b/ui/tests/integration/components/selectable-card-container-test.js new file mode 100644 index 000000000000..d5141c51b45e --- /dev/null +++ b/ui/tests/integration/components/selectable-card-container-test.js @@ -0,0 +1,73 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +const MODEL = { + totalEntities: 0, + httpsRequests: [{ start_time: '2019-04-01T00:00:00Z', total: 5500 }], + totalTokens: 1, +}; + +const MODEL_WITH_GRID = { + httpsRequests: [ + { start_time: '2018-11-01T00:00:00Z', total: 5500 }, + { start_time: '2018-12-01T00:00:00Z', total: 4500 }, + { start_time: '2019-01-01T00:00:00Z', total: 5000 }, + { start_time: '2019-02-01T00:00:00Z', total: 5000 }, + { start_time: '2019-03-01T00:00:00Z', total: 5000 }, + { start_time: '2019-04-01T00:00:00Z', total: 5500 }, + { start_time: '2019-05-01T00:00:00Z', total: 4500 }, + { start_time: '2019-06-01T00:00:00Z', total: 5000 }, + { start_time: '2019-07-01T00:00:00Z', total: 5000 }, + { start_time: '2019-08-01T00:00:00Z', total: 5000 }, + { start_time: '2019-09-01T00:00:00Z', total: 5000 }, + { start_time: '2019-10-01T00:00:00Z', total: 5000 }, + { start_time: '2019-11-01T00:00:00Z', total: 5000 }, + { start_time: '2019-12-01T00:00:00Z', total: 5000 }, + { start_time: '2020-01-01T00:00:00Z', total: 5000 }, + { start_time: '2020-02-01T00:00:00Z', total: 5000 }, + ], + totalEntities: 0, + totalTokens: 1, +}; + +module('Integration | Component | selectable-card-container', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.set('model', MODEL); + this.set('modelWithGrid', MODEL_WITH_GRID); + }); + + test('it renders', async function(assert) { + await render(hbs``); + assert.dom('.selectable-card-container').exists(); + }); + + test('it renders a card for each of the models and titles are returned', async function(assert) { + await render(hbs``); + assert.dom('.selectable-card').exists({ count: 3 }); + let cardTitles = ['Http Requests', 'Entities', 'Token']; + let httpRequestsTitle = this.element.querySelectorAll('[data-test-selectable-card-title]'); + + httpRequestsTitle.forEach(item => { + assert.notEqual(cardTitles.indexOf(item.innerText), -1); + }); + }); + + test('it renders with more than one month of data', async function(assert) { + await render(hbs``); + assert.dom('.selectable-card-container.has-grid').exists(); + }); + + test('it renders 3 selectable cards when there is more than one month of data', async function(assert) { + await render(hbs``); + assert.dom('.selectable-card').exists({ count: 3 }); + }); + + test('it only renders a bar chart with the last 12 months of data', async function(assert) { + await render(hbs``); + assert.dom('rect').exists({ count: 12 }); + }); +}); diff --git a/ui/tests/integration/components/selectable-card-test.js b/ui/tests/integration/components/selectable-card-test.js new file mode 100644 index 000000000000..4c4bda577ba8 --- /dev/null +++ b/ui/tests/integration/components/selectable-card-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +const TOTAL = 15; +const CARD_TITLE = 'Tokens'; + +module('Integration | Component selectable-card', function(hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function() { + this.set('total', TOTAL); + this.set('cardTitle', CARD_TITLE); + }); + + test('it shows the card total', async function(assert) { + await render(hbs``); + let titleNumber = this.element.querySelector('.title-number').innerText; + + assert.equal(titleNumber, 15); + }); + + test('it returns non-plural version of card title if total is 1, ', async function(assert) { + await render(hbs``); + let titleText = this.element.querySelector('.title').innerText; + + assert.equal(titleText, 'Token'); + }); +});