diff --git a/ui/app/adapters/metrics/activity.js b/ui/app/adapters/metrics/activity.js index 250b38cb41..0500c2a783 100644 --- a/ui/app/adapters/metrics/activity.js +++ b/ui/app/adapters/metrics/activity.js @@ -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'; - }, }); diff --git a/ui/app/adapters/metrics/entity.js b/ui/app/adapters/metrics/entity.js deleted file mode 100644 index 550febd659..0000000000 --- a/ui/app/adapters/metrics/entity.js +++ /dev/null @@ -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'; - }, -}); diff --git a/ui/app/adapters/metrics/http-requests.js b/ui/app/adapters/metrics/http-requests.js deleted file mode 100644 index 1bb071a402..0000000000 --- a/ui/app/adapters/metrics/http-requests.js +++ /dev/null @@ -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'; - }, -}); diff --git a/ui/app/adapters/metrics/token.js b/ui/app/adapters/metrics/token.js deleted file mode 100644 index 7baf6cb4f7..0000000000 --- a/ui/app/adapters/metrics/token.js +++ /dev/null @@ -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'; - }, -}); diff --git a/ui/app/components/pricing-metrics-dates.js b/ui/app/components/pricing-metrics-dates.js new file mode 100644 index 0000000000..076ab9f6c7 --- /dev/null +++ b/ui/app/components/pricing-metrics-dates.js @@ -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 + * + * ``` + * @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'); + }, +}); diff --git a/ui/app/controllers/vault/cluster/metrics.js b/ui/app/controllers/vault/cluster/metrics.js new file mode 100644 index 0000000000..e355af5b80 --- /dev/null +++ b/ui/app/controllers/vault/cluster/metrics.js @@ -0,0 +1,8 @@ +import Controller from '@ember/controller'; + +export default Controller.extend({ + queryParams: ['start', 'end'], + + start: null, + end: null, +}); diff --git a/ui/app/helpers/parse-date-string.js b/ui/app/helpers/parse-date-string.js new file mode 100644 index 0000000000..0e5c4552d7 --- /dev/null +++ b/ui/app/helpers/parse-date-string.js @@ -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); diff --git a/ui/app/models/metrics/entity.js b/ui/app/models/metrics/entity.js deleted file mode 100644 index 3a570bad29..0000000000 --- a/ui/app/models/metrics/entity.js +++ /dev/null @@ -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'), -}); diff --git a/ui/app/models/metrics/http-requests.js b/ui/app/models/metrics/http-requests.js deleted file mode 100644 index edf020cfe7..0000000000 --- a/ui/app/models/metrics/http-requests.js +++ /dev/null @@ -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'), -}); diff --git a/ui/app/models/metrics/token.js b/ui/app/models/metrics/token.js deleted file mode 100644 index 5d112ad893..0000000000 --- a/ui/app/models/metrics/token.js +++ /dev/null @@ -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'), -}); diff --git a/ui/app/routes/vault/cluster/metrics.js b/ui/app/routes/vault/cluster/metrics.js index df7cfcb0a6..fa5bd1a1f0 100644 --- a/ui/app/routes/vault/cluster/metrics.js +++ b/ui/app/routes/vault/cluster/metrics.js @@ -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, }); diff --git a/ui/app/serializers/metrics.js b/ui/app/serializers/metrics.js deleted file mode 100644 index 31683a6ca5..0000000000 --- a/ui/app/serializers/metrics.js +++ /dev/null @@ -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); - }, -}); diff --git a/ui/app/serializers/metrics/config.js b/ui/app/serializers/metrics/config.js index 0d716b9c65..4d10789d13 100644 --- a/ui/app/serializers/metrics/config.js +++ b/ui/app/serializers/metrics/config.js @@ -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); }, diff --git a/ui/app/serializers/metrics/entity.js b/ui/app/serializers/metrics/entity.js deleted file mode 100644 index 39d9fbd070..0000000000 --- a/ui/app/serializers/metrics/entity.js +++ /dev/null @@ -1,3 +0,0 @@ -import MetricsSerializer from '../metrics'; - -export default MetricsSerializer.extend(); diff --git a/ui/app/serializers/metrics/token.js b/ui/app/serializers/metrics/token.js deleted file mode 100644 index 39d9fbd070..0000000000 --- a/ui/app/serializers/metrics/token.js +++ /dev/null @@ -1,3 +0,0 @@ -import MetricsSerializer from '../metrics'; - -export default MetricsSerializer.extend(); diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 61dd1606f6..8b2399be48 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -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', }, }; diff --git a/ui/app/styles/components/pricing-metrics-dates.scss b/ui/app/styles/components/pricing-metrics-dates.scss new file mode 100644 index 0000000000..b7483fdf3c --- /dev/null +++ b/ui/app/styles/components/pricing-metrics-dates.scss @@ -0,0 +1,4 @@ +.pricing-metrics-date-form { + display: flex; + align-items: flex-end; +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index 2e684cfd7d..07a3eb74d8 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -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'; diff --git a/ui/app/templates/components/pricing-metrics-dates.hbs b/ui/app/templates/components/pricing-metrics-dates.hbs new file mode 100644 index 0000000000..8d274003eb --- /dev/null +++ b/ui/app/templates/components/pricing-metrics-dates.hbs @@ -0,0 +1,52 @@ +
+
+ +
+ {{input + type="string" + value=start + name="start" + class=(concat 'input' (unless startDate ' has-error')) + autocomplete="off" + spellcheck="false" + data-test-start-input="true" + }} +
+
+
+ +
+ {{input + type="string" + value=end + name="end" + class=(concat 'input' (unless endDate ' has-error')) + autocomplete="off" + spellcheck="false" + data-test-end-input="true" + }} +
+
+ {{#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}} +
+{{#if error}} + {{error}} +{{/if}} +
+

+ {{date-format resultStart "MMM DD, YYYY"}} through {{date-format resultEnd "MMM DD, YYYY"}} +

+ {{#if showResultsWarning}} +
+ + {{!-- TODO: Add "Learn more here." link --}} +

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.

+
+ {{/if}} +
diff --git a/ui/app/templates/partials/status/cluster.hbs b/ui/app/templates/partials/status/cluster.hbs index b084a56d63..5f1799cebc 100644 --- a/ui/app/templates/partials/status/cluster.hbs +++ b/ui/app/templates/partials/status/cluster.hbs @@ -152,10 +152,10 @@ {{/if}} {{/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)}}