Ui/pricing metrics params (#10083)

metrics route takes start and end params and passes to the date display field, as well as the route's API call
This commit is contained in:
Chelsea Shaw
2020-10-05 16:34:52 -05:00
committed by GitHub
parent 376789069a
commit 2c7cadb731
29 changed files with 528 additions and 190 deletions

View File

@@ -1,14 +1,15 @@
import Application from '../application';
export default Application.extend({
queryRecord() {
return this.ajax(this.urlForQuery(), 'GET').then(resp => {
pathForType() {
return 'internal/counters/activity';
},
queryRecord(store, type, query) {
const url = this.urlForQuery(null, type);
// API accepts start and end as query params
return this.ajax(url, 'GET', { data: query }).then(resp => {
resp.id = resp.request_id;
return resp;
});
},
urlForQuery() {
return this.buildURL() + '/internal/counters/activity';
},
});

View File

@@ -1,14 +0,0 @@
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';
},
});

View File

@@ -1,14 +0,0 @@
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/requests';
},
});

View File

@@ -1,14 +0,0 @@
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';
},
});

View File

@@ -0,0 +1,131 @@
/**
* @module PricingMetricsDates
* PricingMetricsDates components are used on the Pricing Metrics page to handle queries related to pricing metrics.
* This component assumes that query parameters (as in, from route params) are being passed in with the format MM-YYYY,
* while the inputs expect a format of MM/YYYY.
*
* @example
* ```js
* <PricingMetricsDates @resultStart="2020-03-01T00:00:00Z" @resultEnd="2020-08-31T23:59:59Z" @queryStart="03-2020" @queryEnd="08-2020" />
* ```
* @param {object} resultStart - resultStart is the start date of the metrics returned. Should be a valid date string that the built-in Date() fn can parse
* @param {object} resultEnd - resultEnd is the end date of the metrics returned. Should be a valid date string that the built-in Date() fn can parse
* @param {string} [queryStart] - queryStart is the route param (formatted MM-YYYY) that the result will be measured against for showing discrepancy warning
* @param {string} [queryEnd] - queryEnd is the route param (formatted MM-YYYY) that the result will be measured against for showing discrepancy warning
* @param {number} [defaultSpan=12] - setting for default time between start and end input dates
* @param {number} [retentionMonths=24] - setting for the retention months, which informs valid dates to query by
*/
import { computed } from '@ember/object';
import Component from '@ember/component';
import {
compareAsc,
differenceInSeconds,
isValid,
subMonths,
startOfToday,
format,
endOfMonth,
} from 'date-fns';
import layout from '../templates/components/pricing-metrics-dates';
import { parseDateString } from 'vault/helpers/parse-date-string';
export default Component.extend({
layout,
queryStart: null,
queryEnd: null,
resultStart: null,
resultEnd: null,
start: null,
end: null,
defaultSpan: 12,
retentionMonths: 24,
startDate: computed('start', function() {
if (!this.start) return null;
let date;
try {
date = parseDateString(this.start, '/');
if (date) return date;
return null;
} catch (e) {
return null;
}
}),
endDate: computed('end', function() {
if (!this.end) return null;
let date;
try {
date = parseDateString(this.end, '/');
if (date) return endOfMonth(date);
return null;
} catch (e) {
return null;
}
}),
showResultsWarning: computed('resultStart', 'resultEnd', function() {
if (!this.queryStart || !this.queryEnd || !this.resultStart || !this.resultEnd) {
return false;
}
const resultStart = new Date(this.resultStart);
const resultEnd = new Date(this.resultEnd);
let queryStart, queryEnd;
try {
queryStart = parseDateString(this.queryStart, '-');
queryEnd = parseDateString(this.queryEnd, '-');
} catch (e) {
// Log error for debugging purposes
console.debug(e);
}
if (!queryStart || !queryEnd || !isValid(resultStart) || !isValid(resultEnd)) {
return false;
}
if (Math.abs(differenceInSeconds(queryStart, resultStart)) >= 86400) {
return true;
}
if (Math.abs(differenceInSeconds(resultEnd, endOfMonth(queryEnd))) >= 86400) {
return true;
}
return false;
}),
error: computed('end', 'start', function() {
if (!this.startDate) {
return 'Start date is invalid. Please use format MM/YYYY';
}
if (!this.endDate) {
return 'End date is invalid. Please use format MM/YYYY';
}
if (compareAsc(this.endDate, this.startDate) < 0) {
return 'Start date is after end date';
}
return null;
}),
init() {
this._super(...arguments);
let initialEnd;
let initialStart;
initialEnd = subMonths(startOfToday(), 1);
if (this.queryEnd) {
initialEnd = parseDateString(this.queryEnd, '-');
} else {
// if query isn't passed in, set it so that showResultsWarning works
this.queryEnd = format(initialEnd, 'MM-YYYY');
}
initialStart = subMonths(initialEnd, this.defaultSpan);
if (this.queryStart) {
initialStart = parseDateString(this.queryStart, '-');
} else {
// if query isn't passed in, set it so that showResultsWarning works
this.queryStart = format(initialStart, 'MM-YYYY');
}
this.start = format(initialStart, 'MM/YYYY');
this.end = format(initialEnd, 'MM/YYYY');
},
});

View File

@@ -0,0 +1,8 @@
import Controller from '@ember/controller';
export default Controller.extend({
queryParams: ['start', 'end'],
start: null,
end: null,
});

View File

@@ -0,0 +1,20 @@
import { helper } from '@ember/component/helper';
import { isValid } from 'date-fns';
export function parseDateString(date, separator = '-') {
// Expects format MM-YYYY by default: no dates
let datePieces = date.split(separator);
if (datePieces.length === 2) {
if (datePieces[0] < 1 || datePieces[0] > 12) {
throw new Error('Not a valid month value');
}
let firstOfMonth = new Date(datePieces[1], datePieces[0] - 1, 1);
if (isValid(firstOfMonth)) {
return firstOfMonth;
}
}
// what to return if not valid?
throw new Error(`Please use format MM${separator}YYYY`);
}
export default helper(parseDateString);

View File

@@ -1,27 +0,0 @@
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'),
});

View File

@@ -1,32 +0,0 @@
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": [
{
"start_time": "2019-05-01T00:00:00Z",
"total": 50
},
{
"start_time": "2019-04-01T00:00:00Z",
"total": 45
}
]
},
"wrap_info": null,
"warnings": null,
"auth": null
}
*/
export default DS.Model.extend({
counters: attr('array'),
});

View File

@@ -1,27 +0,0 @@
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'),
});

View File

@@ -1,14 +1,52 @@
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
import { hash } from 'rsvp';
import { endOfMonth } from 'date-fns';
import { parseDateString } from 'vault/helpers/parse-date-string';
const getActivityParams = ({ start, end }) => {
// Expects MM-YYYY format
// TODO: minStart, maxEnd
let params = {};
if (start) {
let startDate = parseDateString(start);
if (startDate) {
// TODO: Replace with formatRFC3339 when date-fns is updated
params.start_time = Math.round(startDate.getTime() / 1000);
}
}
if (end) {
let endDate = parseDateString(end);
if (endDate) {
// TODO: Replace with formatRFC3339 when date-fns is updated
params.end_time = Math.round(endOfMonth(endDate).getTime() / 1000);
}
}
return params;
};
export default Route.extend(ClusterRoute, {
model() {
let config = this.store.queryRecord('metrics/config', {});
queryParams: {
start: {
refreshModel: true,
},
end: {
refreshModel: true,
},
},
let activity = this.store.queryRecord('metrics/activity', {});
model(params) {
let config = this.store.queryRecord('metrics/config', {}).catch(e => {
console.debug(e);
// swallowing error so activity can show if no config permissions
return {};
});
const activityParams = getActivityParams(params);
let activity = this.store.queryRecord('metrics/activity', activityParams);
return hash({
queryStart: params.start,
queryEnd: params.end,
activity,
config,
});

View File

@@ -1,11 +0,0 @@
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);
},
});

View File

@@ -4,8 +4,10 @@ export default ApplicationSerializer.extend({
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
const normalizedPayload = {
id: payload.id,
...payload.data,
enabled: payload.data.enabled.includes('enabled') ? 'On' : 'Off',
data: {
...payload.data,
enabled: payload.data.enabled.includes('enabled') ? 'On' : 'Off',
},
};
return this._super(store, primaryModelClass, normalizedPayload, id, requestType);
},

View File

@@ -1,3 +0,0 @@
import MetricsSerializer from '../metrics';
export default MetricsSerializer.extend();

View File

@@ -1,3 +0,0 @@
import MetricsSerializer from '../metrics';
export default MetricsSerializer.extend();

View File

@@ -30,8 +30,8 @@ const API_PATHS = {
raft: 'sys/storage/raft/configuration',
},
metrics: {
dashboard: 'sys/internal/counters',
requests: 'sys/internal/counters/requests',
activity: 'sys/internal/counters/activity',
config: 'sys/internal/counters/config',
},
};

View File

@@ -0,0 +1,4 @@
.pricing-metrics-date-form {
display: flex;
align-items: flex-end;
}

View File

@@ -78,6 +78,7 @@
@import './components/navigate-input';
@import './components/page-header';
@import './components/popup-menu';
@import './components/pricing-metrics-dates';
@import './components/radio-card';
@import './components/radial-progress';
@import './components/raft-join';

View File

@@ -0,0 +1,52 @@
<div class="field-body pricing-metrics-date-form">
<div class="field is-narrow">
<label for="start" class="is-label">From</label>
<div class="control">
{{input
type="string"
value=start
name="start"
class=(concat 'input' (unless startDate ' has-error'))
autocomplete="off"
spellcheck="false"
data-test-start-input="true"
}}
</div>
</div>
<div class="field is-narrow">
<label for="end" class="is-label">Through</label>
<div class="control">
{{input
type="string"
value=end
name="end"
class=(concat 'input' (unless endDate ' has-error'))
autocomplete="off"
spellcheck="false"
data-test-end-input="true"
}}
</div>
</div>
{{#link-to 'vault.cluster.metrics'
(query-params start=(date-format startDate 'MM-YYYY') end=(date-format endDate 'MM-YYYY'))
class="button"
disabled=error
}}
Query
{{/link-to}}
</div>
{{#if error}}
<FormError>{{error}}</FormError>
{{/if}}
<div class="box is-fullwidth is-shadowless">
<h2 class="title is-4">
{{date-format resultStart "MMM DD, YYYY"}} through {{date-format resultEnd "MMM DD, YYYY"}}
</h2>
{{#if showResultsWarning}}
<div class="access-information" data-test-results-date-warning>
<Icon @glyph="info-circle-fill" class="has-text-info"/>
{{!-- TODO: Add "Learn more here." link --}}
<p>This data may not reflect your search exactly. This is because Vault will only show data for contiguous blocks of time during which tracking was on. </p>
</div>
{{/if}}
</div>

View File

@@ -152,10 +152,10 @@
{{/if}}
</ul>
{{/if}}
{{#if ( and (has-permission 'metrics' routeParams='dashboard') (not cluster.dr.isSecondary) auth.currentToken)}}
{{#if ( and (has-permission 'metrics' routeParams='activity') (not cluster.dr.isSecondary) auth.currentToken)}}
<ul class="menu-list">
<li class="action">
{{#link-to 'vault.cluster.metrics'
{{#link-to 'vault.cluster.metrics'
invokeAction=(action (queue (action onLinkClick) (action d.actions.close)))
}}
<div class="level is-mobile">

View File

@@ -72,6 +72,18 @@
<StatusMenu @label="Status" @onLinkClick={{action Nav.closeDrawer}} />
</div>
<div class="navbar-separator is-hidden-mobile"></div>
{{else if (and (has-permission 'metrics' routeParams='activity') (not cluster.dr.isSecondary) auth.currentToken)}}
<div class="navbar-sections">
<div class="{{if (is-active-route 'vault.cluster.metrics') 'is-active'}}">
{{#link-to
"vault.cluster.metrics"
current-when="vault.cluster.metrics"
data-test-navbar-item='metrics'
}}
Metrics
{{/link-to}}
</div>
</div>
{{/if}}
<div class="navbar-item">
<button type="button" class="button is-transparent nav-console-button{{if consoleOpen " popup-open"}}"

View File

@@ -37,34 +37,53 @@
</nav>
</div>
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
{{#unless (eq model.config.enabled 'On')}}
<AlertBanner
@type="warning"
@title="Tracking is disabled"
>
This feature is currently disabled and data is not being collected. {{#link-to 'vault.cluster.metrics-config'}}Edit the configuration{{/link-to}} to enable tracking again.
</AlertBanner>
{{/unless}}
<p class="has-bottom-margin-s">The active clients metric contributes to billing. It is collected at the end of each month alongside unique entities and direct active tokens.</p>
<h2 class="title is-4">
{{date-format model.activity.startTime "MMM DD, YYYY"}} through {{date-format model.activity.endTime "MMM DD, YYYY"}}
</h2>
<div class="selectable-card-container">
<SelectableCard
@cardTitle="Active clients"
@total={{model.activity.total.clients}}
@subText="Current namespace"
/>
<SelectableCard
@cardTitle="Unique entities"
@total={{model.activity.total.distinct_entities}}
@subText="Current namespace"
/>
<SelectableCard
@cardTitle="Active direct tokens"
@total={{model.activity.total.non_entity_tokens}}
@subText="Current namespace"
{{#if (eq model.config.queriesAvailable false)}}
{{#if (eq model.config.enabled "On")}}
<EmptyState @title="No data is being received" @message='We havent yet gathered enough data to display here. We collect it at the end of each month, so your data will be available on the first of next month.' />
{{else}}
<EmptyState @title="No data is being received" @message='Tracking is disabled, and no data is being collected. To turn it on, edit the configuration.'>
<p>{{#link-to 'vault.cluster.metrics-config'}}Go to configuration{{/link-to}}</p>
</EmptyState>
{{/if}}
{{else}}
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
{{#if (eq model.config.enabled 'Off')}}
<AlertBanner
@type="warning"
@title="Tracking is disabled"
>
This feature is currently disabled and data is not being collected. {{#link-to 'vault.cluster.metrics-config'}}Edit the configuration{{/link-to}} to enable tracking again.
</AlertBanner>
{{/if}}
<p class="has-bottom-margin-s">The active clients metric contributes to billing. It is collected at the end of each month alongside unique entities and direct active tokens.</p>
<PricingMetricsDates
@queryStart={{model.queryStart}}
@queryEnd={{model.queryEnd}}
@resultStart={{model.activity.startTime}}
@resultEnd={{model.activity.endTime}}
/>
{{#unless model.activity.total}}
<EmptyState @title="No data found" @message="No data exists for that query period. Try searching again." />
{{else}}
<div class="selectable-card-container">
<SelectableCard
@cardTitle="Active clients"
@total={{model.activity.total.clients}}
@subText="Current namespace"
/>
<SelectableCard
@cardTitle="Unique entities"
@total={{model.activity.total.distinct_entities}}
@subText="Current namespace"
/>
<SelectableCard
@cardTitle="Active direct tokens"
@total={{model.activity.total.non_entity_tokens}}
@subText="Current namespace"
/>
</div>
{{/unless}}
</div>
</div>
{{/if}}

View File

@@ -25,7 +25,12 @@ module.exports = function(environment) {
// endpoints that UI uses to determine the cluster state
// calls to these endpoints will always go to the root namespace
// these also need to be updated in the open-api-explorer engine
NAMESPACE_ROOT_URLS: ['sys/health', 'sys/seal-status', 'sys/license/features'],
NAMESPACE_ROOT_URLS: [
'sys/health',
'sys/seal-status',
'sys/license/features',
'sys/internal/counters/config',
],
// number of records to show on a single page by default - this is used by the client-side pagination
DEFAULT_PAGE_SIZE: 100,
},

View File

@@ -0,0 +1,18 @@
/**
* @module FormError
* FormError components are used to show an error on a form field that is more compact than the
* normal MessageError component. This component adds an icon and styling to the content of the
* component, so additionally styling (bold, italic) and links are allowed.
*
* @example
* ```js
* <FormError>Oh no <em>something bad</em>! <a href="#">Do something</a></FormError>
* ```
*/
import Component from '@ember/component';
import layout from '../templates/components/form-error';
export default Component.extend({
layout,
});

View File

@@ -0,0 +1,13 @@
<div class="has-top-margin-s is-flex is-flex-center">
<div class="is-narrow message-icon">
<Icon
@size="s"
class="has-text-danger"
aria-hidden=true
@glyph="cancel-square-fill"
/>
</div>
<div class="has-text-danger">
{{yield}}
</div>
</div>

View File

@@ -0,0 +1 @@
export { default } from 'core/components/form-error';

View File

@@ -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';
module('Integration | Component | form-error', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{form-error}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#form-error}}
template block text
{{/form-error}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@@ -0,0 +1,85 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { subMonths, startOfToday, format } from 'date-fns';
module('Integration | Component | pricing-metrics-dates', function(hooks) {
setupRenderingTest(hooks);
test('by default it sets the start and end inputs', async function(assert) {
const expectedEnd = subMonths(startOfToday(), 1);
const expectedStart = subMonths(expectedEnd, 12);
await render(hbs`
<PricingMetricsDates />
`);
assert.dom('[data-test-end-input]').hasValue(format(expectedEnd, 'MM/YYYY'), 'End input is last month');
assert
.dom('[data-test-start-input]')
.hasValue(format(expectedStart, 'MM/YYYY'), 'Start input is 12 months before last month');
});
test('On init if end date passed, start is calculated', async function(assert) {
const expectedStart = subMonths(new Date(2020, 8, 15), 12);
this.set('queryEnd', '09-2020');
await render(hbs`
<PricingMetricsDates @queryEnd={{queryEnd}} />
`);
assert.dom('[data-test-end-input]').hasValue('09/2020', 'End input matches query');
assert
.dom('[data-test-start-input]')
.hasValue(format(expectedStart, 'MM/YYYY'), 'Start input is 12 months before end input');
});
test('On init if query start date passed, end is default', async function(assert) {
const expectedEnd = subMonths(startOfToday(), 1);
this.set('queryStart', '01-2020');
await render(hbs`
<PricingMetricsDates @queryStart={{queryStart}} />
`);
assert.dom('[data-test-end-input]').hasValue(format(expectedEnd, 'MM/YYYY'), 'End input is last month');
assert.dom('[data-test-start-input]').hasValue('01/2020', 'Start input matches query');
});
test('If result and query dates are within 1 day, warning is not shown', async function(assert) {
this.set('resultStart', new Date(2020, 1, 1));
this.set('resultEnd', new Date(2020, 9, 31));
await render(hbs`
<PricingMetricsDates
@queryStart="2-2020"
@queryEnd="10-2020"
@resultStart={{resultStart}}
@resultEnd={{resultEnd}}
/>
`);
assert.dom('[data-test-results-date-warning]').doesNotExist('Does not show result states warning');
});
test('If result and query start dates are > 1 day apart, warning is shown', async function(assert) {
this.set('resultStart', new Date(2020, 1, 20));
this.set('resultEnd', new Date(2020, 9, 31));
await render(hbs`
<PricingMetricsDates
@queryStart="2-2020"
@queryEnd="10-2020"
@resultStart={{resultStart}}
@resultEnd={{resultEnd}}
/>
`);
assert.dom('[data-test-results-date-warning]').exists('shows states warning');
});
test('If result and query end dates are > 1 day apart, warning is shown', async function(assert) {
this.set('resultStart', new Date(2020, 1, 1));
this.set('resultEnd', new Date(2020, 9, 15));
await render(hbs`
<PricingMetricsDates
@queryStart="2-2020"
@queryEnd="10-2020"
@resultStart={{resultStart}}
@resultEnd={{resultEnd}}
/>
`);
assert.dom('[data-test-results-date-warning]').exists('shows states warning');
});
});

View File

@@ -0,0 +1,47 @@
import { parseDateString } from 'vault/helpers/parse-date-string';
import { module, test } from 'qunit';
import { compareAsc } from 'date-fns';
module('Unit | Helpers | parse-date-string', function() {
test('it returns the first of the month when date like MM-YYYY passed in', function(assert) {
let expected = new Date(2020, 3, 1);
let result = parseDateString('04-2020');
assert.equal(compareAsc(expected, result), 0);
});
test('it can handle a date format like MM/YYYY', function(assert) {
let expected = new Date(2020, 11, 1);
let result = parseDateString('12/2020', '/');
assert.equal(compareAsc(expected, result), 0);
});
test('it throws an error with passed separator if bad format', function(assert) {
let result;
try {
result = parseDateString('01-12-2020');
} catch (e) {
result = e.message;
}
assert.equal('Please use format MM-YYYY', result);
});
test('it throws an error with wrong separator', function(assert) {
let result;
try {
result = parseDateString('12/2020', '.');
} catch (e) {
result = e.message;
}
assert.equal('Please use format MM.YYYY', result);
});
test('it throws an error if month is invalid', function(assert) {
let result;
try {
result = parseDateString('13-2020');
} catch (e) {
result = e.message;
}
assert.equal('Not a valid month value', result);
});
});