Client count updates (#12554)

* Client count updates

- Added Current month tab which leverages partial monthly activity api
- Refactored Vault usage to Monthly history
- New client count history component based on StatText and BarChart component
- Restrict bar chart to showcase only top 10 namespaces
- Removed config route, as config and history component will be rendered based on query param
- Updated all metrics reference to clients
- Removed old tests and added integration test for current month

* Fixed navbar permission

- Added changelog

* Updated the model for current month data

* Fixed current month tests

* Fixed indentation and chart label
This commit is contained in:
Arnav Palnitkar 2021-09-16 15:28:03 -07:00 committed by GitHub
parent b2418a3a8c
commit 35c188fba7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 440 additions and 367 deletions

3
changelog/12554.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: client count monthly view
```

View File

@ -5,7 +5,11 @@ export default Application.extend({
return 'internal/counters/activity'; return 'internal/counters/activity';
}, },
queryRecord(store, type, query) { queryRecord(store, type, query) {
const url = this.urlForQuery(null, type); let url = this.urlForQuery(null, type);
if (query.tab === 'current') {
url = `${url}/monthly`;
query = null;
}
// API accepts start and end as query params // API accepts start and end as query params
return this.ajax(url, 'GET', { data: query }).then(resp => { return this.ajax(url, 'GET', { data: query }).then(resp => {
let response = resp || {}; let response = resp || {};

View File

@ -1,12 +1,12 @@
/** /**
* @module PricingMetricsConfig * @module ClientsConfig
* PricingMetricsConfig components are used to show and edit the pricing metrics config information. * ClientsConfig components are used to show and edit the client count config information.
* *
* @example * @example
* ```js * ```js
* <PricingMetricsConfig @model={{model}} @mode="edit" /> * <Clients::Config @model={{model}} @mode="edit" />
* ``` * ```
* @param {object} model - model is the DS metrics/config model which should be passed in * @param {object} model - model is the DS clients/config model which should be passed in
* @param {string} [mode=show] - mode is either show or edit. Show results in a table with the config, show has a form. * @param {string} [mode=show] - mode is either show or edit. Show results in a table with the config, show has a form.
*/ */
@ -16,7 +16,7 @@ import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
export default class PricingMetricsConfigComponent extends Component { export default class ConfigComponent extends Component {
@service router; @service router;
@tracked mode = 'show'; @tracked mode = 'show';
@tracked modalOpen = false; @tracked modalOpen = false;
@ -57,7 +57,7 @@ export default class PricingMetricsConfigComponent extends Component {
this.error = err.message; this.error = err.message;
return; return;
} }
this.router.transitionTo('vault.cluster.metrics.config'); this.router.transitionTo('vault.cluster.clients.index');
}).drop()) }).drop())
save; save;

View File

@ -0,0 +1,40 @@
import Component from '@glimmer/component';
export default class HistoryComponent extends Component {
max_namespaces = 10;
get hasClientData() {
if (this.args.tab === 'current') {
return this.args.model.activity && this.args.model.activity.clients;
}
return this.args.model.activity && this.args.model.activity.total;
}
get barChartDataset() {
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
return null;
}
let dataset = this.args.model.activity.byNamespace;
// Filter out root data
dataset = dataset.filter(item => {
return item.namespace_id !== 'root';
});
// Show only top 10 namespaces
dataset = dataset.slice(0, this.max_namespaces);
return dataset.map(d => {
return {
label: d['namespace_path'],
non_entity_tokens: d['counts']['non_entity_tokens'],
distinct_entities: d['counts']['distinct_entities'],
total: d['counts']['clients'],
};
});
}
get showGraphs() {
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
return null;
}
return this.args.model.activity.byNamespace.length > 1;
}
}

View File

@ -18,16 +18,7 @@
import { set, computed } from '@ember/object'; import { set, computed } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import Component from '@ember/component'; import Component from '@ember/component';
import { import { subMonths, startOfToday, format, endOfMonth, startOfMonth, isBefore } from 'date-fns';
differenceInSeconds,
isValid,
subMonths,
startOfToday,
format,
endOfMonth,
startOfMonth,
isBefore,
} from 'date-fns';
import layout from '../templates/components/pricing-metrics-dates'; import layout from '../templates/components/pricing-metrics-dates';
import { parseDateString } from 'vault/helpers/parse-date-string'; import { parseDateString } from 'vault/helpers/parse-date-string';
@ -69,35 +60,6 @@ export default Component.extend({
} }
}), }),
// We don't want the warning to show when inputs are being updated before query is made
/* eslint-disable-next-line ember/require-computed-property-dependencies */
showResultsWarning: computed('resultEnd', 'resultStart', 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', 'endDate', 'retentionMonths', 'start', 'startDate', function() { error: computed('end', 'endDate', 'retentionMonths', 'start', 'startDate', function() {
if (!this.startDate) { if (!this.startDate) {
return 'Start date is invalid. Please use format MM/yyyy'; return 'Start date is invalid. Please use format MM/yyyy';
@ -148,7 +110,7 @@ export default Component.extend({
handleQuery() { handleQuery() {
const start = format(this.startDate, 'MM-yyyy'); const start = format(this.startDate, 'MM-yyyy');
const end = format(this.endDate, 'MM-yyyy'); const end = format(this.endDate, 'MM-yyyy');
this.router.transitionTo('vault.cluster.metrics', { this.router.transitionTo('vault.cluster.clients', {
queryParams: { queryParams: {
start, start,
end, end,

View File

@ -0,0 +1,8 @@
import Controller from '@ember/controller';
export default class ClientsController extends Controller {
queryParams = ['tab', 'start', 'end'];
tab = null;
start = null;
end = null;
}

View File

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

View File

@ -2,6 +2,10 @@ import Model, { attr } from '@ember-data/model';
export default Model.extend({ export default Model.extend({
total: attr('object'), total: attr('object'),
byNamespace: attr('array'),
endTime: attr('string'), endTime: attr('string'),
startTime: attr('string'), startTime: attr('string'),
clients: attr('number'),
distinct_entities: attr('number'),
non_entity_tokens: attr('number'),
}); });

View File

@ -15,9 +15,8 @@ Router.map(function() {
this.route('logout'); this.route('logout');
this.mount('open-api-explorer', { path: '/api-explorer' }); this.mount('open-api-explorer', { path: '/api-explorer' });
this.route('license'); this.route('license');
this.route('metrics', function() { this.route('clients', function() {
this.route('index', { path: '/' }); this.route('index', { path: '/' });
this.route('config');
this.route('edit'); this.route('edit');
}); });
this.route('storage', { path: '/storage/raft' }); this.route('storage', { path: '/storage/raft' });

View File

@ -2,6 +2,6 @@ import Route from '@ember/routing/route';
export default Route.extend({ export default Route.extend({
model() { model() {
return this.store.queryRecord('metrics/config', {}); return this.store.queryRecord('clients/config', {});
}, },
}); });

View File

@ -4,23 +4,27 @@ import { hash } from 'rsvp';
import { getTime } from 'date-fns'; import { getTime } from 'date-fns';
import { parseDateString } from 'vault/helpers/parse-date-string'; import { parseDateString } from 'vault/helpers/parse-date-string';
const getActivityParams = ({ start, end }) => { const getActivityParams = ({ tab, start, end }) => {
// Expects MM-yyyy format // Expects MM-yyyy format
// TODO: minStart, maxEnd // TODO: minStart, maxEnd
let params = {}; let params = {};
if (start) { if (tab === 'current') {
let startDate = parseDateString(start); params.tab = tab;
if (startDate) { } else if (tab === 'history') {
// TODO: Replace with formatRFC3339 when date-fns is updated if (start) {
// converts to milliseconds, divide by 1000 to get epoch let startDate = parseDateString(start);
params.start_time = getTime(startDate) / 1000; if (startDate) {
// TODO: Replace with formatRFC3339 when date-fns is updated
// converts to milliseconds, divide by 1000 to get epoch
params.start_time = getTime(startDate) / 1000;
}
} }
} if (end) {
if (end) { let endDate = parseDateString(end);
let endDate = parseDateString(end); if (endDate) {
if (endDate) { // TODO: Replace with formatRFC3339 when date-fns is updated
// TODO: Replace with formatRFC3339 when date-fns is updated params.end_time = getTime(endDate) / 1000;
params.end_time = getTime(endDate) / 1000; }
} }
} }
return params; return params;
@ -28,6 +32,9 @@ const getActivityParams = ({ start, end }) => {
export default Route.extend(ClusterRoute, { export default Route.extend(ClusterRoute, {
queryParams: { queryParams: {
tab: {
refreshModel: true,
},
start: { start: {
refreshModel: true, refreshModel: true,
}, },
@ -37,13 +44,13 @@ export default Route.extend(ClusterRoute, {
}, },
model(params) { model(params) {
let config = this.store.queryRecord('metrics/config', {}).catch(e => { let config = this.store.queryRecord('clients/config', {}).catch(e => {
console.debug(e); console.debug(e);
// swallowing error so activity can show if no config permissions // swallowing error so activity can show if no config permissions
return {}; return {};
}); });
const activityParams = getActivityParams(params); const activityParams = getActivityParams(params);
let activity = this.store.queryRecord('metrics/activity', activityParams); let activity = this.store.queryRecord('clients/activity', activityParams);
return hash({ return hash({
queryStart: params.start, queryStart: params.start,

View File

@ -1,8 +0,0 @@
import Route from '@ember/routing/route';
import ClusterRoute from 'vault/mixins/cluster-route';
export default Route.extend(ClusterRoute, {
model() {
return this.store.queryRecord('metrics/config', {});
},
});

View File

@ -29,7 +29,7 @@ const API_PATHS = {
seal: 'sys/seal', seal: 'sys/seal',
raft: 'sys/storage/raft/configuration', raft: 'sys/storage/raft/configuration',
}, },
metrics: { clients: {
activity: 'sys/internal/counters/activity', activity: 'sys/internal/counters/activity',
config: 'sys/internal/counters/config', config: 'sys/internal/counters/config',
}, },

View File

@ -7,14 +7,11 @@
> div.is-border { > div.is-border {
border: 0.3px solid $ui-gray-200; border: 0.3px solid $ui-gray-200;
width: 94%;
margin-left: 3%;
margin-bottom: $spacing-xxs; margin-bottom: $spacing-xxs;
} }
} }
.chart-header { .chart-header {
margin-left: $spacing-l;
display: grid; display: grid;
grid-template-columns: 3fr 1fr; grid-template-columns: 3fr 1fr;
@ -47,7 +44,7 @@
} }
.bar-chart-container { .bar-chart-container {
padding: $spacing-m $spacing-l $spacing-m $spacing-l; padding: $spacing-m 0;
} }
.bar-chart { .bar-chart {

View File

@ -1,5 +1,8 @@
.stat-text-container { .stat-text-container {
line-height: normal; line-height: normal;
height: 100%;
display: flex;
flex-direction: column;
&.l, &.l,
&.m { &.m {
@ -13,6 +16,7 @@
font-weight: $font-weight-normal; font-weight: $font-weight-normal;
color: $ui-gray-700; color: $ui-gray-700;
line-height: inherit; line-height: inherit;
flex-grow: 1;
} }
.stat-value { .stat-value {
font-size: $size-3; font-size: $size-3;

View File

@ -54,7 +54,8 @@
Save Save
</button> </button>
<LinkTo <LinkTo
@route="vault.cluster.metrics.config" @route="vault.cluster.clients.index"
@query={{hash tab="config"}}
class="button"> class="button">
Cancel Cancel
</LinkTo> </LinkTo>

View File

@ -0,0 +1,149 @@
{{#if (eq @model.config.queriesAvailable false)}}
{{#if (eq @model.config.enabled 'On')}}
<EmptyState
@title="No monthly history"
@message="There is no data in the monthly history yet. We collect it at the end of each month, so your data will be available on the first of next month."
/>
{{else}}
<EmptyState
@title="Data tracking is disabled"
@message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
>
{{#if @model.config.configPath.canUpdate}}
<p>
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='config'}}>
Go to configuration
</LinkTo>
</p>
{{/if}}
</EmptyState>
{{/if}}
{{else}}
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
{{#if (eq @tab 'current')}}
<h1 data-test-client-count-title class="title is-4 has-bottom-margin-s">
Current month
</h1>
<p class="has-bottom-margin-s">
The below data is for the current month starting from the first day. For historical data, see the monthly
history tab.
</p>
{{#if (eq @model.config.enabled 'Off')}}
<EmptyState
@title="Tracking is disabled"
@message="Tracking is disabled and data is not being collected. To turn it on edit the configuration."
>
{{#if @model.config.configPath.canUpdate}}
<LinkTo @route="vault.cluster.clients.edit">
Go to configuration
</LinkTo>
{{/if}}
</EmptyState>
{{/if}}
{{else}}
{{#if (eq @model.config.enabled 'Off')}}
<AlertBanner data-test-tracking-disabled @type="warning" @title="Tracking is disabled">
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will
need to
<LinkTo @route="vault.cluster.clients.edit">
edit the configuration
</LinkTo>
to enable tracking again.
</AlertBanner>
{{/if}}
<h1 data-test-client-count-title class="title is-4 has-bottom-margin-s">
Monthly history
</h1>
<p class="has-bottom-margin-s">
This data is presented by full month. If there is data missing, it's possible that tracking was turned off at
the time. Vault will only show data for contiguous blocks of time during which tracking was on.
</p>
<PricingMetricsDates
@queryStart={{@model.queryStart}}
@queryEnd={{@model.queryEnd}}
@resultStart={{@model.activity.startTime}}
@resultEnd={{@model.activity.endTime}}
@defaultSpan={{or @model.config.defaultReportMonths 12}}
@retentionMonths={{@model.config.retentionMonths}}
/>
{{/if}}
{{#unless this.hasClientData}}
{{#if (eq @tab 'current')}}
{{#if (eq @model.config.enabled 'On')}}
<EmptyState
@title="No data received"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
{{/if}}
{{else}}
<EmptyState
@title="No data received"
@message="No data exists for that query period. Try searching again."
/>
{{/if}}
{{else}}
<div class="card has-bottom-margin-m">
<div class="card-content">
<div class="is-flex is-flex-center">
<div class="is-flex-1">
<h2 class="title is-5 is-marginless">
Total usage
</h2>
<p class="sub-text">
These totals are within this namespace and all its children.
</p>
</div>
<LearnLink @path="/tutorials/vault/usage-metrics">
Learn more
</LearnLink>
</div>
<hr />
<div class="columns">
<div class="column" data-test-client-count-stats>
<StatText
@label="Total active Clients"
@value={{or @model.activity.clients @model.activity.total.clients}}
@size="l"
@subText="The sum of unique entities and active direct tokens; Vault's primary billing metric."
/>
</div>
<div class="column">
<StatText
class="column"
@label="Unique entities"
@value={{or @model.activity.distinct_entities @model.activity.total.distinct_entities}}
@size="l"
@subText="Representation of a particular user, client or application that created a token via login."
/>
</div>
<div class="column">
<StatText
class="column"
@label="Active direct tokens"
@value={{or @model.activity.non_entity_tokens @model.activity.total.non_entity_tokens}}
@size="l"
@subText="Tokens created via a method that is not associated with an entity."
/>
</div>
</div>
</div>
</div>
{{#if this.showGraphs}}
<div class="columns has-bottom-margin-m">
<div class="column is-two-thirds">
<BarChart
@title="Top 10 Namespaces"
@description="Each namespace's client count includes clients in child namespaces."
@dataset={{this.barChartDataset}}
@mapLegend={{array
(hash key='non_entity_tokens' label='Active direct tokens')
(hash key='distinct_entities' label='Unique entities')
}}
/>
</div>
<div class="column"></div>
</div>
{{/if}}
{{/unless}}
</div>
{{/if}}

View File

@ -116,12 +116,12 @@
{{/if}} {{/if}}
</ul> </ul>
{{/if}} {{/if}}
{{#if ( and (has-permission 'metrics' routeParams='activity') (not @cluster.dr.isSecondary) this.auth.currentToken)}} {{#if ( and (has-permission 'clients' routeParams='activity') (not @cluster.dr.isSecondary) this.auth.currentToken)}}
<ul class="menu-list"> <ul class="menu-list">
<li class="action"> <li class="action">
<LinkTo @route="vault.cluster.metrics" @invokeAction={{@onLinkClick}}> <LinkTo @route="vault.cluster.clients" @query={{hash tab="current"}} @invokeAction={{@onLinkClick}}>
<div class="level is-mobile"> <div class="level is-mobile">
<span class="level-left">Metrics</span> <span class="level-left">Client count</span>
<Chevron class="has-text-grey-light level-right" /> <Chevron class="has-text-grey-light level-right" />
</div> </div>
</LinkTo> </LinkTo>

View File

@ -41,14 +41,4 @@
{{date-format resultStart "MMM dd, yyyy"}} through {{date-format resultEnd "MMM dd, yyyy"}} {{date-format resultStart "MMM dd, yyyy"}} through {{date-format resultEnd "MMM dd, yyyy"}}
</h2> </h2>
{{/if}} {{/if}}
{{#if showResultsWarning}}
<div class="access-information" data-test-results-date-warning>
<Icon @glyph="info-circle-fill" class="has-text-info"/>
<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.
<LearnLink @path="/tutorials/vault/usage-metrics">
Learn more here
</LearnLink>
</p>
</div>
{{/if}}
</div> </div>

View File

@ -72,15 +72,16 @@
<StatusMenu @label="Status" @onLinkClick={{action Nav.closeDrawer}} /> <StatusMenu @label="Status" @onLinkClick={{action Nav.closeDrawer}} />
</div> </div>
<div class="navbar-separator is-hidden-mobile"></div> <div class="navbar-separator is-hidden-mobile"></div>
{{else if (and (has-permission 'metrics' routeParams='activity') (not cluster.dr.isSecondary) auth.currentToken)}} {{else if (and (has-permission 'clients' routeParams='activity') (not cluster.dr.isSecondary) auth.currentToken)}}
<div class="navbar-sections"> <div class="navbar-sections">
<div class="{{if (is-active-route 'vault.cluster.metrics') 'is-active'}}"> <div class="{{if (is-active-route 'vault.cluster.clients') 'is-active'}}">
{{#link-to {{#link-to
"vault.cluster.metrics" "vault.cluster.clients"
current-when="vault.cluster.metrics" (query-params tab="history")
current-when="vault.cluster.clients"
data-test-navbar-item='metrics' data-test-navbar-item='metrics'
}} }}
Metrics Client count
{{/link-to}} {{/link-to}}
</div> </div>
</div> </div>

View File

@ -6,4 +6,4 @@
</p.levelLeft> </p.levelLeft>
</PageHeader> </PageHeader>
<PricingMetricsConfig @model={{model}} @mode="edit" /> <Clients::Config @model={{model}} @mode="edit" />

View File

@ -0,0 +1,46 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Vault Client Count
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-pricing-metrics>
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='current' start=null end=null}} @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='current' start=null end=null}} data-test-usage-tab={{true}}>
Current month
</LinkTo>
</LinkTo>
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='history'}} @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='history'}} data-test-usage-tab={{true}}>
Monthly history
</LinkTo>
</LinkTo>
{{#if model.config.configPath.canRead}}
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='config' start=null end=null}} @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.clients.index" @query={{hash tab='config' start=null end=null}} data-test-configuration-tab={{true}}>
Configuration
</LinkTo>
</LinkTo>
{{/if}}
</ul>
</nav>
</div>
{{#if (eq tab "config")}}
<Toolbar>
<ToolbarActions>
{{#if model.config.configPath.canUpdate}}
<LinkTo @route="vault.cluster.clients.edit" class="toolbar-link">
Edit configuration
</LinkTo>
{{/if}}
</ToolbarActions>
</Toolbar>
<Clients::Config @model={{model.config}} />
{{else}}
<Clients::History @tab={{tab}} @model={{model}} />
{{/if}}

View File

@ -1,36 +0,0 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Metrics
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.metrics.index" @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.metrics.index" data-test-configuration-tab={{false}}>
Vault usage
</LinkTo>
</LinkTo>
<LinkTo @route="vault.cluster.metrics.config" @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.metrics.config" data-test-configuration-tab={{true}}>
Configuration
</LinkTo>
</LinkTo>
</ul>
</nav>
</div>
<Toolbar>
<ToolbarActions>
{{#if model.configPath.canUpdate}}
<LinkTo @route="vault.cluster.metrics.edit" class="toolbar-link">
Edit configuration
</LinkTo>
{{/if}}
</ToolbarActions>
</Toolbar>
<PricingMetricsConfig @model={{model}} />

View File

@ -1,78 +0,0 @@
<PageHeader as |p|>
<p.levelLeft>
<h1 class="title is-3">
Metrics
</h1>
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-pricing-metrics>
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.metrics.index" @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.metrics.index" data-test-usage-tab={{true}}>
Vault usage
</LinkTo>
</LinkTo>
{{#if model.config.configPath.canRead}}
<LinkTo @route="vault.cluster.metrics.config" @tagName="li" @activeClass="is-active">
<LinkTo @route="vault.cluster.metrics.config" data-test-configuration-tab={{true}}>
Configuration
</LinkTo>
</LinkTo>
{{/if}}
</ul>
</nav>
</div>
{{#if (eq model.config.queriesAvailable false)}}
{{#if (eq model.config.enabled "On")}}
<EmptyState @title="No data is being received" @message="We haven't 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. It will include the current namespace and all its children." />
{{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><LinkTo @route="vault.cluster.metrics.config">Go to configuration</LinkTo></p>
</EmptyState>
{{/if}}
{{else}}
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
{{#if (eq model.config.enabled 'Off')}}
<AlertBanner
data-test-tracking-disabled
@type="warning"
@title="Tracking is disabled"
>
This feature is currently disabled and data is not being collected. <LinkTo @route="vault.cluster.metrics.edit">Edit the configuration</LinkTo> 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. The data below includes the current namespace and all its children.</p>
<PricingMetricsDates
@queryStart={{model.queryStart}}
@queryEnd={{model.queryEnd}}
@resultStart={{model.activity.startTime}}
@resultEnd={{model.activity.endTime}}
@defaultSpan={{or model.config.defaultReportMonths 12}}
@retentionMonths={{model.config.retentionMonths}}
/>
{{#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>
{{/if}}

View File

@ -94,7 +94,7 @@ class BarChartComponent extends Component {
container container
.append('div') .append('div')
.attr('class', 'chart-tooltip') .attr('class', 'chart-tooltip')
.attr('style', 'position: fixed; opacity: 0;') .attr('style', 'position: absolute; opacity: 0;')
.style('color', 'white') .style('color', 'white')
.style('background', `${TOOLTIP_BACKGROUND}`) .style('background', `${TOOLTIP_BACKGROUND}`)
.style('font-size', '.929rem') .style('font-size', '.929rem')
@ -214,11 +214,11 @@ class BarChartComponent extends Component {
select('.chart-tooltip') select('.chart-tooltip')
.style('opacity', 1) .style('opacity', 1)
.style('max-width', '200px') .style('max-width', '200px')
.style('left', `${event.pageX - 90}px`) .style('left', `${event.pageX - 95}px`)
.style('top', `${event.pageY - 90}px`) .style('top', `${event.pageY - 155}px`)
.text( .text(
`${Math.round((chartData.total * 100) / totalCount)}% of total client counts: `${Math.round((chartData.total * 100) / totalCount)}% of total client counts:
${chartData.distinct_entities} unique entities, ${chartData.non_entity_tokens} active tokens. ${chartData.non_entity_tokens} active tokens, ${chartData.distinct_entities} unique entities.
` `
); );
}); });

View File

@ -5,7 +5,7 @@ export default function() {
this.get('sys/internal/counters/activity', function(db) { this.get('sys/internal/counters/activity', function(db) {
let data = {}; let data = {};
const firstRecord = db['metrics/activities'].first(); const firstRecord = db['clients/activities'].first();
if (firstRecord) { if (firstRecord) {
data = firstRecord; data = firstRecord;
} }
@ -18,7 +18,7 @@ export default function() {
this.get('sys/internal/counters/config', function(db) { this.get('sys/internal/counters/config', function(db) {
return { return {
request_id: '00001', request_id: '00001',
data: db['metrics/configs'].first(), data: db['clients/configs'].first(),
}; };
}); });

View File

@ -1,4 +1,4 @@
export default function(server) { export default function(server) {
server.create('metrics/config'); server.create('clients/config');
server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] }); server.create('feature', { feature_flags: ['SOME_FLAG', 'VAULT_CLOUD_ADMIN_NAMESPACE'] });
} }

View File

@ -1,106 +0,0 @@
import { module, test } from 'qunit';
import { visit, currentURL, findAll } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { create } from 'ember-cli-page-object';
import authPage from 'vault/tests/pages/auth';
import logout from 'vault/tests/pages/logout';
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
const consoleComponent = create(consoleClass);
const tokenWithPolicy = async function(name, policy) {
await consoleComponent.runCommands([
`write sys/policies/acl/${name} policy=${btoa(policy)}`,
`write -field=client_token auth/token/create policies=${name}`,
]);
return consoleComponent.lastLogOutput;
};
module('Acceptance | usage metrics', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function() {
return authPage.login();
});
hooks.afterEach(function() {
return logout.visit();
});
test('it shows empty state when disabled and no data available', async function(assert) {
server.create('metrics/config', { enabled: 'disable' });
await visit('/vault/metrics');
assert.equal(currentURL(), '/vault/metrics');
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No data is being received');
});
test('it shows empty state when enabled and no data available', async function(assert) {
server.create('metrics/config', { enabled: 'enable' });
await visit('/vault/metrics');
assert.equal(currentURL(), '/vault/metrics');
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No data is being received');
});
test('it shows empty state when data available but not returned', async function(assert) {
server.create('metrics/config', { queries_available: true });
await visit('/vault/metrics');
assert.equal(currentURL(), '/vault/metrics');
assert.dom('[data-test-pricing-metrics-form]').exists('Pricing metrics date form exists');
assert.dom('[data-test-pricing-result-dates]').doesNotExist('Pricing metric result dates are not shown');
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No data found');
});
test('it shows warning when disabled and data available', async function(assert) {
server.create('metrics/config', { queries_available: true, enabled: 'disable' });
server.create('metrics/activity');
await visit('/vault/metrics');
assert.equal(currentURL(), '/vault/metrics');
assert.dom('[data-test-pricing-metrics-form]').exists('Pricing metrics date form exists');
assert.dom('[data-test-tracking-disabled]').exists('Flash message exists');
assert.dom('[data-test-tracking-disabled] .message-title').hasText('Tracking is disabled');
});
test('it shows data when available from query', async function(assert) {
server.create('metrics/config', { queries_available: true });
server.create('metrics/activity');
await visit('/vault/metrics');
assert.equal(currentURL(), '/vault/metrics');
assert.dom('[data-test-pricing-metrics-form]').exists('Pricing metrics date form exists');
assert.dom('[data-test-configuration-tab]').exists('Metrics config tab exists');
assert.dom('[data-test-tracking-disabled]').doesNotExist('Flash message does not exists');
assert.ok(findAll('.selectable-card').length === 3, 'renders the counts');
});
test('it shows metrics even if config endpoint not allowed', async function(assert) {
server.create('metrics/activity');
const deny_config_policy = `
path "sys/internal/counters/config" {
capabilities = ["deny"]
},
`;
const userToken = await tokenWithPolicy('no-metrics-config', deny_config_policy);
await logout.visit();
await authPage.login(userToken);
await visit('/vault/metrics');
assert.equal(currentURL(), '/vault/metrics');
assert.dom('[data-test-pricing-metrics-form]').exists('Pricing metrics date form exists');
assert.dom('[data-test-configuration-tab]').doesNotExist('Metrics config tab does not exist');
assert.dom('[data-test-tracking-disabled]').doesNotExist('Flash message does not exists');
assert.ok(findAll('.selectable-card').length === 3, 'renders the counts');
});
});

View File

@ -15,7 +15,7 @@ const routerService = Service.extend({
}, },
}); });
module('Integration | Component | pricing-metrics-config', function(hooks) { module('Integration | Component | client count config', function(hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
const createAttr = (name, type, options) => { const createAttr = (name, type, options) => {
@ -50,7 +50,7 @@ module('Integration | Component | pricing-metrics-config', function(hooks) {
}); });
test('it shows the table with the correct rows by default', async function(assert) { test('it shows the table with the correct rows by default', async function(assert) {
await render(hbs`<PricingMetricsConfig @model={{model}} />`); await render(hbs`<Clients::Config @model={{model}} />`);
assert.dom('[data-test-pricing-metrics-config-table]').exists('Pricing metrics config table exists'); assert.dom('[data-test-pricing-metrics-config-table]').exists('Pricing metrics config table exists');
const rows = document.querySelectorAll('.info-table-row'); const rows = document.querySelectorAll('.info-table-row');
@ -72,7 +72,7 @@ module('Integration | Component | pricing-metrics-config', function(hooks) {
test('TODO: it shows the config edit form when mode = edit', async function(assert) { test('TODO: it shows the config edit form when mode = edit', async function(assert) {
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<PricingMetricsConfig @model={{model}} @mode="edit" /> <Clients::Config @model={{model}} @mode="edit" />
`); `);
assert.dom('[data-test-pricing-metrics-config-form]').exists('Pricing metrics config form exists'); assert.dom('[data-test-pricing-metrics-config-form]').exists('Pricing metrics config form exists');
@ -89,7 +89,7 @@ module('Integration | Component | pricing-metrics-config', function(hooks) {
this.set('model', simModel); this.set('model', simModel);
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<PricingMetricsConfig @model={{model}} @mode="edit" /> <Clients::Config @model={{model}} @mode="edit" />
`); `);
await click('[data-test-edit-metrics-config-save]'); await click('[data-test-edit-metrics-config-save]');
@ -110,7 +110,7 @@ module('Integration | Component | pricing-metrics-config', function(hooks) {
this.set('model', simModel); this.set('model', simModel);
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<PricingMetricsConfig @model={{model}} @mode="edit" /> <Clients::Config @model={{model}} @mode="edit" />
`); `);
await click('[data-test-edit-metrics-config-save]'); await click('[data-test-edit-metrics-config-save]');
@ -131,7 +131,7 @@ module('Integration | Component | pricing-metrics-config', function(hooks) {
this.set('model', simModel); this.set('model', simModel);
await render(hbs` await render(hbs`
<div id="modal-wormhole"></div> <div id="modal-wormhole"></div>
<PricingMetricsConfig @model={{model}} @mode="edit" /> <Clients::Config @model={{model}} @mode="edit" />
`); `);
await click('[data-test-edit-metrics-config-save]'); await click('[data-test-edit-metrics-config-save]');

View File

@ -0,0 +1,55 @@
import { module, test } from 'qunit';
import EmberObject from '@ember/object';
import { render } from '@ember/test-helpers';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | client count current', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
let model = EmberObject.create({
config: {},
activity: {},
});
this.model = model;
this.tab = 'current';
});
test('it shows empty state when disabled and no data available', async function(assert) {
Object.assign(this.model.config, { enabled: 'Off', queriesAvailable: false });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('Data tracking is disabled');
});
test('it shows empty state when enabled and no data available', async function(assert) {
Object.assign(this.model.config, { enabled: 'On', queriesAvailable: false });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No monthly history');
});
test('it shows empty state when data available but not returned', async function(assert) {
Object.assign(this.model.config, { queriesAvailable: true, enabled: 'On' });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-pricing-metrics-form]').doesNotExist('Date range component should not exists');
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No data received');
});
test('it shows data when available from query', async function(assert) {
Object.assign(this.model.config, { queriesAvailable: true, configPath: { canRead: true } });
Object.assign(this.model.activity, {
clients: 1234,
distinct_entities: 234,
non_entity_tokens: 232,
});
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-pricing-metrics-form]').doesNotExist('Date range component should not exists');
assert.dom('[data-test-tracking-disabled]').doesNotExist('Flash message does not exists');
assert.dom('[data-test-client-count-stats]').exists('Client count data exists');
});
});

View File

@ -0,0 +1,67 @@
import { module, test } from 'qunit';
import EmberObject from '@ember/object';
import { render } from '@ember/test-helpers';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | client count history', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
let model = EmberObject.create({
config: {},
activity: {},
});
this.model = model;
this.tab = 'history';
});
test('it shows empty state when disabled and no data available', async function(assert) {
Object.assign(this.model.config, { enabled: 'Off', queriesAvailable: false });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('Data tracking is disabled');
});
test('it shows empty state when enabled and no data available', async function(assert) {
Object.assign(this.model.config, { enabled: 'On', queriesAvailable: false });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No monthly history');
});
test('it shows empty state when data available but not returned', async function(assert) {
Object.assign(this.model.config, { queriesAvailable: true });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-pricing-metrics-form]').exists('Date range form component exists');
assert.dom('[data-test-pricing-result-dates]').doesNotExist('Date range form result dates are not shown');
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No data received');
});
test('it shows warning when disabled and data available', async function(assert) {
Object.assign(this.model.config, { queriesAvailable: true, enabled: 'Off' });
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-pricing-metrics-form]').exists('Date range form component exists');
assert.dom('[data-test-tracking-disabled]').exists('Flash message exists');
assert.dom('[data-test-tracking-disabled] .message-title').hasText('Tracking is disabled');
});
test('it shows data when available from query', async function(assert) {
Object.assign(this.model.config, { queriesAvailable: true, configPath: { canRead: true } });
Object.assign(this.model.activity, {
total: {
clients: 1234,
distinct_entities: 234,
non_entity_tokens: 232,
},
});
await render(hbs`<Clients::History @tab={{tab}} @model={{model}} />`);
assert.dom('[data-test-pricing-metrics-form]').exists('Date range form component exists');
assert.dom('[data-test-tracking-disabled]').doesNotExist('Flash message does not exists');
assert.dom('[data-test-client-count-stats]').exists('Client count data exists');
});
});

View File

@ -55,34 +55,6 @@ module('Integration | Component | pricing-metrics-dates', function(hooks) {
assert.dom('[data-test-results-date-warning]').doesNotExist('Does not show result states warning'); 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');
});
test('it shows appropriate errors on input form', async function(assert) { test('it shows appropriate errors on input form', async function(assert) {
const lastAvailable = endOfMonth(subMonths(startOfToday(), 1)); const lastAvailable = endOfMonth(subMonths(startOfToday(), 1));
const firstAvailable = subMonths(lastAvailable, 12); const firstAvailable = subMonths(lastAvailable, 12);