diff --git a/ui/app/adapters/metrics/entity.js b/ui/app/adapters/metrics/entity.js
new file mode 100644
index 0000000000..550febd659
--- /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 5f30e8ebb8..1bb071a402 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 0000000000..7baf6cb4f7
--- /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 0000000000..3aa9502d91
--- /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 0000000000..4dfde541c9
--- /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 0000000000..5e1a37dcab
--- /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 0000000000..3a570bad29
--- /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 0000000000..5d112ad893
--- /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 9618309e12..2682227e02 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 0000000000..669017080b
--- /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 0000000000..e75905e3c4
--- /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 0000000000..177dc4013a
--- /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 0fb4b7e582..0000000000
--- 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 0000000000..31683a6ca5
--- /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 0000000000..39d9fbd070
--- /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 0000000000..39d9fbd070
--- /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 0590025b5b..61dd1606f6 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 0000000000..bda2c163e4
--- /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 0000000000..4413bd964b
--- /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 bf865e8b0a..15bafbc0c5 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 0000000000..4677c11675
--- /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 0000000000..c19b4f2fed
--- /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}}
+
+
+ {{#if (gt percentChange 0)}}
+
+{{percentChange}}%
+ {{else}}
+
{{percentChange}}%
+ {{/if}}
+
Since last month
+
+
+ {{#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 0000000000..bb69888640
--- /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 18f9a92975..150e8dd0da 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 0000000000..225046ea61
--- /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 0000000000..d68bf74683
--- /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 c5d91ed554..0000000000
--- 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 0000000000..e801632c1e
--- /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 0000000000..d5141c51b4
--- /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 0000000000..4c4bda577b
--- /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');
+ });
+});