UI/Client counts view if no license (#13964)

* adds date picker if no license start date found

* handle permissions denied for license endpoint

* handle permissions errors if no license start date

* change empty state copy for OSS

* fix tests and empty state view

* update nav links

* remove ternary

Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>

* simplify hbs boolean

Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>

* organize history file

* organize current file

* rerun tests

* fix conditional to show attribution chart

* match main

Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>
This commit is contained in:
claire bontempo
2022-02-10 12:51:50 -08:00
committed by GitHub
parent 282ab01e27
commit 171b4f46fd
13 changed files with 220 additions and 110 deletions

View File

@@ -4,32 +4,25 @@ import { formatRFC3339 } from 'date-fns';
export default Application.extend({
formatTimeParams(query) {
let { start_time, end_time } = query;
// do not query without start_time. Otherwise returns last year data, which is not reflective of billing data.
if (start_time) {
// check if it's an array, if it is, it's coming from an action like selecting a new startTime or new EndTime
if (Array.isArray(start_time)) {
let startYear = Number(start_time[0]);
let startMonth = Number(start_time[1]);
start_time = formatRFC3339(new Date(startYear, startMonth));
// check if it's an array, if it is, it's coming from an action like selecting a new startTime or new EndTime
if (Array.isArray(start_time)) {
let startYear = Number(start_time[0]);
let startMonth = Number(start_time[1]);
start_time = formatRFC3339(new Date(startYear, startMonth));
}
if (end_time) {
if (Array.isArray(end_time)) {
let endYear = Number(end_time[0]);
let endMonth = Number(end_time[1]);
end_time = formatRFC3339(new Date(endYear, endMonth));
}
if (end_time) {
if (Array.isArray(end_time)) {
let endYear = Number(end_time[0]);
let endMonth = Number(end_time[1]);
end_time = formatRFC3339(new Date(endYear, endMonth));
}
return { start_time, end_time };
} else {
return { start_time };
}
return { start_time, end_time };
} else {
// did not have a start time, return null through to component.
return null;
return { start_time };
}
},
// ARG TODO current Month tab is hitting this endpoint. Need to amend so only hit on Monthly history (large payload)
// query comes in as either: {start_time: '2021-03-17T00:00:00Z'} or
// {start_time: Array(2), end_time: Array(2)}
// end_time: (2) ['2022', 0]
@@ -44,9 +37,6 @@ export default Application.extend({
response.id = response.request_id || 'no-data';
return response;
});
} else {
// did not have a start time, return null through to component.
return null;
}
},
});

View File

@@ -7,18 +7,28 @@ export default class Current extends Component {
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
@tracked firstUpgradeVersion = this.args.model.versionHistory[0].id || null; // return 1.9.0 or earliest upgrade post 1.9.0
@tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp
@tracked selectedNamespace = null;
@tracked namespaceArray = this.byNamespaceCurrent.map((namespace) => {
return { name: namespace['label'], id: namespace['label'] };
});
@tracked firstUpgradeVersion = this.args.model.versionHistory[0].id || null; // return 1.9.0 or earliest upgrade post 1.9.0
@tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp
// API client count data by namespace for current/partial month
// Response client count data by namespace for current/partial month
get byNamespaceCurrent() {
return this.args.model.monthly?.byNamespace || [];
}
get isGatheringData() {
// return true if tracking IS enabled but no data collected yet
return this.args.model.config?.enabled === 'On' && this.byNamespaceCurrent.length === 0;
}
get hasAttributionData() {
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0;
}
get countsIncludeOlderData() {
let firstUpgrade = this.args.model.versionHistory[0];
if (!firstUpgrade) {

View File

@@ -3,9 +3,10 @@ import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { isSameMonth, isAfter } from 'date-fns';
export default class History extends Component {
// TODO CMB alphabetize and delete unused vars (particularly @tracked)
@service store;
@service version;
arrayOfMonths = [
'January',
'February',
@@ -26,7 +27,7 @@ export default class History extends Component {
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
// needed for startTime modal picker
// FOR START DATE EDIT & MODAL //
months = Array.from({ length: 12 }, (item, i) => {
return new Date(0, i).toLocaleString('en-US', { month: 'long' });
});
@@ -34,33 +35,43 @@ export default class History extends Component {
return new Date().getFullYear() - i;
});
@service store;
@tracked queriedActivityResponse = null;
@tracked barChartSelection = false;
@tracked isEditStartMonthOpen = false;
@tracked responseRangeDiffMessage = null;
@tracked startTimeRequested = null;
@tracked startTimeFromResponse = this.args.model.startTimeFromLicense; // ex: ['2021', 3] is April 2021 (0 indexed)
@tracked endTimeFromResponse = this.args.model.endTimeFromResponse;
@tracked startMonth = null;
@tracked startYear = null;
// FOR HISTORY COMPONENT //
// RESPONSE
@tracked endTimeFromResponse = this.args.model.endTimeFromResponse;
@tracked startTimeFromResponse = this.args.model.startTimeFromLicense; // ex: ['2021', 3] is April 2021 (0 indexed)
@tracked startTimeRequested = null;
@tracked queriedActivityResponse = null;
// VERSION/UPGRADE INFO
@tracked firstUpgradeVersion = this.args.model.versionHistory[0].id || null; // return 1.9.0 or earliest upgrade post 1.9.0
@tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp
// SEARCH SELECT
@tracked selectedNamespace = null;
@tracked noActivityDate = '';
@tracked namespaceArray = this.getActivityResponse.byNamespace.map((namespace) => {
return { name: namespace['label'], id: namespace['label'] };
});
@tracked firstUpgradeVersion = this.args.model.versionHistory[0].id || null; // return 1.9.0 or earliest upgrade post 1.9.0
@tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp
// TEMPLATE MESSAGING
@tracked noActivityDate = '';
@tracked responseRangeDiffMessage = null;
// on init API response uses license start_date, getter updates when user queries dates
get getActivityResponse() {
return this.queriedActivityResponse || this.args.model.activity;
}
get hasAttributionData() {
return this.totalUsageCounts.clients !== 0 && this.totalClientsData.length !== 0;
}
get startTimeDisplay() {
if (!this.startTimeFromResponse) {
// otherwise will return date of new Date(null)
return null;
}
let month = this.startTimeFromResponse[1];
@@ -70,7 +81,6 @@ export default class History extends Component {
get endTimeDisplay() {
if (!this.endTimeFromResponse) {
// otherwise will return date of new Date(null)
return null;
}
let month = this.endTimeFromResponse[1];
@@ -120,7 +130,6 @@ export default class History extends Component {
return isAfter(versionDate, startTimeFromResponseAsDateObject) ? versionDate : false;
}
// ACTIONS
@action
async handleClientActivityQuery(month, year, dateType) {
if (dateType === 'cancel') {
@@ -134,7 +143,7 @@ export default class History extends Component {
// clicked "Edit" Billing start month in Dashboard which opens a modal.
if (dateType === 'startTime') {
let monthIndex = this.arrayOfMonths.indexOf(month);
this.startTimeRequested = [year.toString(), monthIndex]; // ['2021', 0] (e.g. January 2021) // TODO CHANGE TO ARRAY
this.startTimeRequested = [year.toString(), monthIndex]; // ['2021', 0] (e.g. January 2021)
this.endTimeRequested = null;
}
// clicked "Custom End Month" from the calendar-widget
@@ -173,7 +182,7 @@ export default class History extends Component {
}
this.queriedActivityResponse = response;
} catch (e) {
// ARG TODO handle error
return e;
}
}
@@ -188,6 +197,7 @@ export default class History extends Component {
this.selectedNamespace = value;
}
// FOR START DATE MODAL
@action
selectStartMonth(month) {
this.startMonth = month;
@@ -198,7 +208,7 @@ export default class History extends Component {
this.startYear = year;
}
// HELPERS
// HELPERS //
filterByNamespace(namespace) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
}

View File

@@ -0,0 +1,37 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
/**
* @module DateDropdown
* DateDropdown components are used to display a dropdown of months and years to handle date selection
*
* @example
* ```js
* <DateDropdown @handleDateSelection={this.actionFromParent} @name={{"startTime"}}/>
* ```
* @param {function} handleDateSelection - is the action from the parent that the date picker triggers
* @param {string} [name] - optional argument passed from date dropdown to parent function
*/
export default class DateDropdown extends Component {
@tracked startMonth = null;
@tracked startYear = null;
months = Array.from({ length: 12 }, (item, i) => {
return new Date(0, i).toLocaleString('en-US', { month: 'long' });
});
years = Array.from({ length: 5 }, (item, i) => {
return new Date().getFullYear() - i;
});
@action
selectStartMonth(month) {
this.startMonth = month;
}
@action
selectStartYear(year) {
this.startYear = year;
}
}

View File

@@ -5,19 +5,24 @@ import { action } from '@ember/object';
export default class HistoryRoute extends Route {
async getActivity(start_time) {
try {
return this.store.queryRecord('clients/activity', { start_time });
// on init ONLY make network request if we have a start time from the license
// otherwise user needs to manually input
return start_time
? await this.store.queryRecord('clients/activity', { start_time })
: { endTime: null };
} catch (e) {
// ARG TODO handle
return e;
}
}
async getLicense() {
async getLicenseStartTime() {
try {
return this.store.queryRecord('license', {});
let license = await this.store.queryRecord('license', {});
// if license.startTime is 'undefined' return 'null' for consistency
return license.startTime || null;
} catch (e) {
// ARG TODO handle
return e;
// if error due to permission denied, return null so user can input date manually
return null;
}
}
@@ -48,18 +53,18 @@ export default class HistoryRoute extends Route {
async model() {
let config = await this.store.queryRecord('clients/config', {}).catch((e) => {
console.debug(e);
// swallowing error so activity can show if no config permissions
console.debug(e);
return {};
});
let license = await this.getLicense();
let activity = await this.getActivity(license.startTime);
let licenseStart = await this.getLicenseStartTime();
let activity = await this.getActivity(licenseStart);
return RSVP.hash({
config,
activity,
startTimeFromLicense: this.parseRFC3339(license.startTime),
endTimeFromResponse: activity ? this.parseRFC3339(activity.endTime) : null,
startTimeFromLicense: this.parseRFC3339(licenseStart),
endTimeFromResponse: this.parseRFC3339(activity?.endTime),
versionHistory: this.getVersionHistory(),
});
}

View File

@@ -13,46 +13,37 @@
</div>
</div>
{{#if (eq @totalUsageCounts.clients 0)}}
<div class="chart-empty-state">
<EmptyState
@title="No data received"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
</div>
{{else}}
<div class="chart-container-wide">
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}}
@totalUsageCounts={{@totalUsageCounts}}
/>
</div>
<div class="chart-container-wide">
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}}
@totalUsageCounts={{@totalUsageCounts}}
/>
</div>
<div class="chart-subTitle">
<p class="chart-subtext">{{this.chartText.totalCopy}}</p>
</div>
<div class="chart-subTitle">
<p class="chart-subtext">{{this.chartText.totalCopy}}</p>
</div>
<div class="data-details-top">
<h3 class="data-details">Top {{this.attributionBreakdown}}</h3>
<p class="data-details">{{this.topClientCounts.label}}</p>
</div>
<div class="data-details-top">
<h3 class="data-details">Top {{this.attributionBreakdown}}</h3>
<p class="data-details">{{this.topClientCounts.label}}</p>
</div>
<div class="data-details-bottom">
<h3 class="data-details">Clients in {{this.attributionBreakdown}}</h3>
<p class="data-details">{{format-number this.topClientCounts.clients}}</p>
</div>
<div class="data-details-bottom">
<h3 class="data-details">Clients in {{this.attributionBreakdown}}</h3>
<p class="data-details">{{format-number this.topClientCounts.clients}}</p>
</div>
<div class="timestamp">
Updated
{{date-format @timestamp "MMM dd yyyy, h:mm:ss aaa"}}
</div>
<div class="timestamp">
Updated
{{date-format @timestamp "MMM dd yyyy, h:mm:ss aaa"}}
</div>
<div class="legend-center">
<span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span>
<span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span>
</div>
{{/if}}
<div class="legend-center">
<span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span>
<span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span>
</div>
</div>
{{! MODAL FOR CSV DOWNLOAD }}

View File

@@ -13,6 +13,11 @@
</LinkTo>
{{/if}}
</EmptyState>
{{else if this.isGatheringData}}
<EmptyState
@title="No data received"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
{{else}}
<div class="is-subtitle-gray has-bottom-margin-m">
FILTERS
@@ -48,7 +53,7 @@
@title={{date-format this.responseTimestamp "MMMM"}}
@totalUsageCounts={{this.totalUsageCounts}}
/>
{{#if this.totalClientsData}}
{{#if this.hasAttributionData}}
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientsData={{this.totalClientsData}}
@@ -59,8 +64,6 @@
@timestamp={{this.responseTimestamp}}
/>
{{/if}}
{{else}}
<EmptyState @title={{concat "No partial history"}} @message="There is no data in the current month yet." />
{{/if}}
{{/if}}
{{/if}}

View File

@@ -8,10 +8,14 @@
Billing start month
</h1>
<div data-test-start-date-editor class="is-flex-align-baseline">
<p class="is-size-6">{{this.startTimeDisplay}}</p>
<button type="button" class="button is-link" {{on "click" (fn (mut this.isEditStartMonthOpen) true)}}>
Edit
</button>
{{#if this.startTimeDisplay}}
<p class="is-size-6">{{this.startTimeDisplay}}</p>
<button type="button" class="button is-link" {{on "click" (fn (mut this.isEditStartMonthOpen) true)}}>
Edit
</button>
{{else}}
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} />
{{/if}}
</div>
<p class="is-8 has-text-grey has-bottom-margin-xl">
This date comes from your license, and defines when client counting starts. Without this starting point, the data shown
@@ -106,7 +110,7 @@
{{else}}
{{#if this.totalUsageCounts}}
<Clients::UsageStats @title="Total usage" @totalUsageCounts={{this.totalUsageCounts}} />
{{#if this.totalClientsData}}
{{#if this.hasAttributionData}}
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientsData={{this.totalClientsData}}
@@ -123,10 +127,17 @@
{{/if}}
{{/if}}
{{else}}
<EmptyState
@title="No billing start date found"
@message="In order to get the most from this data, please enter your billing period start month. This will ensure that the resulting data is accurate."
/>
{{#if this.version.isEnterprise}}
<EmptyState
@title="No billing start date found"
@message="In order to get the most from this data, please enter your billing period start month. This will ensure that the resulting data is accurate."
/>
{{else}}
<EmptyState
@title="No start date found"
@message="In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month."
/>
{{/if}}
{{/if}}
{{/if}}

View File

@@ -152,7 +152,7 @@
<ul class="menu-list">
<li class="action">
{{! template-lint-disable no-unknown-arguments-for-builtin-components }}
<LinkTo @route="vault.cluster.clients" @query={{hash tab="current"}} @invokeAction={{@onLinkClick}}>
<LinkTo @route="vault.cluster.clients.index" @invokeAction={{@onLinkClick}}>
<div class="level is-mobile">
<span class="level-left">Client count</span>
<Chevron class="has-text-grey-light level-right" />

View File

@@ -0,0 +1,54 @@
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
data-test-popup-menu-trigger="true"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.startMonth "Month"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content class="popup-menu-content is-wide">
<nav class="box menu scroll">
<ul class="menu-list">
{{#each this.months as |month|}}
<button
type="button"
class="link"
{{on "click" (queue (fn this.selectStartMonth month) (action D.actions.close))}}
>
{{month}}
</button>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
data-test-popup-menu-trigger="true"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.startYear "Year"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content class="popup-menu-content is-wide">
<nav class="box menu">
<ul class="menu-list">
{{#each this.years as |year|}}
<button type="button" class="link" {{on "click" (queue (fn this.selectStartYear year) (action D.actions.close))}}>
{{year}}
</button>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
<button
type="button"
class="button is-primary"
disabled={{if (and this.startMonth this.startYear) false true}}
{{on "click" (fn @handleDateSelection this.startMonth this.startYear @name)}}
>
Save
</button>

View File

@@ -76,9 +76,8 @@
{{! template-lint-configure no-curly-component-invocation "warn" }}
{{! template-lint-configure no-link-to-positional-params "warn" }}
{{#link-to
"vault.cluster.clients"
(query-params tab="history")
current-when="vault.cluster.clients"
"vault.cluster.clients.history"
current-when="vault.cluster.clients.history"
data-test-navbar-item="metrics"
}}
Client count

View File

@@ -1,3 +1,2 @@
<Clients::Dashboard @model={{@model}} />
<Clients::History @model={{@model}} @isLoading={{this.currentlyLoading}} />

View File

@@ -32,12 +32,13 @@ module('Integration | Component | client count current', function (hooks) {
<div id="modal-wormhole"></div>
<Clients::Current @model={{this.model}} />`);
assert.dom('[data-test-component="empty-state"]').exists('Empty state exists');
assert.dom('[data-test-empty-state-title]').hasText('No partial history');
assert.dom('[data-test-empty-state-title]').hasText('No data received');
});
test('it shows zeroed data when enabled but no counts', async function (assert) {
Object.assign(this.model.config, { queriesAvailable: true, enabled: 'On' });
Object.assign(this.model.monthly, {
byNamespace: [{ label: 'root', clients: 0, entity_clients: 0, non_entity_clients: 0 }],
total: { clients: 0, entity_clients: 0, non_entity_clients: 0 },
});
await render(hbs`