mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 20:36:26 +02:00
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
This commit is contained in:
parent
0937a58ad7
commit
2a52c1a82b
14
ui/app/adapters/metrics/entity.js
Normal file
14
ui/app/adapters/metrics/entity.js
Normal file
@ -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';
|
||||
},
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import Application from './application';
|
||||
import Application from '../application';
|
||||
|
||||
export default Application.extend({
|
||||
queryRecord() {
|
||||
14
ui/app/adapters/metrics/token.js
Normal file
14
ui/app/adapters/metrics/token.js
Normal file
@ -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';
|
||||
},
|
||||
});
|
||||
153
ui/app/components/http-requests-bar-chart-small.js
Normal file
153
ui/app/components/http-requests-bar-chart-small.js
Normal file
@ -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
|
||||
* <HttpRequestsBarChartSmall @counters={counters}/>
|
||||
* ```
|
||||
*
|
||||
* @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(),
|
||||
});
|
||||
54
ui/app/components/selectable-card-container.js
Normal file
54
ui/app/components/selectable-card-container.js
Normal file
@ -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
|
||||
* <SelectableCardContainer @counters={{model}} @gridContainer="true" />
|
||||
* ```
|
||||
* @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;
|
||||
}),
|
||||
});
|
||||
35
ui/app/components/selectable-card.js
Normal file
35
ui/app/components/selectable-card.js
Normal file
@ -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
|
||||
* <SelectableCard @cardTitle="Tokens" @total={{totalHttpRequests}} @subText="Total" @gridContainer={{gridContainer}}/>
|
||||
* ```
|
||||
* @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;
|
||||
}),
|
||||
});
|
||||
27
ui/app/models/metrics/entity.js
Normal file
27
ui/app/models/metrics/entity.js
Normal file
@ -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'),
|
||||
});
|
||||
27
ui/app/models/metrics/token.js
Normal file
27
ui/app/models/metrics/token.js
Normal file
@ -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'),
|
||||
});
|
||||
@ -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() {
|
||||
|
||||
8
ui/app/routes/vault/cluster/metrics.js
Normal file
8
ui/app/routes/vault/cluster/metrics.js
Normal file
@ -0,0 +1,8 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import ClusterRoute from 'vault/mixins/cluster-route';
|
||||
|
||||
export default Route.extend(ClusterRoute, {
|
||||
model() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
7
ui/app/routes/vault/cluster/metrics/http-requests.js
Normal file
7
ui/app/routes/vault/cluster/metrics/http-requests.js
Normal file
@ -0,0 +1,7 @@
|
||||
import ClusterRouteBase from '../cluster-route-base';
|
||||
|
||||
export default ClusterRouteBase.extend({
|
||||
model() {
|
||||
return this.store.queryRecord('metrics/http-requests', {});
|
||||
},
|
||||
});
|
||||
26
ui/app/routes/vault/cluster/metrics/index.js
Normal file
26
ui/app/routes/vault/cluster/metrics/index.js
Normal file
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -1,7 +0,0 @@
|
||||
import ClusterRouteBase from './cluster-route-base';
|
||||
|
||||
export default ClusterRouteBase.extend({
|
||||
model() {
|
||||
return this.store.queryRecord('requests', {});
|
||||
},
|
||||
});
|
||||
11
ui/app/serializers/metrics.js
Normal file
11
ui/app/serializers/metrics.js
Normal file
@ -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);
|
||||
},
|
||||
});
|
||||
3
ui/app/serializers/metrics/entity.js
Normal file
3
ui/app/serializers/metrics/entity.js
Normal file
@ -0,0 +1,3 @@
|
||||
import MetricsSerializer from '../metrics';
|
||||
|
||||
export default MetricsSerializer.extend();
|
||||
3
ui/app/serializers/metrics/token.js
Normal file
3
ui/app/serializers/metrics/token.js
Normal file
@ -0,0 +1,3 @@
|
||||
import MetricsSerializer from '../metrics';
|
||||
|
||||
export default MetricsSerializer.extend();
|
||||
@ -30,6 +30,7 @@ const API_PATHS = {
|
||||
raft: 'sys/storage/raft/configuration',
|
||||
},
|
||||
metrics: {
|
||||
dashboard: 'sys/internal/counters',
|
||||
requests: 'sys/internal/counters/requests',
|
||||
},
|
||||
};
|
||||
|
||||
31
ui/app/styles/components/selectable-card-container.scss
Normal file
31
ui/app/styles/components/selectable-card-container.scss
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
82
ui/app/styles/components/selectable-card.scss
Normal file
82
ui/app/styles/components/selectable-card.scss
Normal file
@ -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);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
<svg class="http-requests-bar-chart">
|
||||
<g class="x-axis"></g>
|
||||
<g id="bars-container-{{this.elementId}}" class="bars"></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 132 B |
74
ui/app/templates/components/selectable-card-container.hbs
Normal file
74
ui/app/templates/components/selectable-card-container.hbs
Normal file
@ -0,0 +1,74 @@
|
||||
{{#linked-block
|
||||
"vault.cluster.metrics.http-requests"
|
||||
class="grid-item-http"
|
||||
}}
|
||||
<SelectableCard
|
||||
@cardTitle="Http Requests"
|
||||
@total={{totalHttpRequests}}
|
||||
@subText="This month"
|
||||
@gridContainer={{gridContainer}}
|
||||
>
|
||||
{{#if (eq counters.httpsRequests.length 1)}}
|
||||
<div class="is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<PopupMenu name="engine-menu">
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.metrics.http-requests"}}
|
||||
View Details
|
||||
{{/link-to}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="change-metric">
|
||||
<Icon @glyph="arrow-up" @size="xlm" aria-hidden="true" class="change-metric-icon {{if (gt percentChange 0) "is-increase" "is-decrease"}}"/>
|
||||
{{#if (gt percentChange 0)}}
|
||||
<p class="has-text-grey is-size-4 amount-change">+{{percentChange}}%</p>
|
||||
{{else}}
|
||||
<p class="has-text-grey is-size-4 amount-change">{{percentChange}}%</p>
|
||||
{{/if}}
|
||||
<p class="has-text-grey is-size-6 item-c">Since last month</p>
|
||||
</div>
|
||||
<HttpRequestsBarChartSmall @counters={{filteredHttpsRequests}}/>
|
||||
{{#link-to "vault.cluster.metrics.http-requests" class="card-details"}} View Details {{/link-to}}
|
||||
{{/if}}
|
||||
</SelectableCard>
|
||||
{{/linked-block}}
|
||||
|
||||
<SelectableCard
|
||||
@cardTitle="Entities"
|
||||
@total={{counters.totalEntities}}
|
||||
@subText="Total"
|
||||
>
|
||||
<div class="is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<PopupMenu name="engine-menu">
|
||||
<nav class="menu">
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.access.identity" "entities" data-test-link=true }}
|
||||
Manage entities
|
||||
{{/link-to}}
|
||||
</li>
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.access.identity.create" "entities" }}
|
||||
Create entity
|
||||
{{/link-to}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
</div>
|
||||
</SelectableCard>
|
||||
|
||||
<SelectableCard
|
||||
@cardTitle="Tokens"
|
||||
@total={{counters.totalTokens}}
|
||||
@subText="Total"
|
||||
/>
|
||||
20
ui/app/templates/components/selectable-card.hbs
Normal file
20
ui/app/templates/components/selectable-card.hbs
Normal file
@ -0,0 +1,20 @@
|
||||
{{!-- conditional to check if SelectableCard is apart of a CSS Grid, if yes return grid item class --}}
|
||||
{{#if gridContainer}}
|
||||
<div class="selectable-card is-rounded is-grid-container">
|
||||
<div class="selectable-card-title">
|
||||
<h2 class="title-number">{{format-number total}}</h2>
|
||||
<h3 class="title is-5" data-test-selectable-card-title={{formattedCardTitle}}>{{formattedCardTitle}}</h3>
|
||||
<p class="has-text-grey is-size-8">{{subText}}</p>
|
||||
</div>
|
||||
{{yield}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="selectable-card is-rounded">
|
||||
<div>
|
||||
<h2 class="title-number">{{format-number total}}</h2>
|
||||
<h3 class="title is-5" data-test-selectable-card-title={{formattedCardTitle}}>{{formattedCardTitle}}</h3>
|
||||
<p class="has-text-grey is-size-8">{{subText}}</p>
|
||||
</div>
|
||||
{{yield}}
|
||||
</div>
|
||||
{{/if}}
|
||||
@ -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))
|
||||
}}
|
||||
<nav class="menu">
|
||||
<div class="menu-label">
|
||||
Server
|
||||
</div>
|
||||
<ul class="menu-list">
|
||||
{{#if (and version.features (has-permission 'status' routeParams='license') (not cluster.dr.isSecondary))}}
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.license" activeCluster.name invokeAction=onLinkClick}}
|
||||
<div class="level is-mobile">
|
||||
<span class="level-left">License</span>
|
||||
<Chevron class="has-text-grey-light level-right" />
|
||||
</div>
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (and cluster.usingRaft (has-permission 'status' routeParams='raft'))}}
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.storage" activeCluster.name invokeAction=onLinkClick}}
|
||||
<div class="level is-mobile">
|
||||
<span class="level-left">Raft Storage</span>
|
||||
<Chevron class="has-text-grey-light level-right" />
|
||||
</div>
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
<hr/>
|
||||
{{/if}}
|
||||
<nav class="menu">
|
||||
<div class="menu-label">
|
||||
Seal status
|
||||
Server
|
||||
</div>
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
@ -133,24 +99,49 @@
|
||||
{{/if}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{{#if ( and (has-permission 'metrics' routeParams='requests') (not cluster.dr.isSecondary) auth.currentToken)}}
|
||||
<hr />
|
||||
<nav class="menu">
|
||||
<div class="menu-label">
|
||||
Metrics
|
||||
</div>
|
||||
{{#if (and (or
|
||||
(and version.features (has-permission 'status' routeParams='license'))
|
||||
(and cluster.usingRaft (has-permission 'status' routeParams='raft'))
|
||||
)
|
||||
(not cluster.dr.isSecondary))
|
||||
}}
|
||||
<ul class="menu-list">
|
||||
{{#if (and version.features (has-permission 'status' routeParams='license') (not cluster.dr.isSecondary))}}
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.license" activeCluster.name invokeAction=(action (queue (action onLinkClick) (action d.actions.close)))}}
|
||||
<div class="level is-mobile">
|
||||
<span class="level-left">License</span>
|
||||
<Chevron class="has-text-grey-light level-right" />
|
||||
</div>
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (and cluster.usingRaft (has-permission 'status' routeParams='raft'))}}
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.storage" activeCluster.name invokeAction=onLinkClick}}
|
||||
<div class="level is-mobile">
|
||||
<span class="level-left">Raft Storage</span>
|
||||
<Chevron class="has-text-grey-light level-right" />
|
||||
</div>
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
{{#if ( and (has-permission 'metrics' routeParams='dashboard') (not cluster.dr.isSecondary) auth.currentToken)}}
|
||||
<ul class="menu-list">
|
||||
<li class="action">
|
||||
{{#link-to "vault.cluster.requests"}}
|
||||
{{#link-to 'vault.cluster.metrics'
|
||||
invokeAction=(action (queue (action onLinkClick) (action d.actions.close)))
|
||||
}}
|
||||
<div class="level is-mobile">
|
||||
<span class="level-left">HTTP Requests</span>
|
||||
<span class="level-left">Metrics</span>
|
||||
<Chevron class="has-text-grey-light level-right" />
|
||||
</div>
|
||||
{{/link-to}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
ui/app/templates/vault/cluster/metrics/http-requests.hbs
Normal file
21
ui/app/templates/vault/cluster/metrics/http-requests.hbs
Normal file
@ -0,0 +1,21 @@
|
||||
<PageHeader as |p|>
|
||||
<p.top>
|
||||
<nav class="key-value-header breadcrumb">
|
||||
<ul>
|
||||
<li>
|
||||
<span class="sep">/</span>
|
||||
{{#link-to "vault.cluster.metrics"}}
|
||||
Metrics
|
||||
{{/link-to}}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</p.top>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
HTTP Request Volume
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<HttpRequestsContainer @counters={{model.counters}}/>
|
||||
15
ui/app/templates/vault/cluster/metrics/index.hbs
Normal file
15
ui/app/templates/vault/cluster/metrics/index.hbs
Normal file
@ -0,0 +1,15 @@
|
||||
<PageHeader as |p|>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
Metrics
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
|
||||
{{#if (gt model.httpsRequests.length 1) }}
|
||||
<SelectableCardContainer @counters={{model}} @gridContainer="true"/>
|
||||
{{else}}
|
||||
<SelectableCardContainer @counters={{model}} />
|
||||
{{/if}}
|
||||
</div>
|
||||
@ -1,9 +0,0 @@
|
||||
<PageHeader as |p|>
|
||||
<p.levelLeft>
|
||||
<h1 class="title is-3">
|
||||
HTTP Request Volume
|
||||
</h1>
|
||||
</p.levelLeft>
|
||||
</PageHeader>
|
||||
|
||||
<HttpRequestsContainer @counters={{model.counters}}/>
|
||||
@ -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`<HttpRequestsBarChartSmall @counters={{filteredHttpsRequests}}/>`);
|
||||
|
||||
assert.dom('rect').exists({ count: FILTERED_HTTPS_REQUESTS.length });
|
||||
assert.dom('.http-requests-bar-chart-small').exists();
|
||||
});
|
||||
});
|
||||
@ -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`<SelectableCardContainer @counters={{model}}/>`);
|
||||
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`<SelectableCardContainer @counters={{model}}/>`);
|
||||
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`<SelectableCardContainer @counters={{modelWithGrid}}/>`);
|
||||
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`<SelectableCardContainer @counters={{modelWithGrid}}/>`);
|
||||
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`<SelectableCardContainer @counters={{modelWithGrid}}/>`);
|
||||
assert.dom('rect').exists({ count: 12 });
|
||||
});
|
||||
});
|
||||
30
ui/tests/integration/components/selectable-card-test.js
Normal file
30
ui/tests/integration/components/selectable-card-test.js
Normal file
@ -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`<SelectableCard @total={{total}} @cardTitle={{cardTitle}}/>`);
|
||||
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`<SelectableCard @total={{1}} @cardTitle={{cardTitle}}/>`);
|
||||
let titleText = this.element.querySelector('.title').innerText;
|
||||
|
||||
assert.equal(titleText, 'Token');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user