UI: combine current + history client count tabs into one dashboard (#17575)

* WIP/initial routing-ish

* refactor date dropdown to reuse in modal and allowe current month selection

* swap linter disable line

* refactor date-dropdown to return object

* refactor calendar widget, add tests

* change calendar start and end args to getters

* refactor dashboard to use date objects instead of array of year, month

* remove dashboard files for easier to follow git diff

* comment out dashboard tab until route name updated

* delete current tab and route

* fix undefined banner time

* cleanup version history serializer and upgrade data

* first pass of updating tests

* add changelog

* update client count util test

* validate end time is after start time

* update comment

* add current month to calendar widget

* add comments for code changes to make following API update

* Removed a modified file from pull request

* address comments/cleanup

* update variables to const

* update test const

* rename history -> dashboard, fix tests

* fix timestamps for attribution chart

* update release note

* refactor using backend start and end time params

* add test for adapter formatting time params

* fix tests

* cleanup adapter comment and query params

* change back history file name for diff

* rename file using cli

* revert filenames

* rename files via git cli

* revert route file name

* last cli rename

* refactor mirage

* hold off on running total changes

* update params in test

* refactor to remove conditional assertions

* finish tests

* fix firefox tooltip

* remove current-when

* refactor version history

* add timezone/UTC note

* final cleanup!!!!

* fix test

* fix client count date tests

* fix date-dropdown test

* clear datedropdown completely

* update date selectors to accommodate new year (#18586)

* Revert "hold off on running total changes"

This reverts commit 8dc79a626d549df83bc47e290392a556c670f98f.

* remove assumed 0 values

* update average helper to only calculate for array of objects

* remove passing in bar chart data, map in running totals component instead

* cleanup usage stat component

* clear  ss filters for new queries

* update csv export, add explanation to modal

* update test copy

* consistently return null if no upgrade during activity (instead of empty array)

* update description, add clarifying comments

* update tes

* add more clarifying comments

* fix historic single month chart

* remove old test tag

* Update ui/app/components/clients/dashboard.js
This commit is contained in:
claire bontempo 2023-01-26 18:21:12 -08:00 committed by GitHub
parent 6d053a4c00
commit 4a9610f382
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 2096 additions and 11323 deletions

3
changelog/17575.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: use the combined activity log (partial + historic) API for client count dashboard and remove use of monthly endpoint
```

View File

@ -1,38 +1,22 @@
import Application from '../application';
import { formatRFC3339 } from 'date-fns';
import ApplicationAdapter from '../application';
import { getUnixTime } from 'date-fns';
export default Application.extend({
// Since backend converts the timezone to UTC, sending the first (1) as start or end date can cause the month to change.
// To mitigate this impact of timezone conversion, hard coding the dates to avoid month change.
formatTimeParams(query) {
let { start_time, end_time } = query;
// 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)) {
const startYear = Number(start_time[0]);
const startMonth = Number(start_time[1]);
start_time = formatRFC3339(new Date(startYear, startMonth, 10));
}
if (end_time) {
if (Array.isArray(end_time)) {
const endYear = Number(end_time[0]);
const endMonth = Number(end_time[1]);
end_time = formatRFC3339(new Date(endYear, endMonth, 20));
}
export default class ActivityAdapter extends ApplicationAdapter {
// javascript localizes new Date() objects but all activity log data is stored in UTC
// create date object from user's input using Date.UTC() then send to backend as unix
// time params from the backend are formatted as a zulu timestamp
formatQueryParams(queryParams) {
let { start_time, end_time } = queryParams;
start_time = start_time.timestamp || getUnixTime(Date.UTC(start_time.year, start_time.monthIdx, 1));
// day=0 for Date.UTC() returns the last day of the month before
// increase monthIdx by one to get last day of queried month
end_time = end_time.timestamp || getUnixTime(Date.UTC(end_time.year, end_time.monthIdx + 1, 0));
return { start_time, end_time };
}
return { start_time, end_time };
} else {
return { start_time };
}
},
// 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]
// start_time: (2) ['2021', 2]
queryRecord(store, type, query) {
const url = `${this.buildURL()}/internal/counters/activity`;
// check if start and/or end times are in RFC3395 format, if not convert with timezone UTC/zulu.
const queryParams = this.formatTimeParams(query);
const queryParams = this.formatQueryParams(query);
if (queryParams) {
return this.ajax(url, 'GET', { data: queryParams }).then((resp) => {
const response = resp || {};
@ -40,5 +24,5 @@ export default Application.extend({
return response;
});
}
},
});
}
}

View File

@ -1,13 +0,0 @@
import ApplicationAdapter from '../application';
export default class MonthlyAdapter extends ApplicationAdapter {
queryRecord() {
const url = `${this.buildURL()}/internal/counters/activity/monthly`;
// Query has startTime defined. The API will return the endTime if none is provided.
return this.ajax(url, 'GET').then((resp) => {
const response = resp || {};
response.id = response.request_id || 'no-data';
return response;
});
}
}

View File

@ -1,6 +1,6 @@
import Application from '../application';
import ApplicationAdapter from '../application';
export default Application.extend({
export default class VersionHistoryAdapter extends ApplicationAdapter {
findAll() {
return this.ajax(this.buildURL() + '/version-history', 'GET', {
data: {
@ -9,5 +9,5 @@ export default Application.extend({
}).then((resp) => {
return resp;
});
},
});
}
}

View File

@ -1,141 +1,113 @@
import Component from '@glimmer/component';
import layout from '../templates/components/calendar-widget';
import { setComponentTemplate } from '@ember/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { ARRAY_OF_MONTHS, parseAPITimestamp } from 'core/utils/date-formatters';
import { addYears, isSameYear, subYears } from 'date-fns';
/**
* @module CalendarWidget
* CalendarWidget components are used in the client counts metrics. It helps users understand the ranges they can select.
* CalendarWidget component is used in the client counts dashboard to select a month/year to query the /activity endpoint.
* The component returns an object with selected date info, example: { dateType: 'endDate', monthIdx: 0, monthName: 'January', year: 2022 }
*
* @example
* ```js
* <CalendarWidget
* @param {array} arrayOfMonths - An array of all the months that the calendar widget iterates through.
* @param {string} endTimeDisplay - The formatted display value of the endTime. Ex: January 2022.
* @param {array} endTimeFromResponse - The value returned on the counters/activity endpoint, which shows the true endTime not the selected one, which can be different. Ex: ['2022', 0]
* @param {function} handleClientActivityQuery - a function passed from parent. This component sends the month and year to the parent via this method which then calculates the new data.
* @param {function} handleCurrentBillingPeriod - a function passed from parent. This component makes the parent aware that the user selected Current billing period and it handles resetting the data.
* @param {string} startTimeDisplay - The formatted display value of the endTime. Ex: January 2022. This component is only responsible for modifying the endTime which is sends to the parent to make the network request.
* />
* <CalendarWidget @startTimestamp={{this.startTime}} @endTimestamp={{this.endTime}} @selectMonth={{this.handleSelection}} />
*
* @param {string} startTimestamp - ISO timestamp string of the calendar widget's start time, displays in dropdown trigger
* @param {string} endTimestamp - ISO timestamp string for the calendar widget's end time, displays in dropdown trigger
* @param {function} selectMonth - callback function from parent - fires when selecting a month or clicking "Current billing period"
* />
* ```
*/
class CalendarWidget extends Component {
export default class CalendarWidget extends Component {
currentDate = new Date();
currentYear = this.currentDate.getFullYear(); // integer
currentMonth = parseInt(this.currentDate.getMonth()); // integer and zero index
@tracked allMonthsNodeList = [];
@tracked displayYear = this.currentYear; // init to currentYear and then changes as a user clicks on the chevrons
@tracked calendarDisplayDate = this.currentDate; // init to current date, updates when user clicks on calendar chevrons
@tracked showCalendar = false;
@tracked tooltipTarget = null;
@tracked tooltipText = null;
get selectedMonthId() {
if (!this.args.endTimeFromResponse) return '';
const [year, monthIndex] = this.args.endTimeFromResponse;
return `${monthIndex}-${year}`;
// both date getters return a date object
get startDate() {
return parseAPITimestamp(this.args.startTimestamp);
}
get endDate() {
return parseAPITimestamp(this.args.endTimestamp);
}
get displayYear() {
return this.calendarDisplayDate.getFullYear();
}
get disableFutureYear() {
return this.displayYear === this.currentYear;
return isSameYear(this.calendarDisplayDate, this.currentDate);
}
get disablePastYear() {
const startYear = parseInt(this.args.startTimeDisplay.split(' ')[1]);
return this.displayYear === startYear; // if on startYear then don't let them click back to the year prior
// calendar widget should only go as far back as the passed in start time
return isSameYear(this.calendarDisplayDate, this.startDate);
}
get widgetMonths() {
const displayYear = this.displayYear;
const currentYear = this.currentYear;
const currentMonthIdx = this.currentMonth;
const [startMonth, startYear] = this.args.startTimeDisplay.split(' ');
const startMonthIdx = this.args.arrayOfMonths.indexOf(startMonth);
return this.args.arrayOfMonths.map((month, idx) => {
const monthId = `${idx}-${displayYear}`;
const startYear = this.startDate.getFullYear();
const startMonthIdx = this.startDate.getMonth();
return ARRAY_OF_MONTHS.map((month, index) => {
let readonly = false;
// if widget is showing billing start year, disable if month is before start month
if (parseInt(startYear) === displayYear && idx < startMonthIdx) {
// if widget is showing same year as @startTimestamp year, disable if month is before start month
if (startYear === this.displayYear && index < startMonthIdx) {
readonly = true;
}
// if widget showing current year, disable if month is current or later
if (displayYear === currentYear && idx >= currentMonthIdx) {
// if widget showing current year, disable if month is later than current month
if (this.displayYear === this.currentDate.getFullYear() && index > this.currentDate.getMonth()) {
readonly = true;
}
return {
id: monthId,
month,
index,
year: this.displayYear,
name: month,
readonly,
current: monthId === `${currentMonthIdx}-${currentYear}`,
};
});
}
// HELPER FUNCTIONS (alphabetically) //
addClass(element, classString) {
element.classList.add(classString);
}
removeClass(element, classString) {
element.classList.remove(classString);
}
resetDisplayYear() {
let setYear = this.currentYear;
if (this.args.endTimeDisplay) {
try {
const year = this.args.endTimeDisplay.split(' ')[1];
setYear = parseInt(year);
} catch (e) {
console.debug('Error resetting display year', e); // eslint-disable-line
}
}
this.displayYear = setYear;
}
// ACTIONS (alphabetically) //
@action
addTooltip() {
if (this.disablePastYear) {
const previousYear = Number(this.displayYear) - 1;
this.tooltipText = `${previousYear} is unavailable because it is before your billing start month. Change your billing start month to a date in ${previousYear} to see data for this year.`; // set tooltip text
const previousYear = this.displayYear - 1;
this.tooltipText = `${previousYear} is unavailable because it is before your start date. Change your start month to a date in ${previousYear} to see data for this year.`;
this.tooltipTarget = '#previous-year';
}
}
@action
addYear() {
this.displayYear = this.displayYear + 1;
}
@action removeTooltip() {
removeTooltip() {
this.tooltipTarget = null;
}
@action
selectCurrentBillingPeriod(D) {
this.args.handleCurrentBillingPeriod(); // resets the billing startTime and endTime to what it is on init via the parent.
this.showCalendar = false;
D.actions.close(); // close the dropdown.
}
@action
selectEndMonth(monthId, D) {
const [monthIdx, year] = monthId.split('-');
this.toggleShowCalendar();
this.args.handleClientActivityQuery(parseInt(monthIdx), parseInt(year), 'endTime');
D.actions.close(); // close the dropdown.
addYear() {
this.calendarDisplayDate = addYears(this.calendarDisplayDate, 1);
}
@action
subYear() {
this.displayYear = this.displayYear - 1;
this.calendarDisplayDate = subYears(this.calendarDisplayDate, 1);
}
@action
toggleShowCalendar() {
this.showCalendar = !this.showCalendar;
this.resetDisplayYear();
this.calendarDisplayDate = this.endDate;
}
@action
handleDateShortcut(dropdown, { target }) {
this.args.selectMonth({ dateType: target.name }); // send clicked shortcut to parent callback
this.showCalendar = false;
dropdown.close();
}
@action
selectMonth(month, dropdown) {
const { index, year, name } = month;
this.toggleShowCalendar();
this.args.selectMonth({ monthIdx: index, monthName: name, year, dateType: 'endDate' });
dropdown.close();
}
}
export default setComponentTemplate(layout, CalendarWidget);

View File

@ -2,6 +2,9 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { format, isSameMonth } from 'date-fns';
/**
* @module Attribution
* Attribution components display the top 10 total client counts for namespaces or auth methods (mounts) during a billing period.
@ -16,10 +19,11 @@ import { inject as service } from '@ember/service';
* @totalClientAttribution={{this.totalClientAttribution}}
* @newClientAttribution={{this.newClientAttribution}}
* @selectedNamespace={{this.selectedNamespace}}
* @startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}}
* @isDateRange={{this.isDateRange}}
* @isCurrentMonth={{false}}
* @timestamp={{this.responseTimestamp}}
* @startTimestamp={{this.startTime}}
* @endTimestamp={{this.endTime}}
* @isHistoricalMonth={{false}}
* @responseTimestamp={{this.responseTimestamp}}
* @upgradeExplanation="We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data."
* />
* ```
* @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked
@ -28,23 +32,31 @@ import { inject as service } from '@ember/service';
* @param {array} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients
* @param {array} newClientAttribution - array of objects containing a label and breakdown of client counts for new clients
* @param {string} selectedNamespace - namespace selected from filter bar
* @param {string} startTimeDisplay - string that displays as start date for CSV modal
* @param {string} endTimeDisplay - string that displays as end date for CSV modal
* @param {boolean} isDateRange - getter calculated in parent to relay if dataset is a date range or single month and display text accordingly
* @param {boolean} isCurrentMonth - boolean to determine if rendered in current month tab or not
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
* @param {string} startTimestamp - timestamp string from activity response to render start date for CSV modal and whether copy reads 'month' or 'date range'
* @param {string} endTimestamp - timestamp string from activity response to render end date for CSV modal and whether copy reads 'month' or 'date range'
* @param {string} responseTimestamp - ISO timestamp created in serializer to timestamp the response, renders in bottom left corner below attribution chart
* @param {boolean} isHistoricalMonth - when true data is from a single, historical month so side-by-side charts should display for attribution data
* @param {boolean} upgradeExplanation - if data contains an upgrade, explanation is generated by the parent to be rendered in the export modal
*/
export default class Attribution extends Component {
@tracked showCSVDownloadModal = false;
@service download;
get hasCsvData() {
return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
get formattedStartDate() {
if (!this.args.startTimestamp) return null;
return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy');
}
get formattedEndDate() {
if (!this.args.startTimestamp && !this.args.endTimestamp) return null;
// displays on CSV export modal, no need to display duplicate months and years
const startDateObject = parseAPITimestamp(this.args.startTimestamp);
const endDateObject = parseAPITimestamp(this.args.endTimestamp);
return isSameMonth(startDateObject, endDateObject) ? null : format(endDateObject, 'MMMM yyyy');
}
get isDateRange() {
return this.args.isDateRange;
get hasCsvData() {
return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false;
}
get isSingleNamespace() {
@ -75,7 +87,7 @@ export default class Attribution extends Component {
}
get chartText() {
const dateText = this.isDateRange ? 'date range' : 'month';
const dateText = this.formattedEndDate ? 'date range' : 'month';
switch (this.isSingleNamespace) {
case true:
return {
@ -118,7 +130,7 @@ export default class Attribution extends Component {
const otherColumns = newColumns ? [...totalColumns, ...newColumns] : [...totalColumns];
return [
`${typeof namespaceColumn === 'string' ? namespaceColumn : namespaceColumn.label}`,
`${mountColumn ? mountColumn.label : ''}`,
`${mountColumn ? mountColumn.label : '*'}`,
...otherColumns,
];
}
@ -127,9 +139,14 @@ export default class Attribution extends Component {
const totalAttribution = this.args.totalClientAttribution;
const newAttribution = this.barChartNewClients ? this.args.newClientAttribution : null;
const csvData = [];
// added to clarify that the row of namespace totals without an auth method (blank) are not additional clients
// but indicate the total clients for that ns, including its auth methods
const descriptionOfBlanks = this.isSingleNamespace
? ''
: `\n *namespace totals, inclusive of auth method clients`;
const csvHeader = [
'Namespace path',
'Authentication method',
`"Authentication method ${descriptionOfBlanks}"`,
'Total clients',
'Entity clients',
'Non-entity clients',
@ -173,15 +190,14 @@ export default class Attribution extends Component {
return csvData.map((d) => d.join()).join('\n');
}
get getCsvFileName() {
const endRange = this.isDateRange ? `-${this.args.endTimeDisplay}` : '';
const csvDateRange = this.args.startTimeDisplay + endRange;
get formattedCsvFileName() {
const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : '';
const csvDateRange = this.formattedStartDate + endRange;
return this.isSingleNamespace
? `clients_by_auth_method_${csvDateRange}`
: `clients_by_namespace_${csvDateRange}`;
}
// ACTIONS
@action
exportChartData(filename) {
const contents = this.generateCsvData();

View File

@ -1,152 +0,0 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { isAfter, startOfMonth } from 'date-fns';
import { action } from '@ember/object';
export default class Current extends Component {
chartLegend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
@tracked selectedNamespace = null;
@tracked namespaceArray = this.byNamespace.map((namespace) => {
return { name: namespace['label'], id: namespace['label'] };
});
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
get upgradeVersionHistory() {
const versionHistory = this.args.model.versionHistory;
if (!versionHistory || versionHistory.length === 0) {
return null;
}
// get upgrade data for initial upgrade to 1.9 and/or 1.10
const relevantUpgrades = [];
const importantUpgrades = ['1.9', '1.10'];
importantUpgrades.forEach((version) => {
const findUpgrade = versionHistory.find((versionData) => versionData.id.match(version));
if (findUpgrade) relevantUpgrades.push(findUpgrade);
});
// array of upgrade data objects for noteworthy upgrades
return relevantUpgrades;
}
// Response client count data by namespace for current/partial month
get byNamespace() {
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.byNamespace.length === 0;
}
get hasAttributionData() {
if (this.selectedAuthMethod) return false;
if (this.selectedNamespace) {
return this.authMethodOptions.length > 0;
}
return this.totalUsageCounts.clients !== 0 && !!this.totalClientAttribution;
}
get filteredCurrentData() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.byNamespace;
}
if (!auth) {
return this.byNamespace.find((ns) => ns.label === namespace);
}
return this.byNamespace
.find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth);
}
get upgradeDuringCurrentMonth() {
if (!this.upgradeVersionHistory) {
return null;
}
const upgradesWithinData = this.upgradeVersionHistory.filter((upgrade) => {
// TODO how do timezones affect this?
const upgradeDate = new Date(upgrade.timestampInstalled);
return isAfter(upgradeDate, startOfMonth(new Date()));
});
// return all upgrades that happened within date range of queried activity
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
}
get upgradeVersionAndDate() {
if (!this.upgradeDuringCurrentMonth) {
return null;
}
if (this.upgradeDuringCurrentMonth.length === 2) {
const versions = this.upgradeDuringCurrentMonth.map((upgrade) => upgrade.id).join(' and ');
return `Vault was upgraded to ${versions} during this month.`;
} else {
const version = this.upgradeDuringCurrentMonth[0];
return `Vault was upgraded to ${version.id} on this month.`;
}
}
get versionSpecificText() {
if (!this.upgradeDuringCurrentMonth) {
return null;
}
if (this.upgradeDuringCurrentMonth.length === 1) {
const version = this.upgradeDuringCurrentMonth[0].id;
if (version.match('1.9')) {
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data below.';
}
if (version.match('1.10')) {
return ' We added mount level attribution starting in 1.10, so keep that in mind when looking at the data below.';
}
}
// return combined explanation if spans multiple upgrades
return ' How we count clients changed in 1.9 and we added mount level attribution starting in 1.10. Keep this in mind when looking at the data below.';
}
// top level TOTAL client counts for current/partial month
get totalUsageCounts() {
return this.selectedNamespace ? this.filteredCurrentData : this.args.model.monthly?.total;
}
// total client attribution data for horizontal bar chart in attribution component
get totalClientAttribution() {
if (this.selectedNamespace) {
return this.filteredCurrentData?.mounts || null;
} else {
return this.byNamespace;
}
}
get responseTimestamp() {
return this.args.model.monthly?.responseTimestamp;
}
// ACTIONS
@action
selectNamespace([value]) {
// value comes in as [namespace0]
this.selectedNamespace = value;
if (!value) {
this.authMethodOptions = [];
// on clear, also make sure auth method is cleared
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredCurrentData.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
this.authMethodOptions = mounts;
}
}
@action
setAuthMethod([authMount]) {
this.selectedAuthMethod = authMount;
}
}

View File

@ -0,0 +1,371 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { isAfter, isBefore, isSameMonth, format } from 'date-fns';
import getStorage from 'vault/lib/token-storage';
import { parseAPITimestamp } from 'core/utils/date-formatters';
// my sincere apologies to the next dev who has to refactor/debug this (⇀‸↼‶)
export default class Dashboard extends Component {
@service store;
@service version;
chartLegend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
// RESPONSE
@tracked startMonthTimestamp; // when user queries, updates to first month object of response
@tracked endMonthTimestamp; // when user queries, updates to last month object of response
@tracked queriedActivityResponse = null;
// track params sent to /activity request
@tracked activityQueryParams = {
start: {}, // updates when user edits billing start month
end: {}, // updates when user queries end dates via calendar widget
};
// SEARCH SELECT FILTERS
get namespaceArray() {
return this.getActivityResponse.byNamespace
? this.getActivityResponse.byNamespace.map((namespace) => ({
name: namespace.label,
id: namespace.label,
}))
: [];
}
@tracked selectedNamespace = null;
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
// TEMPLATE VIEW
@tracked noActivityData;
@tracked showBillingStartModal = false;
@tracked isLoadingQuery = false;
@tracked errorObject = null;
constructor() {
super(...arguments);
this.startMonthTimestamp = this.args.model.licenseStartTimestamp;
this.endMonthTimestamp = this.args.model.currentDate;
this.activityQueryParams.start.timestamp = this.args.model.licenseStartTimestamp;
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
this.noActivityData = this.args.model.activity.id === 'no-data' ? true : false;
}
// returns text for empty state message if noActivityData
get dateRangeMessage() {
if (!this.startMonthTimestamp && !this.endMonthTimestamp) return null;
const endMonth = isSameMonth(
parseAPITimestamp(this.startMonthTimestamp),
parseAPITimestamp(this.endMonthTimestamp)
)
? ''
: ` to ${parseAPITimestamp(this.endMonthTimestamp, 'MMMM yyyy')}`;
// completes the message 'No data received from { dateRangeMessage }'
return `from ${parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy')}` + endMonth;
}
get versionText() {
return this.version.isEnterprise
? {
label: 'Billing start month',
description:
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
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.',
}
: {
label: 'Client counting start date',
description:
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
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.',
};
}
get isDateRange() {
return !isSameMonth(
parseAPITimestamp(this.getActivityResponse.startTime),
parseAPITimestamp(this.getActivityResponse.endTime)
);
}
get isCurrentMonth() {
return (
isSameMonth(
parseAPITimestamp(this.getActivityResponse.startTime),
parseAPITimestamp(this.args.model.currentDate)
) &&
isSameMonth(
parseAPITimestamp(this.getActivityResponse.endTime),
parseAPITimestamp(this.args.model.currentDate)
)
);
}
get startTimeDiscrepancy() {
// show banner if startTime returned from activity log (response) is after the queried startTime
const activityStartDateObject = parseAPITimestamp(this.getActivityResponse.startTime);
const queryStartDateObject = parseAPITimestamp(this.startMonthTimestamp);
let message = 'You requested data from';
if (this.startMonthTimestamp === this.args.model.licenseStartTimestamp && this.version.isEnterprise) {
// on init, date is automatically pulled from license start date and user hasn't queried anything yet
message = 'Your license start date is';
}
if (
isAfter(activityStartDateObject, queryStartDateObject) &&
!isSameMonth(activityStartDateObject, queryStartDateObject)
) {
return `${message} ${parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy')}.
We only have data from ${parseAPITimestamp(this.getActivityResponse.startTime, 'MMMM yyyy')},
and that is what is being shown here.`;
} else {
return null;
}
}
get upgradeDuringActivity() {
const versionHistory = this.args.model.versionHistory;
if (!versionHistory || versionHistory.length === 0) {
return null;
}
// filter for upgrade data of noteworthy upgrades (1.9 and/or 1.10)
const upgradeVersionHistory = versionHistory.filter(
({ version }) => version.match('1.9') || version.match('1.10')
);
if (!upgradeVersionHistory || upgradeVersionHistory.length === 0) {
return null;
}
const activityStart = parseAPITimestamp(this.getActivityResponse.startTime);
const activityEnd = parseAPITimestamp(this.getActivityResponse.endTime);
// filter and return all upgrades that happened within date range of queried activity
const upgradesWithinData = upgradeVersionHistory.filter(({ timestampInstalled }) => {
const upgradeDate = parseAPITimestamp(timestampInstalled);
return isAfter(upgradeDate, activityStart) && isBefore(upgradeDate, activityEnd);
});
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
}
get upgradeVersionAndDate() {
if (!this.upgradeDuringActivity) return null;
if (this.upgradeDuringActivity.length === 2) {
const [firstUpgrade, secondUpgrade] = this.upgradeDuringActivity;
const firstDate = parseAPITimestamp(firstUpgrade.timestampInstalled, 'MMM d, yyyy');
const secondDate = parseAPITimestamp(secondUpgrade.timestampInstalled, 'MMM d, yyyy');
return `Vault was upgraded to ${firstUpgrade.version} (${firstDate}) and ${secondUpgrade.version} (${secondDate}) during this time range.`;
} else {
const [upgrade] = this.upgradeDuringActivity;
return `Vault was upgraded to ${upgrade.version} on ${parseAPITimestamp(
upgrade.timestampInstalled,
'MMM d, yyyy'
)}.`;
}
}
get upgradeExplanation() {
if (!this.upgradeDuringActivity) return null;
if (this.upgradeDuringActivity.length === 1) {
const version = this.upgradeDuringActivity[0].version;
if (version.match('1.9')) {
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data.';
}
if (version.match('1.10')) {
return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data.';
}
}
// return combined explanation if spans multiple upgrades
return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data.';
}
get formattedStartDate() {
if (!this.startMonthTimestamp) return null;
return parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy');
}
// GETTERS FOR RESPONSE & DATA
// on init API response uses license start_date, getter updates when user queries dates
get getActivityResponse() {
return this.queriedActivityResponse || this.args.model.activity;
}
get byMonthActivityData() {
if (this.selectedNamespace) {
return this.filteredActivityByMonth;
} else {
return this.getActivityResponse?.byMonth;
}
}
get hasAttributionData() {
if (this.selectedAuthMethod) return false;
if (this.selectedNamespace) {
return this.authMethodOptions.length > 0;
}
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
}
// (object) top level TOTAL client counts for given date range
get totalUsageCounts() {
return this.selectedNamespace ? this.filteredActivityByNamespace : this.getActivityResponse.total;
}
// (object) single month new client data with total counts + array of namespace breakdown
get newClientCounts() {
return this.isDateRange ? null : this.byMonthActivityData[0]?.new_clients;
}
// total client data for horizontal bar chart in attribution component
get totalClientAttribution() {
if (this.selectedNamespace) {
return this.filteredActivityByNamespace?.mounts || null;
} else {
return this.getActivityResponse?.byNamespace || null;
}
}
// new client data for horizontal bar chart
get newClientAttribution() {
// new client attribution only available in a single, historical month (not a date range or current month)
if (this.isDateRange || this.isCurrentMonth) return null;
if (this.selectedNamespace) {
return this.newClientCounts?.mounts || null;
} else {
return this.newClientCounts?.namespaces || null;
}
}
get responseTimestamp() {
return this.getActivityResponse.responseTimestamp;
}
// FILTERS
get filteredActivityByNamespace() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse;
}
if (!auth) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
}
return this.getActivityResponse.byNamespace
.find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth);
}
get filteredActivityByMonth() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse?.byMonth;
}
const namespaceData = this.getActivityResponse?.byMonth
.map((m) => m.namespaces_by_key[namespace])
.filter((d) => d !== undefined);
if (!auth) {
return namespaceData.length === 0 ? null : namespaceData;
}
const mountData = namespaceData
.map((namespace) => namespace.mounts_by_key[auth])
.filter((d) => d !== undefined);
return mountData.length === 0 ? null : mountData;
}
@action
async handleClientActivityQuery({ dateType, monthIdx, year }) {
this.showBillingStartModal = false;
switch (dateType) {
case 'cancel':
return;
case 'reset': // clicked 'Current billing period' in calendar widget -> reset to initial start/end dates
this.activityQueryParams.start.timestamp = this.args.model.licenseStartTimestamp;
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
break;
case 'currentMonth': // clicked 'Current month' from calendar widget
this.activityQueryParams.start.timestamp = this.args.model.currentDate;
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
break;
case 'startDate': // from "Edit billing start" modal
this.activityQueryParams.start = { monthIdx, year };
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
break;
case 'endDate': // selected month and year from calendar widget
this.activityQueryParams.end = { monthIdx, year };
break;
default:
break;
}
try {
this.isLoadingQuery = true;
const response = await this.store.queryRecord('clients/activity', {
start_time: this.activityQueryParams.start,
end_time: this.activityQueryParams.end,
});
// preference for byMonth timestamps because those correspond to a user's query
const { byMonth } = response;
this.startMonthTimestamp = byMonth[0]?.timestamp || response.startTime;
this.endMonthTimestamp = byMonth[byMonth.length - 1]?.timestamp || response.endTime;
if (response.id === 'no-data') {
this.noActivityData = true;
} else {
this.noActivityData = false;
getStorage().setItem('vault:ui-inputted-start-date', this.startMonthTimestamp);
}
this.queriedActivityResponse = response;
// reset search-select filters
this.selectedNamespace = null;
this.selectedAuthMethod = null;
this.authMethodOptions = [];
} catch (e) {
this.errorObject = e;
return e;
} finally {
this.isLoadingQuery = false;
}
}
get hasMultipleMonthsData() {
return this.byMonthActivityData && this.byMonthActivityData.length > 1;
}
@action
selectNamespace([value]) {
this.selectedNamespace = value;
if (!value) {
this.authMethodOptions = [];
// on clear, also make sure auth method is cleared
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredActivityByNamespace.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
this.authMethodOptions = mounts;
}
}
@action
setAuthMethod([authMount]) {
this.selectedAuthMethod = authMount;
}
// validation function sent to <DateDropdown> selecting 'endDate'
@action
isEndBeforeStart(selection) {
let { start } = this.activityQueryParams;
start = start?.timestamp ? parseAPITimestamp(start.timestamp) : new Date(start.year, start.monthIdx);
return isBefore(selection, start) && !isSameMonth(start, selection)
? `End date must be after ${format(start, 'MMMM yyyy')}`
: false;
}
}

View File

@ -1,393 +0,0 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { isSameMonth, isAfter, isBefore } from 'date-fns';
import getStorage from 'vault/lib/token-storage';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
import { dateFormat } from 'core/helpers/date-format';
import { parseAPITimestamp } from 'core/utils/date-formatters';
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
export default class History extends Component {
@service store;
@service version;
arrayOfMonths = ARRAY_OF_MONTHS;
chartLegend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
// FOR START DATE EDIT & MODAL //
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;
});
currentDate = new Date();
currentYear = this.currentDate.getFullYear(); // integer of year
currentMonth = this.currentDate.getMonth(); // index of month
@tracked isEditStartMonthOpen = false;
@tracked startMonth = null;
@tracked startYear = null;
@tracked allowedMonthMax = 12;
@tracked disabledYear = 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;
// SEARCH SELECT
@tracked selectedNamespace = null;
@tracked namespaceArray = this.getActivityResponse.byNamespace
? this.getActivityResponse.byNamespace.map((namespace) => ({
name: namespace.label,
id: namespace.label,
}))
: [];
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
// TEMPLATE MESSAGING
@tracked noActivityDate = '';
@tracked responseRangeDiffMessage = null;
@tracked isLoadingQuery = false;
@tracked licenseStartIsCurrentMonth = this.args.model.activity?.isLicenseDateError || false;
@tracked errorObject = null;
get versionText() {
return this.version.isEnterprise
? {
label: 'Billing start month',
description:
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
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.',
}
: {
label: 'Client counting start date',
description:
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
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.',
};
}
get isDateRange() {
return !isSameMonth(
parseAPITimestamp(this.getActivityResponse.startTime),
parseAPITimestamp(this.getActivityResponse.endTime)
);
}
get upgradeVersionHistory() {
const versionHistory = this.args.model.versionHistory;
if (!versionHistory || versionHistory.length === 0) {
return null;
}
// get upgrade data for initial upgrade to 1.9 and/or 1.10
const relevantUpgrades = [];
const importantUpgrades = ['1.9', '1.10'];
importantUpgrades.forEach((version) => {
const findUpgrade = versionHistory.find((versionData) => versionData.id.match(version));
if (findUpgrade) relevantUpgrades.push(findUpgrade);
});
// array of upgrade data objects for noteworthy upgrades
return relevantUpgrades;
}
get upgradeDuringActivity() {
if (!this.upgradeVersionHistory) {
return null;
}
const activityStart = new Date(this.getActivityResponse.startTime);
const activityEnd = new Date(this.getActivityResponse.endTime);
const upgradesWithinData = this.upgradeVersionHistory.filter((upgrade) => {
// TODO how do timezones affect this?
const upgradeDate = new Date(upgrade.timestampInstalled);
return isAfter(upgradeDate, activityStart) && isBefore(upgradeDate, activityEnd);
});
// return all upgrades that happened within date range of queried activity
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
}
get upgradeVersionAndDate() {
if (!this.upgradeDuringActivity) {
return null;
}
if (this.upgradeDuringActivity.length === 2) {
const firstUpgrade = this.upgradeDuringActivity[0];
const secondUpgrade = this.upgradeDuringActivity[1];
const firstDate = dateFormat([firstUpgrade.timestampInstalled, 'MMM d, yyyy'], { isFormatted: true });
const secondDate = dateFormat([secondUpgrade.timestampInstalled, 'MMM d, yyyy'], { isFormatted: true });
return `Vault was upgraded to ${firstUpgrade.id} (${firstDate}) and ${secondUpgrade.id} (${secondDate}) during this time range.`;
} else {
const upgrade = this.upgradeDuringActivity[0];
return `Vault was upgraded to ${upgrade.id} on ${dateFormat(
[upgrade.timestampInstalled, 'MMM d, yyyy'],
{ isFormatted: true }
)}.`;
}
}
get versionSpecificText() {
if (!this.upgradeDuringActivity) {
return null;
}
if (this.upgradeDuringActivity.length === 1) {
const version = this.upgradeDuringActivity[0].id;
if (version.match('1.9')) {
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data below.';
}
if (version.match('1.10')) {
return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data below.';
}
}
// return combined explanation if spans multiple upgrades
return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data below.';
}
get startTimeDisplay() {
if (!this.startTimeFromResponse) {
return null;
}
const month = this.startTimeFromResponse[1];
const year = this.startTimeFromResponse[0];
return `${this.arrayOfMonths[month]} ${year}`;
}
get endTimeDisplay() {
if (!this.endTimeFromResponse) {
return null;
}
const month = this.endTimeFromResponse[1];
const year = this.endTimeFromResponse[0];
return `${this.arrayOfMonths[month]} ${year}`;
}
// GETTERS FOR RESPONSE & DATA
// on init API response uses license start_date, getter updates when user queries dates
get getActivityResponse() {
return this.queriedActivityResponse || this.args.model.activity;
}
get byMonthActivityData() {
if (this.selectedNamespace) {
return this.filteredActivityByMonth;
} else {
return this.getActivityResponse?.byMonth;
}
}
get byMonthNewClients() {
if (this.byMonthActivityData) {
return this.byMonthActivityData?.map((m) => m.new_clients);
}
return null;
}
get hasAttributionData() {
if (this.selectedAuthMethod) return false;
if (this.selectedNamespace) {
return this.authMethodOptions.length > 0;
}
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
}
// (object) top level TOTAL client counts for given date range
get totalUsageCounts() {
return this.selectedNamespace ? this.filteredActivityByNamespace : this.getActivityResponse.total;
}
// (object) single month new client data with total counts + array of namespace breakdown
get newClientCounts() {
return this.isDateRange ? null : this.byMonthActivityData[0]?.new_clients;
}
// total client data for horizontal bar chart in attribution component
get totalClientAttribution() {
if (this.selectedNamespace) {
return this.filteredActivityByNamespace?.mounts || null;
} else {
return this.getActivityResponse?.byNamespace || null;
}
}
// new client data for horizontal bar chart
get newClientAttribution() {
// new client attribution only available in a single, historical month (not a date range)
if (this.isDateRange) return null;
if (this.selectedNamespace) {
return this.newClientCounts?.mounts || null;
} else {
return this.newClientCounts?.namespaces || null;
}
}
get responseTimestamp() {
return this.getActivityResponse.responseTimestamp;
}
// FILTERS
get filteredActivityByNamespace() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse;
}
if (!auth) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
}
return this.getActivityResponse.byNamespace
.find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth);
}
get filteredActivityByMonth() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse?.byMonth;
}
const namespaceData = this.getActivityResponse?.byMonth
.map((m) => m.namespaces_by_key[namespace])
.filter((d) => d !== undefined);
if (!auth) {
return namespaceData.length === 0 ? null : namespaceData;
}
const mountData = namespaceData
.map((namespace) => namespace.mounts_by_key[auth])
.filter((d) => d !== undefined);
return mountData.length === 0 ? null : mountData;
}
@action
async handleClientActivityQuery(month, year, dateType) {
this.isEditStartMonthOpen = false;
if (dateType === 'cancel') {
return;
}
// clicked "Current Billing period" in the calendar widget
if (dateType === 'reset') {
this.startTimeRequested = this.args.model.startTimeFromLicense;
this.endTimeRequested = null;
}
// clicked "Edit" Billing start month in History which opens a modal.
if (dateType === 'startTime') {
const monthIndex = this.arrayOfMonths.indexOf(month);
this.startTimeRequested = [year.toString(), monthIndex]; // ['2021', 0] (e.g. January 2021)
this.endTimeRequested = null;
}
// clicked "Custom End Month" from the calendar-widget
if (dateType === 'endTime') {
// use the currently selected startTime for your startTimeRequested.
this.startTimeRequested = this.startTimeFromResponse;
this.endTimeRequested = [year.toString(), month]; // endTime comes in as a number/index whereas startTime comes in as a month name. Hence the difference between monthIndex and month.
}
try {
this.isLoadingQuery = true;
const response = await this.store.queryRecord('clients/activity', {
start_time: this.startTimeRequested,
end_time: this.endTimeRequested,
});
if (response.id === 'no-data') {
// empty response (204) is the only time we want to update the displayed date with the requested time
this.startTimeFromResponse = this.startTimeRequested;
this.noActivityDate = this.startTimeDisplay;
} else {
// note: this.startTimeDisplay (getter) is updated by the @tracked startTimeFromResponse
this.startTimeFromResponse = response.formattedStartTime;
this.endTimeFromResponse = response.formattedEndTime;
this.storage().setItem(INPUTTED_START_DATE, this.startTimeFromResponse);
}
this.queriedActivityResponse = response;
this.licenseStartIsCurrentMonth = response.isLicenseDateError;
// compare if the response startTime comes after the requested startTime. If true throw a warning.
// only display if they selected a startTime
if (
dateType === 'startTime' &&
isAfter(
new Date(this.getActivityResponse.startTime),
new Date(this.startTimeRequested[0], this.startTimeRequested[1])
)
) {
this.responseRangeDiffMessage = `You requested data from ${month} ${year}. We only have data from ${this.startTimeDisplay}, and that is what is being shown here.`;
} else {
this.responseRangeDiffMessage = null;
}
} catch (e) {
this.errorObject = e;
return e;
} finally {
this.isLoadingQuery = false;
}
}
get hasMultipleMonthsData() {
return this.byMonthActivityData && this.byMonthActivityData.length > 1;
}
@action
handleCurrentBillingPeriod() {
this.handleClientActivityQuery(0, 0, 'reset');
}
@action
selectNamespace([value]) {
// value comes in as [namespace0]
this.selectedNamespace = value;
if (!value) {
this.authMethodOptions = [];
// on clear, also make sure auth method is cleared
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredActivityByNamespace.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
this.authMethodOptions = mounts;
}
}
@action
setAuthMethod([authMount]) {
this.selectedAuthMethod = authMount;
}
// FOR START DATE MODAL
@action
selectStartMonth(month, event) {
this.startMonth = month;
// disables months if in the future
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
event.close();
}
@action
selectStartYear(year, event) {
this.startYear = year;
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
event.close();
}
storage() {
return getStorage();
}
}

View File

@ -54,7 +54,7 @@ export default class LineChart extends Component {
} else if (!Object.keys(upgradeData[0]).includes('timestampInstalled')) {
// eslint-disable-next-line
console.debug(
`upgrade must be an object with the following key names: ['id', 'previousVersion', 'timestampInstalled']`
`upgrade must be an object with the following key names: ['version', 'previousVersion', 'timestampInstalled']`
);
return null;
} else {
@ -184,9 +184,9 @@ export default class LineChart extends Component {
this.tooltipUpgradeText = '';
const upgradeInfo = findUpgradeData(data);
if (upgradeInfo) {
const { id, previousVersion } = upgradeInfo;
const { version, previousVersion } = upgradeInfo;
this.tooltipUpgradeText = `Vault was upgraded
${previousVersion ? 'from ' + previousVersion : ''} to ${id}`;
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
}
const node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node();

View File

@ -9,7 +9,7 @@ import { calculateAverage } from 'vault/utils/chart-helpers';
* ```js
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@timestamp={{this.responseTimestamp}}
@responseTimestamp={{this.responseTimestamp}}
@verticalBarChartData={{this.byMonthActivityData}}
/>
* ```

View File

@ -12,8 +12,7 @@ import { calculateAverage } from 'vault/utils/chart-helpers';
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedNamespace={{this.selectedNamespace}}
@barChartData={{this.byMonthNewClients}}
@lineChartData={{this.byMonth}}
@byMonthActivityData={{this.byMonth}}
@runningTotals={{this.runningTotals}}
@upgradeData={{if this.countsIncludeOlderData this.latestUpgradeData}}
/>
@ -21,38 +20,44 @@ import { calculateAverage } from 'vault/utils/chart-helpers';
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} selectedAuthMethod - string of auth method label for empty state message in bar chart
* @param {array} barChartData - array of objects from /activity response, from the 'months' key
object example: {
* @param {array} byMonthActivityData - array of objects from /activity response, from the 'months' key, includes total and new clients per month
object structure: {
month: '1/22',
entity_clients: 23,
non_entity_clients: 45,
total: 68,
clients: 68,
namespaces: [],
new_clients: {
entity_clients: 11,
non_entity_clients: 36,
total: 47,
clients: 47,
namespaces: [],
},
};
* @param {array} lineChartData - array of objects from /activity response, from the 'months' key
* @param {object} runningTotals - top level totals from /activity response { clients: 3517, entity_clients: 1593, non_entity_clients: 1924 }
* @param {object} upgradeData - object containing version upgrade data e.g.: {id: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
* @param {object} upgradeData - object containing version upgrade data e.g.: {version: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
*
*/
export default class RunningTotal extends Component {
get byMonthNewClients() {
if (this.args.byMonthActivityData) {
return this.args.byMonthActivityData?.map((m) => m.new_clients);
}
return null;
}
get entityClientData() {
return {
runningTotal: this.args.runningTotals.entity_clients,
averageNewClients: calculateAverage(this.args.barChartData, 'entity_clients') || '0',
averageNewClients: calculateAverage(this.byMonthNewClients, 'entity_clients'),
};
}
get nonEntityClientData() {
return {
runningTotal: this.args.runningTotals.non_entity_clients,
averageNewClients: calculateAverage(this.args.barChartData, 'non_entity_clients') || '0',
averageNewClients: calculateAverage(this.byMonthNewClients, 'non_entity_clients'),
};
}
@ -70,22 +75,7 @@ export default class RunningTotal extends Component {
);
}
get showSingleMonth() {
if (this.args.lineChartData?.length === 1) {
const monthData = this.args?.lineChartData[0];
return {
total: {
total: monthData.clients,
entityClients: monthData.entity_clients,
nonEntityClients: monthData.non_entity_clients,
},
new: {
total: monthData.new_clients.clients,
entityClients: monthData.new_clients.entity_clients,
nonEntityClients: monthData.new_clients.non_entity_clients,
},
};
}
return null;
get singleMonthData() {
return this.args?.byMonthActivityData[0];
}
}

View File

@ -93,13 +93,15 @@ export default class VerticalBarChart extends Component {
const tooltipTether = chartSvg
.append('g')
.attr('transform', `translate(${BAR_WIDTH / 2})`)
.attr('data-test-vertical-chart', 'tool-tip-tethers')
.attr('data-test-vertical-chart', 'tooltip-tethers')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.style('opacity', '0')
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]));
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 1);
// MAKE AXES //
const yAxisScale = scaleLinear()

View File

@ -1,53 +1,79 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
/**
* @module DateDropdown
* DateDropdown components are used to display a dropdown of months and years to handle date selection
* DateDropdown components are used to display a dropdown of months and years to handle date selection. Future dates are disabled (current month and year are selectable).
* The component returns an object with selected date info, example: { dateType: 'start', monthIdx: 0, monthName: 'January', year: 2022 }
*
* @example
* ```js
* <DateDropdown @handleDateSelection={this.actionFromParent} @name={{"startTime"}} @submitText="Save"/>
* <DateDropdown @handleSubmit={{this.actionFromParent}} @name="startTime" @submitText="Save" @handleCancel={{this.onCancel}}/>
* ```
* @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
* @param {function} handleSubmit - callback function from parent that the date picker triggers on submit
* @param {function} [handleCancel] - optional callback for cancel action, if exists then buttons appear modal style with a light gray background
* @param {string} [dateType] - optional argument to give the selected month/year a type
* @param {string} [submitText] - optional argument to change submit button text
* @param {function} [validateDate] - parent function to validate date selection, receives date object and returns an error message that's passed to the inline alert
*/
export default class DateDropdown extends Component {
currentDate = new Date();
currentYear = this.currentDate.getFullYear(); // integer of year
currentMonth = this.currentDate.getMonth(); // index of month
currentMonthIdx = this.currentDate.getMonth(); // integer of month, 0 indexed
dropdownMonths = ARRAY_OF_MONTHS.map((m, i) => ({ name: m, index: i }));
dropdownYears = Array.from({ length: 5 }, (item, i) => this.currentYear - i);
@tracked allowedMonthMax = 12;
@tracked disabledYear = null;
@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;
});
@tracked maxMonthIdx = 11; // disables months with index greater than this number, initially all months are selectable
@tracked disabledYear = null; // year as integer if current year should be disabled
@tracked selectedMonth = null;
@tracked selectedYear = null;
@tracked invalidDate = null;
@action
selectStartMonth(month, event) {
this.startMonth = month;
// disables months if in the future
this.disabledYear = this.months.indexOf(month) >= this.currentMonth ? this.currentYear : null;
event.close();
selectMonth(month, dropdown) {
this.selectedMonth = month;
// disable current year if selected month is later than current month
this.disabledYear = month.index > this.currentMonthIdx ? this.currentYear : null;
dropdown.close();
}
@action
selectStartYear(year, event) {
this.startYear = year;
this.allowedMonthMax = year === this.currentYear ? this.currentMonth : 12;
event.close();
selectYear(year, dropdown) {
this.selectedYear = year;
// disable months after current month if selected year is current year
this.maxMonthIdx = year === this.currentYear ? this.currentMonthIdx : 11;
dropdown.close();
}
@action
saveDateSelection() {
this.args.handleDateSelection(this.startMonth, this.startYear, this.args.name);
handleSubmit() {
if (this.args.validateDate) {
this.invalidDate = null;
this.invalidDate = this.args.validateDate(new Date(this.selectedYear, this.selectedMonth.index));
if (this.invalidDate) return;
}
const { index, name } = this.selectedMonth;
this.args.handleSubmit({
monthIdx: index,
monthName: name,
year: this.selectedYear,
dateType: this.args.dateType,
});
this.resetDropdown();
}
@action
handleCancel() {
this.args.handleCancel();
this.resetDropdown();
}
resetDropdown() {
this.maxMonthIdx = 11;
this.disabledYear = null;
this.selectedMonth = null;
this.selectedYear = null;
this.invalidDate = null;
}
}

View File

@ -3,8 +3,6 @@ export default class Activity extends Model {
@attr('array') byMonth;
@attr('array') byNamespace;
@attr('object') total;
@attr('array') formattedEndTime;
@attr('array') formattedStartTime;
@attr('string') startTime;
@attr('string') endTime;
@attr('string') responseTimestamp;

View File

@ -5,7 +5,7 @@ import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { apiPath } from 'vault/macros/lazy-capabilities';
const M = Model.extend({
queriesAvailable: attr('boolean'),
queriesAvailable: attr('boolean'), // true only if historical data exists, will be false if there is only current month data
retentionMonths: attr('number', {
label: 'Retention period',
subText: 'The number of months of activity logs to maintain for client tracking.',

View File

@ -1,6 +0,0 @@
import Model, { attr } from '@ember-data/model';
export default class MonthlyModel extends Model {
@attr('string') responseTimestamp;
@attr('object') total; // total clients during the current/partial month
@attr('array') byNamespace;
}

View File

@ -1,5 +1,6 @@
import Model, { attr } from '@ember-data/model';
export default class VersionHistoryModel extends Model {
@attr('string') version;
@attr('string') previousVersion;
@attr('string') timestampInstalled;
}

View File

@ -20,8 +20,7 @@ Router.map(function () {
this.route('license');
this.route('mfa-setup');
this.route('clients', function () {
this.route('current');
this.route('history');
this.route('dashboard');
this.route('config');
this.route('edit');
});

View File

@ -1,5 +1,5 @@
import Route from '@ember/routing/route';
import RSVP from 'rsvp';
import { hash } from 'rsvp';
import { action } from '@ember/object';
import getStorage from 'vault/lib/token-storage';
import { inject as service } from '@ember/service';
@ -8,32 +8,24 @@ const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
export default class ClientsRoute extends Route {
@service store;
async getVersionHistory() {
try {
const arrayOfModels = [];
const response = await this.store.findAll('clients/version-history'); // returns a class with nested models
response.forEach((model) => {
arrayOfModels.push({
id: model.id,
previousVersion: model.previousVersion,
timestampInstalled: model.timestampInstalled,
return this.store
.findAll('clients/version-history')
.then((response) => {
return response.map(({ version, previousVersion, timestampInstalled }) => {
return {
version,
previousVersion,
timestampInstalled,
};
});
});
return arrayOfModels;
} catch (e) {
console.debug(e); // eslint-disable-line
return [];
}
})
.catch(() => []);
}
async model() {
const config = await this.store.queryRecord('clients/config', {}).catch((e) => {
console.debug(e); // eslint-disable-line
// swallowing error so activity can show if no config permissions
return {};
});
return RSVP.hash({
config,
model() {
// swallow config error so activity can show if no config permissions
return hash({
config: this.store.queryRecord('clients/config', {}).catch(() => {}),
versionHistory: this.getVersionHistory(),
});
}

View File

@ -1,17 +0,0 @@
import Route from '@ember/routing/route';
import RSVP from 'rsvp';
import { inject as service } from '@ember/service';
export default class CurrentRoute extends Route {
@service store;
async model() {
const parentModel = this.modelFor('vault.cluster.clients');
return RSVP.hash({
config: parentModel.config,
monthly: await this.store.queryRecord('clients/monthly', {}),
versionHistory: parentModel.versionHistory,
});
}
}

View File

@ -0,0 +1,43 @@
import Route from '@ember/routing/route';
import getStorage from 'vault/lib/token-storage';
import { inject as service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
currentDate = new Date().toISOString();
async getActivity(start_time) {
// on init ONLY make network request if we have a start_time
return start_time
? await this.store.queryRecord('clients/activity', {
start_time: { timestamp: start_time },
end_time: { timestamp: this.currentDate },
})
: {};
}
async getLicenseStartTime() {
try {
const license = await this.store.queryRecord('license', {});
// if license.startTime is 'undefined' return 'null' for consistency
return license.startTime || getStorage().getItem('vault:ui-inputted-start-date') || null;
} catch (e) {
// return null so user can input date manually
// if already inputted manually, will be in localStorage
return getStorage().getItem('vault:ui-inputted-start-date') || null;
}
}
async model() {
const { config, versionHistory } = this.modelFor('vault.cluster.clients');
const licenseStart = await this.getLicenseStartTime();
const activity = await this.getActivity(licenseStart);
return {
config,
versionHistory,
activity,
licenseStartTimestamp: licenseStart,
currentDate: this.currentDate,
};
}
}

View File

@ -1,46 +0,0 @@
import Route from '@ember/routing/route';
import { isSameMonth } from 'date-fns';
import RSVP from 'rsvp';
import getStorage from 'vault/lib/token-storage';
import { parseRFC3339 } from 'core/utils/date-formatters';
import { inject as service } from '@ember/service';
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
export default class HistoryRoute extends Route {
@service store;
async getActivity(start_time) {
if (isSameMonth(new Date(start_time), new Date())) {
// triggers empty state to manually enter date if license begins in current month
return { isLicenseDateError: true };
}
// on init ONLY make network request if we have a start_time
return start_time ? await this.store.queryRecord('clients/activity', { start_time }) : {};
}
async getLicenseStartTime() {
try {
const license = await this.store.queryRecord('license', {});
// if license.startTime is 'undefined' return 'null' for consistency
return license.startTime || getStorage().getItem(INPUTTED_START_DATE) || null;
} catch (e) {
// return null so user can input date manually
// if already inputted manually, will be in localStorage
return getStorage().getItem(INPUTTED_START_DATE) || null;
}
}
async model() {
const parentModel = this.modelFor('vault.cluster.clients');
const licenseStart = await this.getLicenseStartTime();
const activity = await this.getActivity(licenseStart);
return RSVP.hash({
config: parentModel.config,
activity,
startTimeFromLicense: parseRFC3339(licenseStart),
endTimeFromResponse: parseRFC3339(activity?.endTime),
versionHistory: parentModel.versionHistory,
});
}
}

View File

@ -1,6 +1,5 @@
import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns';
import { parseRFC3339 } from 'core/utils/date-formatters';
import { formatByMonths, formatByNamespace, homogenizeClientNaming } from 'core/utils/client-count-utils';
export default class ActivitySerializer extends ApplicationSerializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
@ -14,8 +13,6 @@ export default class ActivitySerializer extends ApplicationSerializer {
by_namespace: formatByNamespace(payload.data.by_namespace),
by_month: formatByMonths(payload.data.months),
total: homogenizeClientNaming(payload.data.total),
formatted_end_time: parseRFC3339(payload.data.end_time),
formatted_start_time: parseRFC3339(payload.data.start_time),
};
delete payload.data.by_namespace;
delete payload.data.months;

View File

@ -1,25 +0,0 @@
import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns';
import { formatByNamespace, homogenizeClientNaming } from 'core/utils/client-count-utils';
export default class MonthlySerializer extends ApplicationSerializer {
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (payload.id === 'no-data') {
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
}
const response_timestamp = formatISO(new Date());
// TODO CMB: the following is assumed, need to confirm
// the months array will always include a single object: a timestamp of the current month and new/total count data, if available
const transformedPayload = {
...payload,
response_timestamp,
by_namespace: formatByNamespace(payload.data.by_namespace),
// nest within 'total' object to mimic /activity response shape
total: homogenizeClientNaming(payload.data),
};
delete payload.data.by_namespace;
delete payload.data.months;
delete payload.data.total;
return super.normalizeResponse(store, primaryModelClass, transformedPayload, id, requestType);
}
}

View File

@ -1,13 +1,11 @@
import ApplicationSerializer from '../application';
export default ApplicationSerializer.extend({
export default class VersionHistorySerializer extends ApplicationSerializer {
primaryKey = 'version';
normalizeItems(payload) {
if (payload.data.keys && Array.isArray(payload.data.keys)) {
return payload.data.keys.map((key) => {
const model = payload.data.key_info[key];
model.id = key;
return model;
});
return payload.data.keys.map((key) => ({ version: key, ...payload.data.key_info[key] }));
}
},
});
}
}

View File

@ -82,12 +82,6 @@ $dark-gray: #535f73;
color: lighten($dark-gray, 30%);
pointer-events: none;
}
&.is-selected-month {
background-color: lighten($dark-gray, 30%);
color: white;
text-align: center;
pointer-events: none;
}
}
}

View File

@ -91,3 +91,20 @@ pre {
margin-left: 10px;
}
}
// customize spacing (.modal-card-body is restricted to padding: 20px)
.modal-card-custom {
background-color: white;
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
&.has-padding-m {
padding: $spacing-m;
}
&.has-padding-btm-left {
padding-bottom: $spacing-m;
padding-left: $spacing-m;
}
}

View File

@ -225,6 +225,9 @@
.has-top-padding-l {
padding-top: $spacing-l;
}
.has-left-padding-l {
padding-left: $spacing-l;
}
.has-top-padding-xxl {
padding-top: $spacing-xxl;
}

View File

@ -4,9 +4,9 @@
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{@startTimeDisplay}}
{{date-format this.startDate "MMM yyyy"}}
-
{{@endTimeDisplay}}
{{date-format this.endDate "MMM yyyy"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass={{concat "popup-menu-content calendar-content" (if this.showCalendar " calendar-open")}}>
@ -15,12 +15,24 @@
DATE OPTIONS
</div>
<ul class="menu-list">
<li class="action">
<button
data-test-current-month
class="link link-plain has-text-weight-semibold is-ghost"
type="button"
name="currentMonth"
{{on "click" (fn this.handleDateShortcut D.actions)}}
>
Current month
</button>
</li>
<li class="action">
<button
data-test-current-billing-period
class="link link-plain has-text-weight-semibold is-ghost"
type="button"
{{on "click" (fn this.selectCurrentBillingPeriod D)}}
name="reset"
{{on "click" (fn this.handleDateShortcut D.actions)}}
>
Current billing period
</button>
@ -65,7 +77,7 @@
{{this.displayYear}}
</p>
<button
data-test-future-year
data-test-next-year
type="button"
class={{concat "button is-transparent " (if (or this.tooltipTarget) "negative-margin" "padding-right")}}
disabled={{this.disableFutureYear}}
@ -93,15 +105,15 @@
{{/if}}
</div>
<div class="calendar-widget-grid calendar-widget">
{{#each this.widgetMonths as |m|}}
{{#each this.widgetMonths as |month|}}
<button
data-test-calendar-month={{m.month}}
data-test-calendar-month={{month.name}}
type="button"
class="is-month-list {{if m.readonly 'is-readOnly'}} {{if (eq m.id this.selectedMonthId) 'is-selected-month'}}"
id={{m.id}}
{{on "click" (fn this.selectEndMonth m.id D)}}
class="is-month-list {{if month.readonly 'is-readOnly'}}"
id={{month.index}}
{{on "click" (fn this.selectMonth month D.actions)}}
>
{{m.month}}
{{month.name}}
</button>
{{/each}}
</div>

View File

@ -1,6 +1,6 @@
{{! show single horizontal bar chart unless data is from a single, historical month (isDateRange = false) }}
{{! only show side-by-side horizontal bar charts if data is from a single, historical month }}
<div
class={{concat "chart-wrapper" (if (or @isCurrentMonth @isDateRange) " single-chart-grid" " dual-chart-grid")}}
class={{concat "chart-wrapper" (if @isHistoricalMonth " dual-chart-grid" " single-chart-grid")}}
data-test-clients-attribution
>
<div class="chart-header has-header-link has-bottom-margin-m">
@ -21,12 +21,32 @@
{{/if}}
</div>
</div>
{{#if this.barChartTotalClients}}
{{#if (or @isDateRange @isCurrentMonth)}}
{{#if @isHistoricalMonth}}
<div class="chart-container-left" data-test-chart-container="new-clients">
<h2 class="chart-title">New clients</h2>
<p class="chart-description">{{this.chartText.newCopy}}</p>
<Clients::HorizontalBarChart
@dataset={{this.barChartNewClients}}
@chartLegend={{@chartLegend}}
@totalCounts={{@newUsageCounts}}
@noDataMessage="There are no new clients for this namespace during this time period."
/>
</div>
<div class="chart-container-right" data-test-chart-container="total-clients">
<h2 class="chart-title">Total clients</h2>
<p class="chart-description">{{this.chartText.totalCopy}}</p>
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}}
@totalCounts={{@totalUsageCounts}}
/>
</div>
{{else}}
<div
class={{concat (unless this.barChartTotalClients "chart-empty-state ") "chart-container-wide"}}
data-test-chart-container="total-clients"
data-test-chart-container="single-chart"
>
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@ -47,27 +67,6 @@
<h3 class="data-details">Clients in {{this.attributionBreakdown}}</h3>
<p class="data-details">{{format-number this.topClientCounts.clients}}</p>
</div>
{{else}}
<div class="chart-container-left" data-test-chart-container="new-clients">
<h2 class="chart-title">New clients</h2>
<p class="chart-description">{{this.chartText.newCopy}}</p>
<Clients::HorizontalBarChart
@dataset={{this.barChartNewClients}}
@chartLegend={{@chartLegend}}
@totalCounts={{@newUsageCounts}}
@noDataMessage={{"There are no new clients for this namespace during this time period."}}
/>
</div>
<div class="chart-container-right" data-test-chart-container="total-clients">
<h2 class="chart-title">Total clients</h2>
<p class="chart-description">{{this.chartText.totalCopy}}</p>
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}}
@totalCounts={{@totalUsageCounts}}
/>
</div>
{{/if}}
<div class="legend-center">
<span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span>
@ -79,12 +78,13 @@
</div>
{{/if}}
<div class="timestamp" data-test-attribution-timestamp>
{{#if @timestamp}}
{{#if @responseTimestamp}}
Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}}
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>
</div>
{{! MODAL FOR CSV DOWNLOAD }}
<Modal
@title="Export attribution data"
@ -97,17 +97,30 @@
<p class="has-bottom-margin-s">
This export will include the namespace path, authentication method path, and the associated total, entity, and
non-entity clients for the below
{{if @isCurrentMonth "month" "date range"}}.
{{if this.formattedEndDate "date range" "month"}}.
</p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if @endTimeDisplay " RANGE"}}</p>
<p class="has-bottom-margin-s">{{@startTimeDisplay}} {{if @endTimeDisplay "-"}} {{@endTimeDisplay}}</p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if this.formattedEndDate " RANGE"}}</p>
<p class="has-bottom-margin-s">{{this.formattedStartDate}} {{if this.formattedEndDate "-"}} {{this.formattedEndDate}}</p>
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button type="button" class="button is-primary" {{on "click" (fn this.exportChartData this.getCsvFileName)}}>
<button type="button" class="button is-primary" {{on "click" (fn this.exportChartData this.formattedCsvFileName)}}>
Export
</button>
<button type="button" class="button is-secondary" onclick={{action (mut this.showCSVDownloadModal) false}}>
Cancel
</button>
{{#if @upgradeExplanation}}
<div class="has-text-grey is-size-8">
<AlertInline @type="warning" @isMarginless={{true}}>
Your data contains an upgrade.
<DocLink
@path="/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
>
Learn more here.
</DocLink>
</AlertInline>
<p class="has-left-padding-l">{{@upgradeExplanation}}</p>
</div>
{{/if}}
</footer>
</Modal>

View File

@ -1,86 +0,0 @@
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
<p class="has-bottom-margin-s">
The below data is for the current month starting from the first day. For historical data, see the 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.config">
Go to configuration
</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 if (eq @model.monthly.id "no-data")}}
<EmptyState
@title="No data available"
@message="Tracking may be turned off. Check with your administrator or check back later."
/>
{{else}}
<div class="is-subtitle-gray has-bottom-margin-m">
FILTERS
<Toolbar>
<ToolbarFilters data-test-clients-filter-bar>
<SearchSelect
@id="namespace-search-select-monthly"
@options={{this.namespaceArray}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}}
@displayInherit={{true}}
class="is-marginless"
/>
{{#if (not (is-empty this.authMethodOptions))}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.authMethodOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.setAuthMethod}}
@placeholder={{"Filter by auth method"}}
@displayInherit={{true}}
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
</div>
{{#if this.upgradeDuringCurrentMonth}}
<AlertBanner @type="warning" @title="Warning">
{{this.upgradeVersionAndDate}}
{{this.versionSpecificText}}
<DocLink @path="/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts">
Learn more here.
</DocLink>
</AlertBanner>
{{/if}}
{{#if this.totalUsageCounts}}
<Clients::UsageStats
@title={{date-format this.responseTimestamp "MMMM"}}
@totalUsageCounts={{this.totalUsageCounts}}
/>
{{#if this.hasAttributionData}}
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newUsageCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@selectedNamespace={{this.selectedNamespace}}
@startTimeDisplay={{date-format this.responseTimestamp "MMMM yyyy"}}
@isCurrentMonth={{true}}
@isDateRange={{false}}
@timestamp={{this.responseTimestamp}}
/>
{{/if}}
{{/if}}
{{/if}}
</div>

View File

@ -0,0 +1,208 @@
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
<p class="has-bottom-margin-xl">
This dashboard will surface Vault client usage over time. Clients represent a user or service that has authenticated to
Vault. Documentation is available
<DocLink @path="/vault/docs/concepts/client-count">here.</DocLink>
Date queries are sent in UTC.
</p>
<h2 class="title is-6 has-bottom-margin-xs">
{{this.versionText.label}}
</h2>
<div data-test-start-date-editor class="is-flex-align-baseline">
{{#if this.formattedStartDate}}
<p class="is-size-6" data-test-date-display>{{this.formattedStartDate}}</p>
<button type="button" class="button is-link" {{on "click" (fn (mut this.showBillingStartModal) true)}}>
Edit
</button>
{{else}}
<DateDropdown @handleSubmit={{this.handleClientActivityQuery}} @dateType="startDate" @submitText="Save" />
{{/if}}
</div>
<p class="is-8 has-text-grey has-bottom-margin-xl">
{{this.versionText.description}}
</p>
{{#if this.noActivityData}}
{{#if (eq @model.config.enabled "On")}}
<EmptyState
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
{{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.config">
Go to configuration
</LinkTo>
</p>
{{/if}}
</EmptyState>
{{/if}}
{{else if this.errorObject}}
<Clients::Error @error={{this.errorObject}} />
{{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}}
{{#if (or this.totalUsageCounts this.hasAttributionData)}}
<div class="is-subtitle-gray has-bottom-margin-m">
FILTERS
<Toolbar data-test-clients-filter-bar>
<ToolbarFilters>
<CalendarWidget
@startTimestamp={{this.startMonthTimestamp}}
@endTimestamp={{this.endMonthTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
{{#if this.namespaceArray}}
<SearchSelect
@id="namespace-search-select"
@options={{this.namespaceArray}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}}
@displayInherit={{true}}
class="is-marginless"
/>
{{/if}}
{{#if (not (is-empty this.authMethodOptions))}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.authMethodOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.setAuthMethod}}
@placeholder={{"Filter by auth method"}}
@displayInherit={{true}}
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
</div>
{{#if (or this.upgradeDuringActivity this.startTimeDiscrepancy)}}
<AlertBanner @type="warning" @title="Warning">
<ul class={{if (and this.versionUpdateText this.startTimeDiscrepancy) "bullet"}}>
{{#if this.startTimeDiscrepancy}}
<li>{{this.startTimeDiscrepancy}}</li>
{{/if}}
{{#if this.upgradeDuringActivity}}
<li>
{{this.upgradeVersionAndDate}}
{{this.upgradeExplanation}}
<DocLink
@path="/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
>
Learn more here.
</DocLink>
</li>
{{/if}}
</ul>
</AlertBanner>
{{/if}}
{{#if this.isLoadingQuery}}
<LayoutLoading />
{{else}}
{{#if this.totalUsageCounts}}
{{#unless this.byMonthActivityData}}
{{! UsageStats render when viewing a single, historical month AND activity data predates new client breakdown (< v1.10.0)
or viewing the current month filtered down to auth method }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within
{{if this.selectedAuthMethod this.selectedAuthMethod 'this namespace and all its children'}}.
{{if
this.isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
}}"
@totalUsageCounts={{this.totalUsageCounts}}
/>
{{/unless}}
{{#if this.byMonthActivityData}}
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedAuthMethod={{this.selectedAuthMethod}}
@byMonthActivityData={{this.byMonthActivityData}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@isCurrentMonth={{this.isCurrentMonth}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradeDuringActivity}}
@responseTimestamp={{this.responseTimestamp}}
/>
{{/if}}
{{#if this.hasAttributionData}}
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}
@selectedNamespace={{this.selectedNamespace}}
@startTimestamp={{this.startMonthTimestamp}}
@endTimestamp={{this.endMonthTimestamp}}
@responseTimestamp={{this.responseTimestamp}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@upgradeExplanation={{this.upgradeExplanation}}
/>
{{/if}}
{{#if this.hasMultipleMonthsData}}
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@verticalBarChartData={{this.byMonthActivityData}}
@responseTimestamp={{this.responseTimestamp}}
/>
{{/if}}
{{/if}}
{{/if}}
{{else if (and (not @model.licenseStartTimestamp) (not this.startMonthTimestamp))}}
{{! Empty state for no billing/license start date }}
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
{{else}}
<EmptyState
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
@message="Select a different start date above, or choose a different end date here:"
>
<DateDropdown
@handleSubmit={{this.handleClientActivityQuery}}
@validateDate={{this.isEndBeforeStart}}
@dateType="endDate"
@submitText="View"
/>
</EmptyState>
{{/if}}
{{/if}}
{{! BILLING START DATE MODAL }}
<Modal
@title="Edit start month"
@onClose={{action (mut this.showBillingStartModal) false}}
@isActive={{this.showBillingStartModal}}
@showCloseButton={{true}}
>
<section class="modal-card-custom has-padding-m">
<p class="has-bottom-margin-s">
{{this.versionText.description}}
</p>
<p><strong>{{this.versionText.label}}</strong></p>
</section>
<DateDropdown
@handleSubmit={{this.handleClientActivityQuery}}
@dateType="startDate"
@submitText="Save"
@handleCancel={{fn this.handleClientActivityQuery (hash dateType="cancel")}}
/>
</Modal>
</div>

View File

@ -1,262 +0,0 @@
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
<p class="has-bottom-margin-xl">
This data is presented by full month. If there is data missing, its possible that tracking was turned off at the time.
Vault will only show data for contiguous blocks of time during which tracking was on. Documentation is available
<DocLink @path="/docs/concepts/client-count">here</DocLink>.
</p>
<h2 class="title is-6 has-bottom-margin-xs">
{{this.versionText.label}}
</h2>
<div data-test-start-date-editor class="is-flex-align-baseline">
{{#if this.startTimeDisplay}}
<p class="is-size-6" data-test-date-display>{{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"}} @submitText="Save" />
{{/if}}
</div>
<p class="is-8 has-text-grey has-bottom-margin-xl">
{{this.versionText.description}}
</p>
{{#if this.licenseStartIsCurrentMonth}}
<EmptyState
@title="No data for this billing period"
@subTitle="Your billing period has just begun, so there is no data yet. Data will be available here on the first of next month."
@message="To view data from a previous billing period, you can enter your previous billing start date."
@bottomBorder={{true}}
>
<DateDropdown @handleDateSelection={{this.handleClientActivityQuery}} @name={{"startTime"}} @submitText="View" />
</EmptyState>
{{else if (eq @model.config.queriesAvailable false)}}
{{#if (eq @model.config.enabled "On")}}
<EmptyState
@title={{concat "No monthly history " (if this.noActivityDate "from ") this.noActivityDate}}
@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.config">
Go to configuration
</LinkTo>
</p>
{{/if}}
</EmptyState>
{{/if}}
{{else if this.errorObject}}
<Clients::Error @error={{this.errorObject}} />
{{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}}
{{#if (or this.totalUsageCounts this.hasAttributionData)}}
<div class="is-subtitle-gray has-bottom-margin-m">
FILTERS
<Toolbar data-test-clients-filter-bar>
<ToolbarFilters>
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{this.endTimeDisplay}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay={{this.startTimeDisplay}}
/>
{{#if this.namespaceArray}}
<SearchSelect
@id="namespace-search-select"
@options={{this.namespaceArray}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}}
@displayInherit={{true}}
class="is-marginless"
/>
{{/if}}
{{#if (not (is-empty this.authMethodOptions))}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.authMethodOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.setAuthMethod}}
@placeholder={{"Filter by auth method"}}
@displayInherit={{true}}
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
</div>
{{#if (or this.upgradeDuringActivity this.responseRangeDiffMessage)}}
<AlertBanner @type="warning" @title="Warning">
<ul class={{if (and this.versionUpdateText this.responseRangeDiffMessage) "bullet"}}>
{{#if this.responseRangeDiffMessage}}
<li>{{this.responseRangeDiffMessage}}</li>
{{/if}}
{{#if this.upgradeDuringActivity}}
<li>
{{this.upgradeVersionAndDate}}
{{this.versionSpecificText}}
<DocLink
@path="/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
>
Learn more here.
</DocLink>
</li>
{{/if}}
</ul>
</AlertBanner>
{{/if}}
{{#if this.isLoadingQuery}}
<LayoutLoading />
{{else}}
{{#if this.totalUsageCounts}}
{{#unless this.byMonthActivityData}}
<Clients::UsageStats @title="Total usage" @totalUsageCounts={{this.totalUsageCounts}} />
{{/unless}}
{{#if this.byMonthActivityData}}
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedAuthMethod={{this.selectedAuthMethod}}
@lineChartData={{this.byMonthActivityData}}
@barChartData={{this.byMonthNewClients}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradeDuringActivity}}
@timestamp={{this.responseTimestamp}}
/>
{{/if}}
{{#if this.hasAttributionData}}
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}
@selectedNamespace={{this.selectedNamespace}}
@startTimeDisplay={{this.startTimeDisplay}}
@endTimeDisplay={{this.endTimeDisplay}}
@isDateRange={{this.isDateRange}}
@timestamp={{this.responseTimestamp}}
/>
{{/if}}
{{#if this.hasMultipleMonthsData}}
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@verticalBarChartData={{this.byMonthActivityData}}
@timestamp={{this.responseTimestamp}}
/>
{{/if}}
{{/if}}
{{/if}}
{{else if (and (not @model.startTimeFromLicense) (not this.startTimeFromResponse))}}
{{! Empty state for no billing/license start date }}
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
{{else}}
<EmptyState
@title={{concat "No data received " (if this.noActivityDate "from ") this.noActivityDate}}
@message="No data exists for that query period. Try searching again."
/>
{{/if}}
{{/if}}
{{! BILLING START DATE MODAL }}
<Modal
@title="Edit start month"
@onClose={{action (mut this.isEditStartMonthOpen) false}}
@isActive={{this.isEditStartMonthOpen}}
@showCloseButton={{true}}
>
<section class="modal-card-body">
<p class="has-bottom-margin-s">
{{this.versionText.description}}
</p>
<p class="has-bottom-margin-s"><strong>{{this.versionText.label}}</strong></p>
<div class="modal-radio-button">
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
data-test-popup-menu-trigger="month"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.startMonth "Month"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll" aria-label="months">
<ul class="menu-list">
{{#each this.months as |month index|}}
<button
type="button"
class="button link"
data-test-date-modal-month={{month}}
disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}}
>
{{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="year"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.startYear "Year"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu" aria-label="years">
<ul class="menu-list">
{{#each this.years as |year|}}
<button
type="button"
class="button link"
data-test-date-modal-year={{year}}
disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectStartYear year D.actions)}}
>
{{year}}
</button>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
</div>
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button
data-test-modal-save
type="button"
class="button is-primary"
disabled={{or (if (and this.startMonth this.startYear) false true)}}
{{on "click" (fn this.handleClientActivityQuery this.startMonth this.startYear "startTime")}}
>
Save
</button>
<button type="button" class="button is-secondary" {{on "click" (fn this.handleClientActivityQuery 0 0 "cancel")}}>
Cancel
</button>
</footer>
</Modal>
</div>

View File

@ -32,9 +32,9 @@
</div>
<div data-test-monthly-usage-timestamp class="timestamp">
{{#if @timestamp}}
{{#if @responseTimestamp}}
Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}}
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>

View File

@ -1,46 +1,4 @@
{{#if this.showSingleMonth}}
<div class="chart-wrapper single-month-grid" data-test-running-total="single-month-stats">
<div class="chart-header has-bottom-margin-sm">
<h2 class="chart-title">Vault client counts</h2>
<p class="chart-description">
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity clients.
The total client count number is an important consideration for Vault billing.
</p>
</div>
<div class="single-month-stats" data-test-new>
<div class="single-month-section-title">
<StatText
@label="New clients"
@subText="This is the number of clients which were created in Vault for the first time in the selected month."
@value={{this.showSingleMonth.new.total}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{this.showSingleMonth.new.entityClients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{this.showSingleMonth.new.nonEntityClients}} @size="m" />
</div>
</div>
<div class="single-month-stats" data-test-total>
<div class="single-month-section-title">
<StatText
@label="Total monthly clients"
@subText="This is the number of total clients which used Vault for the given month, both new and previous."
@value={{this.showSingleMonth.total.total}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{this.showSingleMonth.total.entityClients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{this.showSingleMonth.total.nonEntityClients}} @size="m" />
</div>
</div>
</div>
{{else}}
{{#if (gt @byMonthActivityData.length 1)}}
<div class="chart-wrapper stacked-charts" data-test-running-total="monthly-charts">
<div class="single-chart-grid">
<div class="chart-header has-bottom-margin-xl">
@ -51,8 +9,8 @@
</p>
</div>
<div class={{concat (unless @lineChartData "chart-empty-state ") "chart-container-wide"}}>
<Clients::LineChart @dataset={{@lineChartData}} @upgradeData={{@upgradeData}} />
<div class={{concat (unless @byMonthActivityData "chart-empty-state ") "chart-container-wide"}}>
<Clients::LineChart @dataset={{@byMonthActivityData}} @upgradeData={{@upgradeData}} />
</div>
<div class="chart-subTitle">
@ -77,11 +35,11 @@
<div class="single-chart-grid">
<div class={{concat (unless this.hasAverageNewClients "chart-empty-state ") "chart-container-wide"}}>
<Clients::VerticalBarChart
@dataset={{if this.hasAverageNewClients @barChartData false}}
@dataset={{if this.hasAverageNewClients this.byMonthNewClients false}}
@chartLegend={{@chartLegend}}
@noDataTitle="No new clients"
@noDataMessage={{concat
"There are no new clients for this "
"There is no new client data available for this "
(if @selectedAuthMethod "auth method" "namespace")
" in this date range"
}}
@ -95,24 +53,26 @@
</p>
</div>
<div class="data-details-top" data-test-running-new-entity>
<h3 class="data-details">Average new entity clients per month</h3>
<p class="data-details">
{{format-number this.entityClientData.averageNewClients}}
</p>
</div>
{{#if this.hasAverageNewClients}}
<div class="data-details-top" data-test-running-new-entity>
<h3 class="data-details">Average new entity clients per month</h3>
<p class="data-details">
{{format-number this.entityClientData.averageNewClients}}
</p>
</div>
<div class="data-details-bottom" data-test-running-new-nonentity>
<h3 class="data-details">Average new non-entity clients per month</h3>
<p class="data-details">
{{format-number this.nonEntityClientData.averageNewClients}}
</p>
</div>
<div class="data-details-bottom" data-test-running-new-nonentity>
<h3 class="data-details">Average new non-entity clients per month</h3>
<p class="data-details">
{{format-number this.nonEntityClientData.averageNewClients}}
</p>
</div>
{{/if}}
<div class="timestamp" data-test-running-total-timestamp>
{{#if @timestamp}}
{{#if @responseTimestamp}}
Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}}
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>
@ -124,4 +84,61 @@
{{/if}}
</div>
</div>
{{else}}
{{#if (and @isHistoricalMonth this.singleMonthData.new_clients.clients)}}
<div class="chart-wrapper single-month-grid" data-test-running-total="single-month-stats">
<div class="chart-header has-bottom-margin-sm">
<h2 class="chart-title">Vault client counts</h2>
<p class="chart-description">
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity
clients. The total client count number is an important consideration for Vault billing.
</p>
</div>
<div class="single-month-stats" data-test-new>
<div class="single-month-section-title">
<StatText
@label="New clients"
@subText="This is the number of clients which were created in Vault for the first time in the selected month."
@value={{this.singleMonthData.new_clients.clients}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{this.singleMonthData.new_clients.entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{this.singleMonthData.new_clients.non_entity_clients}} @size="m" />
</div>
</div>
<div class="single-month-stats" data-test-total>
<div class="single-month-section-title">
<StatText
@label="Total monthly clients"
@subText="This is the number of total clients which used Vault for the given month, both new and previous."
@value={{this.singleMonthData.clients}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{this.singleMonthData.entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{this.singleMonthData.non_entity_clients}} @size="m" />
</div>
</div>
</div>
{{else}}
{{! This renders when either:
-> viewing the current month and all namespaces (no filters)
-> filtering by a namespace with no month over month data
if filtering by a mount with no month over month data <UsageStats> in dashboard.hbs renders }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within this namespace and all its children. {{if
@isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
}}"
@totalUsageCounts={{@runningTotals}}
/>
{{/if}}
{{/if}}

View File

@ -2,7 +2,7 @@
<div class="chart-header has-header-link has-bottom-margin-m">
<div class="header-left">
<h2 class="chart-title">{{@title}}</h2>
<p class="chart-description"> These totals are within this namespace and all its children. </p>
<p class="chart-description"> {{or @description "These totals are within this namespace and all its children."}}</p>
</div>
<div class="header-right">
<DocLink @path="/vault/tutorials/monitoring/usage-metrics">
@ -15,7 +15,7 @@
<div class="column">
<StatText
@label="Total clients"
@value={{or @totalUsageCounts.clients "0"}}
@value={{@totalUsageCounts.clients}}
@size="l"
@subText="The sum of entity clientIDs and non-entity clientIDs; this is Vaults primary billing metric."
data-test-stat-text="total-clients"
@ -25,7 +25,7 @@
<StatText
class="column"
@label="Entity clients"
@value={{or @totalUsageCounts.entity_clients "0"}}
@value={{@totalUsageCounts.entity_clients}}
@size="l"
@subText="Representations of a particular user, client, or application that created a token via login."
data-test-stat-text="entity-clients"
@ -35,7 +35,7 @@
<StatText
class="column"
@label="Non-entity clients"
@value={{or @totalUsageCounts.non_entity_clients "0"}}
@value={{@totalUsageCounts.non_entity_clients}}
@size="l"
@subText="Clients created with a shared set of permissions, but not associated with an entity. "
data-test-stat-text="non-entity-clients"

View File

@ -136,7 +136,7 @@
{{#if (and (has-permission "clients" routeParams="activity") (not @cluster.dr.isSecondary) this.auth.currentToken)}}
<ul class="menu-list">
<li class="action">
<LinkTo @route="vault.cluster.clients.current" {{on "click" @onLinkClick}}>
<LinkTo @route="vault.cluster.clients.dashboard" {{on "click" @onLinkClick}}>
<div class="level is-mobile">
<span class="level-left">Client count</span>
<Chevron class="has-text-grey-light level-right" />

View File

@ -1,61 +1,86 @@
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
data-test-popup-menu-trigger="month"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.startMonth "Month"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll" aria-label="months">
<ul data-test-month-list class="menu-list">
{{#each this.months as |month index|}}
<button
type="button"
class="button link"
disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}}
>
{{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="year"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.startYear "Year"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu" aria-label="years">
<ul data-test-year-list class="menu-list">
{{#each this.years as |year|}}
<button
type="button"
class="button link"
disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectStartYear year D.actions)}}
>
{{year}}
</button>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
<button
data-test-date-dropdown-submit
type="button"
class="button is-primary"
disabled={{if (and this.startMonth this.startYear) false true}}
{{on "click" this.saveDateSelection}}
>
{{or @submitText "Submit"}}
</button>
<div class="modal-card-custom has-padding-btm-left">
<BasicDropdown @class="popup-menu" @horizontalPosition="auto-right" @verticalPosition="below" as |D|>
<D.Trigger
data-test-popup-menu-trigger="month"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.selectedMonth.name "Month"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu scroll" aria-label="months">
<ul data-test-month-list class="menu-list">
{{#each this.dropdownMonths as |month|}}
<button
data-test-dropdown-month={{month.name}}
type="button"
class="button link"
disabled={{if (gt month.index this.maxMonthIdx) true false}}
{{on "click" (fn this.selectMonth month D.actions)}}
>
{{month.name}}
</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="year"
class={{concat "toolbar-link" (if D.isOpen " is-active")}}
@htmlTag="button"
>
{{or this.selectedYear "Year"}}
<Chevron @direction="down" @isButton={{true}} />
</D.Trigger>
<D.Content @defaultClass="popup-menu-content is-wide">
<nav class="box menu" aria-label="years">
<ul data-test-year-list class="menu-list">
{{#each this.dropdownYears as |year|}}
<button
data-test-dropdown-year={{year}}
type="button"
class="button link"
disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectYear year D.actions)}}
>
{{year}}
</button>
{{/each}}
</ul>
</nav>
</D.Content>
</BasicDropdown>
{{#unless @handleCancel}}
<button
data-test-date-dropdown-submit
type="button"
class="button is-primary"
disabled={{if (and this.selectedMonth this.selectedYear) false true}}
{{on "click" this.handleSubmit}}
>
{{or @submitText "Submit"}}
</button>
{{/unless}}
{{#if this.invalidDate}}
<AlertInline @type="danger" @message={{this.invalidDate}} @paddingTop={{true}} @mimicRefresh={{true}} />
{{/if}}
</div>
{{#if @handleCancel}}
<footer class="modal-card-foot modal-card-foot-outlined">
<button
data-test-date-dropdown-submit
type="button"
class="button is-primary"
disabled={{if (and this.selectedMonth this.selectedYear) false true}}
{{on "click" this.handleSubmit}}
>
{{or @submitText "Submit"}}
</button>
<button data-test-date-dropdown-cancel type="button" class="button is-secondary" {{on "click" this.handleCancel}}>
Cancel
</button>
</footer>
{{/if}}

View File

@ -78,11 +78,7 @@
)}}
<div class="navbar-sections">
<div class={{if (is-active-route "vault.cluster.clients") "is-active"}}>
<LinkTo
@route="vault.cluster.clients.history"
@current-when="vault.cluster.clients.history"
data-test-navbar-item="metrics"
>
<LinkTo @route="vault.cluster.clients.dashboard" data-test-navbar-item="metrics">
Client count
</LinkTo>
</div>

View File

@ -6,14 +6,11 @@
</p.levelLeft>
</PageHeader>
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless" data-test-pricing-metrics>
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
<nav class="tabs">
<ul>
<LinkTo @route="vault.cluster.clients.current" data-test-current-month>
Current month
</LinkTo>
<LinkTo @route="vault.cluster.clients.history" data-test-history>
History
<LinkTo @route="vault.cluster.clients.dashboard" data-test-dashboard>
Dashboard
</LinkTo>
{{#if (or @model.config.configPath.canRead @model.configPath.canRead)}}
<LinkTo @route="vault.cluster.clients.config">

View File

@ -1 +0,0 @@
<Clients::Current @model={{@model}} />

View File

@ -0,0 +1 @@
<Clients::Dashboard @model={{@model}} />

View File

@ -1 +0,0 @@
<Clients::History @model={{@model}} />

View File

@ -28,10 +28,13 @@ export function formatTooltipNumber(value) {
}
export function calculateAverage(dataset, objectKey) {
if (!Array.isArray(dataset) || dataset?.length === 0) return null;
// if an array of objects, objectKey of the integer we want to calculate, ex: 'entity_clients'
// if d[objectKey] is undefined there is no value, so return 0
const getIntegers = objectKey ? dataset?.map((d) => (d[objectKey] ? d[objectKey] : 0)) : dataset;
const checkIntegers = getIntegers.every((n) => Number.isInteger(n)); // decimals will be false
return checkIntegers ? Math.round(mean(getIntegers)) : null;
// before mapping for values, check that the objectKey exists at least once in the dataset because
// map returns 0 when dataset[objectKey] is undefined in order to calculate average
if (!Array.isArray(dataset) || !objectKey || !dataset.some((d) => Object.keys(d).includes(objectKey))) {
return null;
}
const integers = dataset.map((d) => (d[objectKey] ? d[objectKey] : 0));
const checkIntegers = integers.every((n) => Number.isInteger(n)); // decimals will be false
return checkIntegers ? Math.round(mean(integers)) : null;
}

View File

@ -7,5 +7,6 @@
{{#if @subText}}
<div class="stat-text">{{@subText}}</div>
{{/if}}
<div class="stat-value">{{format-number @value}}</div>
{{! Ember reads 0 as falsy, so only render a dash if the value is truly undefined}}
<div class="stat-value">{{if (or @value (eq @value 0)) (format-number @value) "-"}}</div>
</div>

View File

@ -1,21 +0,0 @@
/**
* @module StatText
* StatText components are used to display a label and associated value beneath, with the option to include a description.
*
* @example
* ```js
* <StatText @label="Active Clients" @value="4,198" @size="l" @subText="These are the active client counts"/>
* ```
* @param {string} label=null - The label for the statistic
* @param {number} value=null - Value passed in, usually a number or statistic
* @param {string} size=null - Sizing changes whether or not there is subtext. If there is subtext 's' and 'l' are valid sizes. If no subtext, then 'm' is also acceptable.
* @param {string} [subText] - SubText is optional and will display below the label
*/
import Component from '@glimmer/component';
import layout from '../templates/components/stat-text';
import { setComponentTemplate } from '@ember/component';
/* eslint ember/no-empty-glimmer-component-classes: 'warn' */
class StatTextComponent extends Component {}
export default setComponentTemplate(layout, StatTextComponent);

View File

@ -1,16 +1,11 @@
import { helper } from '@ember/component/helper';
import { format, parseISO } from 'date-fns';
export function dateFormat([date, style], { isFormatted = false, dateOnly = false }) {
export function dateFormat([date, style], { isFormatted = false, withTimeZone = false }) {
// see format breaking in upgrade to date-fns 2.x https://github.com/date-fns/date-fns/blob/master/CHANGELOG.md#changed-5
if (isFormatted) {
return format(new Date(date), style);
}
// when date is in '2021-09-01T00:00:00Z' format
// remove hours so date displays unaffected by timezone
if (dateOnly && typeof date === 'string') {
date = date.split('T')[0];
}
let number = typeof date === 'string' ? parseISO(date) : date;
if (!number) {
return;
@ -18,7 +13,15 @@ export function dateFormat([date, style], { isFormatted = false, dateOnly = fals
if (number.toString().length === 10) {
number = new Date(number * 1000);
}
return format(number, style);
let zone; // local timezone ex: 'PST'
try {
// passing undefined means default to the browser's locale
zone = ' ' + number.toLocaleTimeString(undefined, { timeZoneName: 'short' }).split(' ')[2];
} catch (e) {
zone = '';
}
zone = withTimeZone ? zone : '';
return format(number, style) + zone;
}
export default helper(dateFormat);

View File

@ -2,7 +2,7 @@ import { parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc } from 'date-fns';
export const formatByMonths = (monthsArray) => {
// the months array will always include a timestamp of the month and either new/total client data or counts = null
// the monthsArray will always include a timestamp of the month and either new/total client data or counts = null
if (!Array.isArray(monthsArray)) return monthsArray;
const sortedPayload = sortMonthsByTimestamp(monthsArray);
@ -15,11 +15,13 @@ export const formatByMonths = (monthsArray) => {
const newCounts = m.new_clients ? flattenDataset(m.new_clients) : {};
return {
month,
timestamp: m.timestamp,
...totalCounts,
namespaces: formatByNamespace(m.namespaces) || [],
namespaces_by_key: namespaceArrayToObject(totalClientsByNamespace, newClientsByNamespace, month),
new_clients: {
month,
timestamp: m.timestamp,
...newCounts,
namespaces: formatByNamespace(m.new_clients?.namespaces) || [],
},
@ -116,6 +118,7 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
clients,
entity_clients,
non_entity_clients,
mounts: newClientsByMount,
},
mounts: [...nestNewClientsWithinMounts],
};
@ -149,98 +152,103 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
};
});
return namespaces_by_key;
// structure of object returned
// namespace_by_key: {
// "namespace_label": {
// month: "3/22",
// clients: 32,
// entity_clients: 16,
// non_entity_clients: 16,
// new_clients: {
// month: "3/22",
// clients: 5,
// entity_clients: 2,
// non_entity_clients: 3,
// },
// mounts_by_key: {
// "mount_label": {
// month: "3/22",
// clients: 3,
// entity_clients: 2,
// non_entity_clients: 1,
// new_clients: {
// month: "3/22",
// clients: 5,
// entity_clients: 2,
// non_entity_clients: 3,
// },
// },
// },
// },
// };
/*
structure of object returned
namespace_by_key: {
"namespace_label": {
month: "3/22",
clients: 32,
entity_clients: 16,
non_entity_clients: 16,
new_clients: {
month: "3/22",
clients: 5,
entity_clients: 2,
non_entity_clients: 3,
mounts: [...array of this namespace's mounts and their new client counts],
},
mounts_by_key: {
"mount_label": {
month: "3/22",
clients: 3,
entity_clients: 2,
non_entity_clients: 1,
new_clients: {
month: "3/22",
clients: 5,
entity_clients: 2,
non_entity_clients: 3,
},
},
},
},
};
*/
};
// API RESPONSE STRUCTURE:
// data: {
// ** by_namespace organized in descending order of client count number **
// by_namespace: [
// {
// namespace_id: '96OwG',
// namespace_path: 'test-ns/',
// counts: {},
// mounts: [{ mount_path: 'path-1', counts: {} }],
// },
// ],
// ** months organized in ascending order of timestamps, oldest to most recent
// months: [
// {
// timestamp: '2022-03-01T00:00:00Z',
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// new_clients: {
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// },
// },
// {
// timestamp: '2022-04-01T00:00:00Z',
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// new_clients: {
// counts: {},
// namespaces: [
// {
// namespace_id: 'root',
// namespace_path: '',
// counts: {},
// mounts: [{ mount_path: 'auth/up2/', counts: {} }],
// },
// ],
// },
// },
// ],
// start_time: 'start timestamp string',
// end_time: 'end timestamp string',
// total: { clients: 300, non_entity_clients: 100, entity_clients: 400} ,
// }
/*
API RESPONSE STRUCTURE:
data: {
** by_namespace organized in descending order of client count number **
by_namespace: [
{
namespace_id: '96OwG',
namespace_path: 'test-ns/',
counts: {},
mounts: [{ mount_path: 'path-1', counts: {} }],
},
],
** months organized in ascending order of timestamps, oldest to most recent
months: [
{
timestamp: '2022-03-01T00:00:00Z',
counts: {},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
new_clients: {
counts: {},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
},
},
{
timestamp: '2022-04-01T00:00:00Z',
counts: {},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
new_clients: {
counts: {},
namespaces: [
{
namespace_id: 'root',
namespace_path: '',
counts: {},
mounts: [{ mount_path: 'auth/up2/', counts: {} }],
},
],
},
},
],
start_time: 'start timestamp string',
end_time: 'end timestamp string',
total: { clients: 300, non_entity_clients: 100, entity_clients: 400} ,
}
*/

View File

@ -23,17 +23,6 @@ export const parseAPITimestamp = (timestamp, style) => {
return format(date, style);
};
// convert ISO timestamp '2021-03-21T00:00:00Z' to ['2021', 2]
// (e.g. 2021 March, month is zero indexed) (used by calendar widget)
export const parseRFC3339 = (timestamp) => {
if (Array.isArray(timestamp)) {
// return if already formatted correctly
return timestamp;
}
const date = parseAPITimestamp(timestamp);
return date ? [`${date.getFullYear()}`, date.getMonth()] : null;
};
// convert M/yy (format of dates in charts) to 'Month yyyy' (format in tooltip)
export function formatChartDate(date) {
const array = date.split('/');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
// add all handlers here
// individual lookup done in mirage config
import base from './base';
import activity from './activity';
import clients from './clients';
import db from './db';
import kms from './kms';
@ -11,4 +10,4 @@ import oidcConfig from './oidc-config';
import hcpLink from './hcp-link';
import kubernetes from './kubernetes';
export { base, activity, clients, db, kms, mfaConfig, mfaLogin, oidcConfig, hcpLink, kubernetes };
export { base, clients, db, kms, mfaConfig, mfaLogin, oidcConfig, hcpLink, kubernetes };

View File

@ -147,7 +147,7 @@
"ember-inflector": "4.0.2",
"ember-load-initializers": "^2.1.2",
"ember-maybe-in-element": "^2.0.3",
"ember-modal-dialog": "4.0.0",
"ember-modal-dialog": "^4.0.1",
"ember-modifier": "^3.1.0",
"ember-page-title": "^7.0.0",
"ember-power-select": "6.0.1",

View File

@ -1,175 +0,0 @@
import { module, test } from 'qunit';
import { visit, currentURL, settled, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import ENV from 'vault/config/environment';
import authPage from 'vault/tests/pages/auth';
import { create } from 'ember-cli-page-object';
import { clickTrigger } from 'ember-power-select/test-support/helpers';
import ss from 'vault/tests/pages/components/search-select';
import { overrideResponse, SELECTORS } from '../helpers/clients';
const searchSelect = create(ss);
module('Acceptance | clients current tab', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
ENV['ember-cli-mirage'].handler = 'clients';
});
hooks.after(function () {
ENV['ember-cli-mirage'].handler = null;
});
hooks.beforeEach(function () {
return authPage.login();
});
test('shows empty state when config disabled, no data', async function (assert) {
assert.expect(3);
this.server.get('sys/internal/counters/config', () => {
return {
request_id: 'some-config-id',
data: {
default_report_months: 12,
enabled: 'default-disable',
retention_months: 24,
},
};
});
this.server.get('sys/internal/counters/activity/monthly', () => overrideResponse(204));
await visit('/vault/clients/current');
assert.strictEqual(currentURL(), '/vault/clients/current');
assert.dom(SELECTORS.currentMonthActiveTab).hasText('Current month', 'current month tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasText('Tracking is disabled');
});
test('shows empty state when config enabled, no data', async function (assert) {
assert.expect(3);
this.server.get('sys/internal/counters/activity/monthly', () => {
return {
request_id: 'some-monthly-id',
data: {
by_namespace: [],
clients: 0,
distinct_entities: 0,
entity_clients: 0,
months: [],
non_entity_clients: 0,
non_entity_tokens: 0,
},
};
});
await visit('/vault/clients/current');
assert.strictEqual(currentURL(), '/vault/clients/current');
assert.dom(SELECTORS.currentMonthActiveTab).hasText('Current month', 'current month tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasText('No data received');
});
test('filters correctly on current with full data', async function (assert) {
assert.expect(27);
await visit('/vault/clients/current');
assert.strictEqual(currentURL(), '/vault/clients/current');
assert.dom(SELECTORS.currentMonthActiveTab).hasText('Current month', 'current month tab is active');
assert.dom(SELECTORS.usageStats).exists('usage stats block exists');
assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist');
// TODO update with dynamic counts
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('175');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('132');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('43');
assert.dom('[data-test-clients-attribution]').exists('Shows attribution area');
assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
await settled();
// FILTER BY NAMESPACE
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('100');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('85');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('15');
assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
await settled();
// FILTER BY AUTH METHOD
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('35');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('20');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('15');
assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution block');
// Delete auth filter goes back to filtered only on namespace
await click('#auth-method-search-select [data-test-selected-list-button="delete"]');
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('100');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('85');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('15');
await settled();
assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom(SELECTORS.attributionBlock).exists('Still shows attribution block');
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
// Delete namespace filter with auth filter on
await click('#namespace-search-select-monthly [data-test-selected-list-button="delete"]');
// Goes back to no filters
// TODO update with dynamic counts
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('175');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('132');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('43');
assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
assert.dom('[data-test-chart-container="new-clients"] [data-test-empty-state-subtext]').doesNotExist();
});
test('filters correctly on current with no auth mounts', async function (assert) {
assert.expect(16);
await visit('/vault/clients/current');
assert.strictEqual(currentURL(), '/vault/clients/current');
assert.dom(SELECTORS.currentMonthActiveTab).hasText('Current month', 'current month tab is active');
assert.dom(SELECTORS.usageStats).exists('usage stats block exists');
assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist');
// TODO CMB update with dynamic data
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('175');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('132');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('43');
assert.dom('[data-test-clients-attribution]').exists('Shows attribution area');
assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
// Filter by namespace
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('100');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('85');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('15');
// TODO add month data without mounts
// assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution');
// assert.dom('#auth-method-search-select').doesNotExist('Auth method filter is not shown');
// Remove namespace filter
await click('#namespace-search-select-monthly [data-test-selected-list-button="delete"]');
// Goes back to no filters
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('175');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('132');
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('43');
assert.dom('[data-test-chart-container="new-clients"]').doesNotExist();
});
test('shows correct empty state when config off but no read on config', async function (assert) {
assert.expect(2);
this.server.get('sys/internal/counters/activity/monthly', () => overrideResponse(204));
this.server.get('sys/internal/counters/config', () => overrideResponse(403));
await visit('/vault/clients/current');
assert.dom(SELECTORS.filterBar).doesNotExist('Filter bar is not shown');
assert.dom(SELECTORS.emptyStateTitle).containsText('No data available', 'Shows no data empty state');
});
});

View File

@ -10,20 +10,20 @@ import { create } from 'ember-cli-page-object';
import ss from 'vault/tests/pages/components/search-select';
import { clickTrigger } from 'ember-power-select/test-support/helpers';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
import { formatNumber } from 'core/helpers/format-number';
const searchSelect = create(ss);
const NEW_DATE = new Date();
const LAST_MONTH = startOfMonth(subMonths(NEW_DATE, 1));
const COUNTS_START = subMonths(NEW_DATE, 12); // pretend vault user started cluster 1 year ago
const CURRENT_DATE = new Date();
const LAST_MONTH = startOfMonth(subMonths(CURRENT_DATE, 1));
const COUNTS_START = subMonths(CURRENT_DATE, 12); // pretend vault user started cluster 1 year ago
// for testing, we're in the middle of a license/billing period
const LICENSE_START = startOfMonth(subMonths(NEW_DATE, 6));
const LICENSE_START = startOfMonth(subMonths(CURRENT_DATE, 6));
// upgrade happened 1 month after license start
const UPGRADE_DATE = addMonths(LICENSE_START, 1);
module('Acceptance | clients history tab', function (hooks) {
module('Acceptance | client counts dashboard tab', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@ -31,6 +31,10 @@ module('Acceptance | clients history tab', function (hooks) {
ENV['ember-cli-mirage'].handler = 'clients';
});
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
});
hooks.after(function () {
ENV['ember-cli-mirage'].handler = null;
});
@ -39,30 +43,7 @@ module('Acceptance | clients history tab', function (hooks) {
return authPage.login();
});
test('shows warning when config off, no data, queries_available=true', async function (assert) {
assert.expect(6);
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
this.server.get('sys/internal/counters/config', () => {
return {
request_id: 'some-config-id',
data: {
default_report_months: 12,
enabled: 'default-disable',
queries_available: true,
retention_months: 24,
},
};
});
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history');
assert.dom(SELECTORS.historyActiveTab).hasText('History', 'history tab is active');
assert.dom('[data-test-tracking-disabled] .message-title').hasText('Tracking is disabled');
assert.dom(SELECTORS.emptyStateTitle).hasText('No data received');
assert.dom(SELECTORS.filterBar).doesNotExist('Shows filter bar to search previous dates');
assert.dom(SELECTORS.usageStats).doesNotExist('No usage stats');
});
test('shows warning when config off, no data, queries_available=false', async function (assert) {
test('shows warning when config off, no data', async function (assert) {
assert.expect(4);
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
this.server.get('sys/internal/counters/config', () => {
@ -76,14 +57,14 @@ module('Acceptance | clients history tab', function (hooks) {
},
};
});
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history');
assert.dom(SELECTORS.historyActiveTab).hasText('History', 'history tab is active');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasText('Data tracking is disabled');
assert.dom(SELECTORS.filterBar).doesNotExist('Filter bar is hidden when no data available');
});
test('shows empty state when config enabled and queries_available=false', async function (assert) {
test('shows empty state when config enabled and no data', async function (assert) {
assert.expect(4);
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
this.server.get('sys/internal/counters/config', () => {
@ -92,31 +73,28 @@ module('Acceptance | clients history tab', function (hooks) {
data: {
default_report_months: 12,
enabled: 'default-enable',
queries_available: false,
retention_months: 24,
},
};
});
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history');
assert.dom(SELECTORS.historyActiveTab).hasText('History', 'history tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasText('No monthly history');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasTextContaining('No data received');
assert.dom(SELECTORS.filterBar).doesNotExist('Does not show filter bar');
});
test('visiting history tab config on and data with mounts', async function (assert) {
test('visiting dashboard tab config on and data with mounts', async function (assert) {
assert.expect(8);
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
assert
.dom(SELECTORS.dateDisplay)
.hasText(format(LICENSE_START, 'MMMM yyyy'), 'billing start month is correctly parsed from license');
assert
.dom(SELECTORS.rangeDropdown)
.hasText(
`${format(LICENSE_START, 'MMMM yyyy')} - ${format(LAST_MONTH, 'MMMM yyyy')}`,
`${format(LICENSE_START, 'MMM yyyy')} - ${format(CURRENT_DATE, 'MMM yyyy')}`,
'Date range shows dates correctly parsed activity response'
);
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
@ -129,18 +107,15 @@ module('Acceptance | clients history tab', function (hooks) {
.hasText(`${format(LICENSE_START, 'M/yy')}`, 'x-axis labels start with billing start date');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
5,
`line chart plots 5 points to match query`
6,
`line chart plots 6 points to match query`
);
});
test('updates correctly when querying date ranges', async function (assert) {
assert.expect(26);
// TODO CMB: wire up dynamically generated activity to mirage clients handler
// const activity = generateActivityResponse(5, LICENSE_START, LAST_MONTH);
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
// query for single, historical month with no new counts
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
@ -148,7 +123,6 @@ module('Acceptance | clients history tab', function (hooks) {
await click('[data-test-previous-year]');
}
await click(find(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`));
assert.dom('[data-test-usage-stats]').exists('total usage stats show');
assert
.dom(SELECTORS.runningTotalMonthStats)
@ -173,11 +147,10 @@ module('Acceptance | clients history tab', function (hooks) {
// change billing start to month/year of first upgrade
await click('[data-test-start-date-editor] button');
await click(SELECTORS.monthDropdown);
await click(find(`[data-test-date-modal-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`));
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(find(`[data-test-date-modal-year="${UPGRADE_DATE.getFullYear()}`));
await click('[data-test-modal-save]');
await click(`[data-test-dropdown-year="${UPGRADE_DATE.getFullYear()}"]`);
await click('[data-test-date-dropdown-submit]');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
assert
@ -188,12 +161,12 @@ module('Acceptance | clients history tab', function (hooks) {
.hasText(`${format(UPGRADE_DATE, 'M/yy')}`, 'x-axis labels start with updated billing start month');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
5,
`line chart plots 5 points to match query`
6,
`line chart plots 6 points to match query`
);
// query custom end month
const customEndDate = subMonths(NEW_DATE, 3);
// query three months ago
const customEndDate = subMonths(CURRENT_DATE, 3);
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
if (parseInt(find('[data-test-display-year]').innerText) !== customEndDate.getFullYear()) {
@ -216,7 +189,7 @@ module('Acceptance | clients history tab', function (hooks) {
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText(`${format(subMonths(LAST_MONTH, 2), 'M/yy')}`, 'x-axis labels end with queried end month');
// query for single, historical month
// query for single, historical month (upgrade month)
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
if (parseInt(find('[data-test-display-year]').innerText) !== UPGRADE_DATE.getFullYear()) {
@ -236,13 +209,13 @@ module('Acceptance | clients history tab', function (hooks) {
// reset to billing period
await click('[data-test-popup-menu-trigger]');
await click('[data-test-current-billing-period]');
// query month older than count start date
await click('[data-test-start-date-editor] button');
await click(SELECTORS.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(find(`[data-test-date-modal-year="${LICENSE_START.getFullYear() - 3}`));
await click('[data-test-modal-save]');
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
await click('[data-test-date-dropdown-submit]');
assert
.dom('[data-test-alert-banner="alert"]')
.hasTextContaining(
@ -251,53 +224,83 @@ module('Acceptance | clients history tab', function (hooks) {
);
});
test('filters correctly on history with full data', async function (assert) {
assert.expect(19);
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history', 'clients/history URL is correct');
assert.dom(SELECTORS.historyActiveTab).hasText('History', 'history tab is active');
test('dashboard filters correctly with full data', async function (assert) {
assert.expect(21);
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.exists('Shows running totals with monthly breakdown charts');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
// FILTER BY NAMESPACE
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
const topNamespace = response.byNamespace[0];
const topMount = topNamespace.mounts[0];
assert.ok(true, 'Filter by first namespace');
assert.strictEqual(
find(SELECTORS.selectedNs).innerText.toLowerCase(),
topNamespace.label,
'selects top namespace'
);
assert.dom('[data-test-top-attribution]').includesText('Top auth method');
assert.dom('[data-test-running-total-entity]').includesText('23,326', 'total entity clients is accurate');
assert
.dom('[data-test-running-total-nonentity]')
.includesText('17,826', 'total non-entity clients is accurate');
assert.dom('[data-test-attribution-clients]').includesText('10,142', 'top attribution clients accurate');
.dom('[data-test-running-total-entity] p')
.includesText(`${formatNumber([topNamespace.entity_clients])}`, 'total entity clients is accurate');
assert
.dom('[data-test-running-total-nonentity] p')
.includesText(
`${formatNumber([topNamespace.non_entity_clients])}`,
'total non-entity clients is accurate'
);
assert
.dom('[data-test-attribution-clients] p')
.includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate');
// FILTER BY AUTH METHOD
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
assert.ok(true, 'Filter by first auth method');
assert.dom('[data-test-running-total-entity]').includesText('6,508', 'total entity clients is accurate');
assert.strictEqual(
find(SELECTORS.selectedAuthMount).innerText.toLowerCase(),
topMount.label,
'selects top mount'
);
assert
.dom('[data-test-running-total-nonentity]')
.includesText('3,634', 'total non-entity clients is accurate');
.dom('[data-test-running-total-entity] p')
.includesText(`${formatNumber([topMount.entity_clients])}`, 'total entity clients is accurate');
assert
.dom('[data-test-running-total-nonentity] p')
.includesText(`${formatNumber([topMount.non_entity_clients])}`, 'total non-entity clients is accurate');
assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution block');
await click('#namespace-search-select [data-test-selected-list-button="delete"]');
assert.ok(true, 'Remove namespace filter without first removing auth method filter');
assert.dom('[data-test-top-attribution]').includesText('Top namespace');
assert
.dom('[data-test-attribution-clients]')
.hasTextContaining('41,152', 'top attribution clients back to unfiltered value');
assert
.dom('[data-test-running-total-entity]')
.hasTextContaining('98,289', 'total entity clients is back to unfiltered value');
.hasTextContaining(
`${formatNumber([response.total.entity_clients])}`,
'total entity clients is back to unfiltered value'
);
assert
.dom('[data-test-running-total-nonentity]')
.hasTextContaining('96,412', 'total non-entity clients is back to unfiltered value');
.hasTextContaining(
`${formatNumber([formatNumber([response.total.non_entity_clients])])}`,
'total non-entity clients is back to unfiltered value'
);
assert
.dom('[data-test-attribution-clients]')
.hasTextContaining(
`${formatNumber([topNamespace.clients])}`,
'top attribution clients back to unfiltered value'
);
});
test('shows warning if upgrade happened within license period', async function (assert) {
@ -327,22 +330,23 @@ module('Acceptance | clients history tab', function (hooks) {
},
};
});
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history', 'clients/history URL is correct');
assert.dom(SELECTORS.historyActiveTab).hasText('History', 'history tab is active');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert
.dom('[data-test-alert-banner="alert"]')
.hasTextContaining(
`Warning Vault was upgraded to 1.10.1 on ${format(
UPGRADE_DATE,
'MMM d, yyyy'
)}. We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data below. Learn more here.`
)}. We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data. Learn more here.`
);
});
test('Shows empty if license start date is current month', async function (assert) {
const licenseStart = NEW_DATE;
const licenseEnd = addMonths(NEW_DATE, 12);
// TODO cmb update to reflect new behavior
const licenseStart = CURRENT_DATE;
const licenseEnd = addMonths(CURRENT_DATE, 12);
this.server.get('sys/license/status', function () {
return {
request_id: 'my-license-request-id',
@ -355,21 +359,16 @@ module('Acceptance | clients history tab', function (hooks) {
},
};
});
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history', 'clients/history URL is correct');
assert.dom(SELECTORS.emptyStateTitle).hasText('No data for this billing period');
assert
.dom(SELECTORS.dateDisplay)
.hasText(format(licenseStart, 'MMMM yyyy'), 'Shows license date, gives ability to edit');
assert.dom(SELECTORS.monthDropdown).exists('Dropdown exists to select month');
assert.dom(SELECTORS.yearDropdown).exists('Dropdown exists to select year');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.emptyStateTitle).doesNotExist('No data for this billing period');
});
test('shows correct interface if no permissions on license', async function (assert) {
this.server.get('/sys/license/status', () => overrideResponse(403));
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history', 'clients/history URL is correct');
assert.dom(SELECTORS.historyActiveTab).hasText('History', 'history tab is active');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
// Message changes depending on ent or OSS
assert.dom(SELECTORS.emptyStateTitle).exists('Empty state exists');
assert.dom(SELECTORS.monthDropdown).exists('Dropdown exists to select month');
@ -382,8 +381,8 @@ module('Acceptance | clients history tab', function (hooks) {
this.server.get('sys/internal/counters/config', () => overrideResponse(403));
this.server.get('sys/internal/counters/activity', () => overrideResponse(403));
await visit('/vault/clients/history');
assert.strictEqual(currentURL(), '/vault/clients/history', 'clients/history URL is correct');
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert
.dom(SELECTORS.emptyStateTitle)
.includesText('start date found', 'Empty state shows no billing start date');

View File

@ -1,4 +1,3 @@
import { addMonths, differenceInCalendarMonths, formatRFC3339, startOfMonth } from 'date-fns';
import { Response } from 'miragejs';
/** Scenarios
@ -18,8 +17,7 @@ import { Response } from 'miragejs';
License start date this month
*/
export const SELECTORS = {
currentMonthActiveTab: '.active[data-test-current-month]',
historyActiveTab: '.active[data-test-history]',
dashboardActiveTab: '.active[data-test-dashboard]',
emptyStateTitle: '[data-test-empty-state-title]',
usageStats: '[data-test-usage-stats]',
dateDisplay: '[data-test-date-display]',
@ -32,6 +30,8 @@ export const SELECTORS = {
runningTotalMonthStats: '[data-test-running-total="single-month-stats"]',
runningTotalMonthlyCharts: '[data-test-running-total="monthly-charts"]',
monthlyUsageBlock: '[data-test-monthly-usage]',
selectedAuthMount: 'div#auth-method-search-select [data-test-selected-option] div',
selectedNs: 'div#namespace-search-select [data-test-selected-option] div',
};
export const CHART_ELEMENTS = {
@ -74,187 +74,3 @@ export function overrideResponse(httpStatus, data) {
}
return new Response(200, { 'Content-Type': 'application/json' }, JSON.stringify(data));
}
function generateNamespaceBlock(idx = 0, skipMounts = false) {
let mountCount = 1;
const nsBlock = {
namespace_id: `${idx}UUID`,
namespace_path: `${idx}/namespace`,
counts: {
clients: mountCount * 15,
entity_clients: mountCount * 5,
non_entity_clients: mountCount * 10,
distinct_entities: mountCount * 5,
non_entity_tokens: mountCount * 10,
},
};
if (!skipMounts) {
mountCount = Math.floor((Math.random() + idx) * 20);
const mounts = [];
Array.from(Array(mountCount)).forEach((v, index) => {
mounts.push({
mount_path: `auth/authid${index}`,
counts: {
clients: 5,
entity_clients: 3,
non_entity_clients: 2,
distinct_entities: 3,
non_entity_tokens: 2,
},
});
});
nsBlock.mounts = mounts;
}
return nsBlock;
}
function generateCounts(max, arrayLength) {
function randomBetween(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
var result = [];
var sum = 0;
for (var i = 0; i < arrayLength - 1; i++) {
result[i] = randomBetween(1, max - (arrayLength - i - 1) - sum);
sum += result[i];
}
result[arrayLength - 1] = max - sum;
return result.sort((a, b) => b - a);
}
function generateMonths(startDate, endDate, hasNoData = false) {
const numberOfMonths = differenceInCalendarMonths(endDate, startDate) + 1;
const months = [];
for (let i = 0; i < numberOfMonths; i++) {
if (hasNoData) {
months.push({
timestamp: formatRFC3339(startOfMonth(addMonths(startDate, i))),
counts: null,
namespace: null,
new_clients: null,
});
continue;
}
const namespaces = Array.from(Array(5)).map((v, idx) => {
return generateNamespaceBlock(idx);
});
const clients = numberOfMonths * 5 + i * 5;
const [entity_clients, non_entity_clients] = generateCounts(clients, 2);
const counts = {
clients,
entity_clients,
non_entity_clients,
distinct_entities: entity_clients,
non_entity_tokens: non_entity_clients,
};
const new_counts = 5 + i;
const [new_entity, new_non_entity] = generateCounts(new_counts, 2);
months.push({
timestamp: formatRFC3339(startOfMonth(addMonths(startDate, i))),
counts,
namespaces,
new_clients: {
counts: {
distinct_entities: new_entity,
entity_clients: new_entity,
non_entity_tokens: new_non_entity,
non_entity_clients: new_non_entity,
clients: new_counts,
},
namespaces,
},
});
}
return months;
}
export function generateActivityResponse(nsCount = 1, startDate, endDate) {
if (nsCount === 0) {
return {
request_id: 'some-activity-id',
data: {
start_time: formatRFC3339(startDate),
end_time: formatRFC3339(endDate),
total: {
clients: 0,
entity_clients: 0,
non_entity_clients: 0,
},
by_namespace: [
{
namespace_id: `root`,
namespace_path: '',
counts: {
entity_clients: 0,
non_entity_clients: 0,
clients: 0,
},
},
],
months: generateMonths(startDate, endDate, false),
},
};
}
const namespaces = Array.from(Array(nsCount)).map((v, idx) => {
return generateNamespaceBlock(idx);
});
return {
request_id: 'some-activity-id',
data: {
start_time: formatRFC3339(startDate),
end_time: formatRFC3339(endDate),
total: {
clients: 999,
entity_clients: 666,
non_entity_clients: 333,
},
by_namespace: namespaces,
months: generateMonths(startDate, endDate),
},
};
}
export function generateCurrentMonthResponse(namespaceCount, skipMounts = false, configEnabled = true) {
if (!configEnabled) {
return {
data: { id: 'no-data' },
};
}
if (!namespaceCount) {
return {
request_id: 'monthly-response-id',
data: {
by_namespace: [],
clients: 0,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
months: [],
},
};
}
// generate by_namespace data
const by_namespace = Array.from(Array(namespaceCount)).map((ns, idx) =>
generateNamespaceBlock(idx, skipMounts)
);
const counts = by_namespace.reduce(
(prev, curr) => {
return {
clients: prev.clients + curr.counts.clients,
entity_clients: prev.entity_clients + curr.counts.entity_clients,
non_entity_clients: prev.non_entity_clients + curr.counts.non_entity_clients,
};
},
{ clients: 0, entity_clients: 0, non_entity_clients: 0 }
);
return {
request_id: 'monthly-response-id',
data: {
by_namespace,
...counts,
months: [],
},
};
}

View File

@ -1,140 +1,321 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { render, click, findAll, find } from '@ember/test-helpers';
import sinon from 'sinon';
import hbs from 'htmlbars-inline-precompile';
import calendarDropdown from 'vault/tests/pages/components/calendar-widget';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
import { subYears } from 'date-fns';
import { subMonths, subYears } from 'date-fns';
import format from 'date-fns/format';
module('Integration | Component | calendar-widget', function (hooks) {
setupRenderingTest(hooks);
const isDisplayingSameYear = (comparisonDate, calendarYear) => {
return comparisonDate.getFullYear() === parseInt(calendarYear);
};
hooks.beforeEach(function () {
const CURRENT_YEAR = new Date().getFullYear();
const PREVIOUS_YEAR = subYears(new Date(), 1).getFullYear();
this.set('currentYear', CURRENT_YEAR);
this.set('previousYear', PREVIOUS_YEAR);
const CURRENT_DATE = new Date();
this.set('currentDate', CURRENT_DATE);
this.set('calendarStartDate', subMonths(CURRENT_DATE, 12));
this.set('calendarEndDate', CURRENT_DATE);
this.set('startTimestamp', subMonths(CURRENT_DATE, 12).toISOString());
this.set('endTimestamp', CURRENT_DATE.toISOString());
this.set('handleClientActivityQuery', sinon.spy());
this.set('handleCurrentBillingPeriod', sinon.spy());
this.set('arrayOfMonths', ARRAY_OF_MONTHS);
this.set('endTimeFromResponse', [CURRENT_YEAR, 0]);
});
test('it renders and can open the calendar view', async function (assert) {
test('it renders and disables correct months when start date is 12 months ago', async function (assert) {
assert.expect(14);
await render(hbs`
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{concat "January " this.currentYear}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay={{concat "February " this.previousYear}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
assert.dom(calendarDropdown.dateRangeTrigger).hasText(
`${format(this.calendarStartDate, 'MMM yyyy')} -
${format(this.calendarEndDate, 'MMM yyyy')}`,
'renders and formats start and end dates'
);
await calendarDropdown.openCalendar();
assert.ok(calendarDropdown.showsCalendar, 'renders the calendar component');
// assert months in current year are disabled/enabled correctly
const monthButtons = findAll('[data-test-calendar-month]');
const enabledMonths = [],
disabledMonths = [];
for (let monthIdx = 0; monthIdx < 12; monthIdx++) {
if (monthIdx > this.calendarEndDate.getMonth()) {
disabledMonths.push(monthButtons[monthIdx]);
} else {
enabledMonths.push(monthButtons[monthIdx]);
}
}
enabledMonths.forEach((btn) => {
assert
.dom(btn)
.doesNotHaveClass(
'is-readOnly',
`${ARRAY_OF_MONTHS[btn.id] + this.calendarEndDate.getFullYear()} is enabled`
);
});
disabledMonths.forEach((btn) => {
assert
.dom(btn)
.hasClass(
'is-readOnly',
`${ARRAY_OF_MONTHS[btn.id] + this.calendarEndDate.getFullYear()} is read only`
);
});
});
test('it does not allow a user to click to a future year but does allow a user to click to previous year', async function (assert) {
test('it renders and disables months before start timestamp', async function (assert) {
await render(hbs`
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{concat "March " this.currentYear}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay={{concat "February " this.previousYear}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
await calendarDropdown.openCalendar();
assert.dom('[data-test-future-year]').isDisabled('Future year is disabled');
assert.dom('[data-test-next-year]').isDisabled('Future year is disabled');
await calendarDropdown.clickPreviousYear();
assert.dom('[data-test-display-year]').hasText(this.previousYear.toString(), 'shows the previous year');
assert
.dom('[data-test-calendar-month="January"]')
.hasClass(
'is-readOnly',
`January ${this.previousYear} is disabled because it comes before startTimeDisplay`
);
.dom('[data-test-display-year]')
.hasText(`${subYears(this.currentDate, 1).getFullYear()}`, 'shows the previous year');
assert.dom('[data-test-previous-year]').isDisabled('disables previous year');
// assert months in previous year are disabled/enabled correctly
const monthButtons = findAll('[data-test-calendar-month]');
const enabledMonths = [],
disabledMonths = [];
for (let monthIdx = 0; monthIdx < 12; monthIdx++) {
if (monthIdx < this.calendarStartDate.getMonth()) {
disabledMonths.push(monthButtons[monthIdx]);
} else {
enabledMonths.push(monthButtons[monthIdx]);
}
}
disabledMonths.forEach((btn) => {
assert
.dom(btn)
.hasClass(
'is-readOnly',
`${ARRAY_OF_MONTHS[btn.id] + this.calendarEndDate.getFullYear()} is read only`
);
});
enabledMonths.forEach((btn) => {
assert
.dom(btn)
.doesNotHaveClass(
'is-readOnly',
`${ARRAY_OF_MONTHS[btn.id] + this.calendarEndDate.getFullYear()} is enabled`
);
});
});
test('it disables the current month', async function (assert) {
test('it calls parent callback with correct arg when clicking "Current billing period"', async function (assert) {
await render(hbs`
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{concat "January " this.currentYear}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay={{concat "February " this.previousYear}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
await calendarDropdown.openCalendar();
const month = this.arrayOfMonths[new Date().getMonth()];
assert
.dom(`[data-test-calendar-month="${month}"]`)
.hasClass('is-readOnly', `${month} ${this.currentYear} is disabled`);
// The component also disables all months after the current one, but this
// is tricky to test since it's based on browser time, so the behavior
// would be different in december than other months
});
test('it allows you to reset the billing period', async function (assert) {
await render(hbs`
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{concat "January " this.currentYear}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay={{concat "February " this.previousYear}}
/>
`);
await calendarDropdown.menuToggle();
await calendarDropdown.clickCurrentBillingPeriod();
assert.ok(this.handleCurrentBillingPeriod.calledOnce, 'it calls the parents handleCurrentBillingPeriod');
});
test('it passes the appropriate data to the handleCurrentBillingPeriod when a date is selected', async function (assert) {
await render(hbs`
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{concat "January " this.currentYear}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay={{concat "February " this.previousYear}}
/>
`);
await calendarDropdown.openCalendar();
await calendarDropdown.clickPreviousYear();
await click('[data-test-calendar-month="October"]'); // select endTime of October 2021
assert.ok(this.handleClientActivityQuery.calledOnce, 'it calls the parents handleClientActivityQuery');
assert.ok(
this.handleClientActivityQuery.calledWith(9, this.previousYear, 'endTime'),
'Passes the month as an index, year and date type to the parent'
assert.propEqual(
this.handleClientActivityQuery.args[0][0],
{ dateType: 'reset' },
'it calls parent function with reset dateType'
);
});
test('it displays the year from endTimeDisplay when opened', async function (assert) {
this.set('endTimeFromResponse', [this.previousYear, 11]);
test('it calls parent callback with correct arg when clicking "Current month"', async function (assert) {
await render(hbs`
<CalendarWidget
@arrayOfMonths={{this.arrayOfMonths}}
@endTimeDisplay={{concat "December " this.previousYear}}
@endTimeFromResponse={{this.endTimeFromResponse}}
@handleClientActivityQuery={{this.handleClientActivityQuery}}
@handleCurrentBillingPeriod={{this.handleCurrentBillingPeriod}}
@startTimeDisplay="March 2020"
/>
`);
<CalendarWidget
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
await calendarDropdown.menuToggle();
await calendarDropdown.clickCurrentMonth();
assert.propEqual(
this.handleClientActivityQuery.args[0][0],
{ dateType: 'currentMonth' },
'it calls parent function with currentMoth dateType'
);
});
test('it calls parent callback with correct arg when selecting a month', async function (assert) {
await render(hbs`
<CalendarWidget
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
await calendarDropdown.openCalendar();
assert
.dom('[data-test-display-year]')
.hasText(this.previousYear.toString(), 'Shows year from the end response');
await click(`[data-test-calendar-month="${ARRAY_OF_MONTHS[this.calendarEndDate.getMonth()]}"]`);
assert.propEqual(
this.handleClientActivityQuery.lastCall.lastArg,
{
dateType: 'endDate',
monthIdx: this.currentDate.getMonth(),
monthName: ARRAY_OF_MONTHS[this.currentDate.getMonth()],
year: this.currentDate.getFullYear(),
},
'it calls parent function with end date (current) month/year'
);
await calendarDropdown.openCalendar();
await calendarDropdown.clickPreviousYear();
await click(`[data-test-calendar-month="${ARRAY_OF_MONTHS[this.calendarStartDate.getMonth()]}"]`);
assert.propEqual(
this.handleClientActivityQuery.lastCall.lastArg,
{
dateType: 'endDate',
monthIdx: this.currentDate.getMonth(),
monthName: ARRAY_OF_MONTHS[this.currentDate.getMonth()],
year: this.currentDate.getFullYear() - 1,
},
'it calls parent function with start date month/year'
);
});
test('it disables correct months when start date 6 months ago', async function (assert) {
this.set('calendarStartDate', subMonths(this.currentDate, 6));
this.set('startTimestamp', subMonths(this.currentDate, 6).toISOString());
await render(hbs`
<CalendarWidget
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
await calendarDropdown.openCalendar();
assert.dom('[data-test-next-year]').isDisabled('Future year is disabled');
const displayYear = find('[data-test-display-year]').innerText;
const isRangeSameYear = isDisplayingSameYear(this.calendarStartDate, displayYear);
// only click previous year if 6 months ago was last year
if (!isRangeSameYear) {
await calendarDropdown.clickPreviousYear();
}
assert.dom('[data-test-previous-year]').isDisabled('previous year is disabled');
// DOM calendar is viewing start date year
findAll('[data-test-calendar-month]').forEach((m) => {
// months before start month should always be disabled
if (m.id < this.calendarStartDate.getMonth()) {
assert.dom(m).hasClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is read only`);
}
// if start/end dates are in the same year, DOM is also showing end date
if (isRangeSameYear) {
// months after end date should be disabled
if (m.id > this.calendarEndDate.getMonth()) {
assert.dom(m).hasClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is read only`);
}
// months between including start/end month should be enabled
if (m.id >= this.calendarStartDate.getMonth() && m.id <= this.calendarEndDate.getMonth()) {
assert.dom(m).doesNotHaveClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is enabled`);
}
}
});
// click back to current year if duration spans multiple years
if (!isRangeSameYear) {
await click('[data-test-next-year]');
findAll('[data-test-calendar-month]').forEach((m) => {
// DOM is no longer showing start month, all months before current date should be enabled
if (m.id <= this.currentDate.getMonth()) {
assert.dom(m).doesNotHaveClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is enabled`);
}
// future months should be disabled
if (m.id > this.currentDate.getMonth()) {
assert.dom(m).hasClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is read only`);
}
});
}
});
test('it disables correct months when start date 36 months ago', async function (assert) {
this.set('calendarStartDate', subMonths(this.currentDate, 36));
this.set('startTimestamp', subMonths(this.currentDate, 36).toISOString());
await render(hbs`
<CalendarWidget
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
`);
await calendarDropdown.openCalendar();
assert.dom('[data-test-next-year]').isDisabled('Future year is disabled');
let displayYear = find('[data-test-display-year]').innerText;
while (!isDisplayingSameYear(this.calendarStartDate, displayYear)) {
await calendarDropdown.clickPreviousYear();
displayYear = find('[data-test-display-year]').innerText;
}
assert.dom('[data-test-previous-year]').isDisabled('previous year is disabled');
assert.dom('[data-test-next-year]').isEnabled('next year is enabled');
// DOM calendar is viewing start date year (3 years ago)
findAll('[data-test-calendar-month]').forEach((m) => {
// months before start month should always be disabled
if (m.id < this.calendarStartDate.getMonth()) {
assert.dom(m).hasClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is read only`);
}
if (m.id >= this.calendarStartDate.getMonth()) {
assert.dom(m).doesNotHaveClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is enabled`);
}
});
await click('[data-test-next-year]');
displayYear = await find('[data-test-display-year]').innerText;
if (!isDisplayingSameYear(this.currentDate, displayYear)) {
await findAll('[data-test-calendar-month]').forEach((m) => {
// years between should have all months enabled
assert.dom(m).doesNotHaveClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is enabled`);
});
}
await click('[data-test-next-year]');
displayYear = await find('[data-test-display-year]').innerText;
if (!isDisplayingSameYear(this.currentDate, displayYear)) {
await findAll('[data-test-calendar-month]').forEach((m) => {
// years between should have all months enabled
assert.dom(m).doesNotHaveClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is enabled`);
});
}
await click('[data-test-next-year]');
displayYear = await find('[data-test-display-year]').innerText;
// now DOM is showing current year
assert.dom('[data-test-next-year]').isDisabled('Future year is disabled');
if (isDisplayingSameYear(this.currentDate, displayYear)) {
findAll('[data-test-calendar-month]').forEach((m) => {
// all months before current month should be enabled
if (m.id <= this.currentDate.getMonth()) {
assert.dom(m).doesNotHaveClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is enabled`);
}
// future months should be disabled
if (m.id > this.currentDate.getMonth()) {
assert.dom(m).hasClass('is-readOnly', `${ARRAY_OF_MONTHS[m.id] + displayYear} is read only`);
}
});
}
});
});

View File

@ -2,16 +2,17 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { formatRFC3339 } from 'date-fns';
import { endOfMonth, formatRFC3339 } from 'date-fns';
import { click } from '@ember/test-helpers';
import subMonths from 'date-fns/subMonths';
module('Integration | Component | clients/attribution', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set('startTimestamp', formatRFC3339(subMonths(new Date(), 6)));
this.set('timestamp', formatRFC3339(new Date()));
this.set('selectedNamespace', null);
this.set('isDateRange', true);
this.set('chartLegend', [
{ label: 'entity clients', key: 'entity_clients' },
{ label: 'non-entity clients', key: 'non_entity_clients' },
@ -48,9 +49,11 @@ module('Integration | Component | clients/attribution', function (hooks) {
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.timestamp}}
@selectedNamespace={{this.selectedNamespace}}
@isDateRange={{this.isDateRange}}
@isHistoricalMonth={{false}}
/>
`);
@ -71,17 +74,20 @@ module('Integration | Component | clients/attribution', function (hooks) {
assert.dom('[data-test-attribution-clients]').includesText('namespace').includesText('10');
});
test('it renders correct text for a single month', async function (assert) {
this.set('isDateRange', false);
test('it renders two charts and correct text for single, historical month', async function (assert) {
this.start = formatRFC3339(subMonths(new Date(), 1));
this.end = formatRFC3339(subMonths(endOfMonth(new Date()), 1));
await render(hbs`
<div id="modal-wormhole"></div>
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
@startTimestamp={{this.start}}
@endTimestamp={{this.end}}
@selectedNamespace={{this.selectedNamespace}}
@isDateRange={{this.isDateRange}}
@isHistoricalMonth={{true}}
/>
`);
assert
@ -124,6 +130,51 @@ module('Integration | Component | clients/attribution', function (hooks) {
);
});
test('it renders single chart for current month', async function (assert) {
await render(hbs`
<div id="modal-wormhole"></div>
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@startTimestamp={{this.timestamp}}
@endTimestamp={{this.timestamp}}
@selectedNamespace={{this.selectedNamespace}}
@isHistoricalMonth={{false}}
/>
`);
assert
.dom('[data-test-chart-container="single-chart"]')
.exists('renders single chart with total clients');
assert
.dom('[data-test-attribution-subtext]')
.hasTextContaining('this month', 'renders total monthly namespace text');
});
test('it renders single chart and correct text for for date range', async function (assert) {
await render(hbs`
<div id="modal-wormhole"></div>
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.timestamp}}
@selectedNamespace={{this.selectedNamespace}}
@isHistoricalMonth={{false}}
/>
`);
assert
.dom('[data-test-chart-container="single-chart"]')
.exists('renders single chart with total clients');
assert
.dom('[data-test-attribution-subtext]')
.hasTextContaining('date range', 'renders total monthly namespace text');
});
test('it renders with data for selected namespace auth methods for a date range', async function (assert) {
this.set('selectedNamespace', 'second');
await render(hbs`
@ -132,9 +183,11 @@ module('Integration | Component | clients/attribution', function (hooks) {
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.namespaceMountsData}}
@totalUsageCounts={{this.totalUsageCounts}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.timestamp}}
@selectedNamespace={{this.selectedNamespace}}
@isDateRange={{this.isDateRange}}
@isHistoricalMonth={{this.isHistoricalMonth}}
/>
`);
@ -161,13 +214,13 @@ module('Integration | Component | clients/attribution', function (hooks) {
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.namespaceMountsData}}
@timestamp={{this.timestamp}}
@startTimeDisplay={{"January 2022"}}
@endTimeDisplay={{"February 2022"}}
@responseTimestamp={{this.timestamp}}
@startTimestamp="2022-06-01T23:00:11.050Z"
@endTimestamp="2022-12-01T23:00:11.050Z"
/>
`);
await click('[data-test-attribution-export-button]');
assert.dom('.modal.is-active .title').hasText('Export attribution data', 'modal appears to export csv');
assert.dom('.modal.is-active').includesText('January 2022 - February 2022');
assert.dom('.modal.is-active').includesText('June 2022 - December 2022');
});
});

View File

@ -1421,7 +1421,7 @@ module('Integration | Component | clients/monthly-usage', function (hooks) {
test('it renders empty state with no data', async function (assert) {
await render(hbs`
<div id="modal-wormhole"></div>
<Clients::MonthlyUsage @chartLegend={{this.chartLegend}} @timestamp={{this.timestamp}}/>
<Clients::MonthlyUsage @chartLegend={{this.chartLegend}} @responseTimestamp={{this.timestamp}}/>
`);
assert.dom('[data-test-monthly-usage]').exists('monthly usage component renders');
assert.dom('[data-test-component="empty-state"]').exists();
@ -1446,7 +1446,7 @@ module('Integration | Component | clients/monthly-usage', function (hooks) {
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@verticalBarChartData={{this.byMonthActivityData}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
/>
`);
assert.dom('[data-test-monthly-usage]').exists('monthly usage component renders');

View File

@ -1424,7 +1424,6 @@ module('Integration | Component | clients/running-total', function (hooks) {
test('it renders with full monthly activity data', async function (assert) {
this.set('byMonthActivityData', MONTHLY_ACTIVITY);
this.set('byMonthNewClients', NEW_ACTIVITY);
this.set('totalUsageCounts', TOTAL_USAGE_COUNTS);
const expectedTotalEntity = formatNumber([TOTAL_USAGE_COUNTS.entity_clients]);
const expectedTotalNonEntity = formatNumber([TOTAL_USAGE_COUNTS.non_entity_clients]);
@ -1436,11 +1435,11 @@ module('Integration | Component | clients/running-total', function (hooks) {
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedAuthMethod={{this.selectedAuthMethod}}
// @lineChartData={{this.byMonthActivityData}}
// @barChartData={{this.byMonthNewClients}}
@byMonthActivityData={{this.byMonthActivityData}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradeDuringActivity}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
@isHistoricalMonth={{false}}
/>
`);
@ -1496,8 +1495,8 @@ module('Integration | Component | clients/running-total', function (hooks) {
});
test('it renders with no new monthly data', async function (assert) {
this.set('byMonthActivityData', MONTHLY_ACTIVITY);
this.set('byMonthNewClients', NEW_ACTIVITY);
const monthlyWithoutNew = MONTHLY_ACTIVITY.map((d) => ({ ...d, new_clients: { month: d.month } }));
this.set('byMonthActivityData', monthlyWithoutNew);
this.set('totalUsageCounts', TOTAL_USAGE_COUNTS);
const expectedTotalEntity = formatNumber([TOTAL_USAGE_COUNTS.entity_clients]);
const expectedTotalNonEntity = formatNumber([TOTAL_USAGE_COUNTS.non_entity_clients]);
@ -1507,12 +1506,12 @@ module('Integration | Component | clients/running-total', function (hooks) {
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedAuthMethod={{this.selectedAuthMethod}}
@lineChartData={{this.byMonthActivityData}}
@byMonthActivityData={{this.byMonthActivityData}}
@runningTotals={{this.totalUsageCounts}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
@isHistoricalMonth={{false}}
/>
`);
assert.dom('[data-test-running-total]').exists('running total component renders');
assert.dom('[data-test-line-chart]').exists('line chart renders');
assert.dom('[data-test-vertical-bar-chart]').doesNotExist('vertical bar chart does not render');
@ -1528,17 +1527,16 @@ module('Integration | Component | clients/running-total', function (hooks) {
.hasText(`${expectedTotalNonEntity}`, `renders correct new average ${expectedTotalNonEntity}`);
assert
.dom('[data-test-running-new-entity] p.data-details')
.hasText('0', 'renders 0 average new entity clients');
.doesNotExist('new client counts does not exist');
assert
.dom('[data-test-running-new-nonentity] p.data-details')
.hasText('0', 'renders 0 average entity clients');
.doesNotExist('average new client counts does not exist');
});
test('it renders with single historical month data', async function (assert) {
const singleMonth = MONTHLY_ACTIVITY[MONTHLY_ACTIVITY.length - 1];
const singleMonthNew = NEW_ACTIVITY[NEW_ACTIVITY.length - 1];
this.set('singleMonth', [singleMonth]);
this.set('singleMonthNew', [singleMonthNew]);
const expectedTotalClients = formatNumber([singleMonth.clients]);
const expectedTotalEntity = formatNumber([singleMonth.entity_clients]);
const expectedTotalNonEntity = formatNumber([singleMonth.non_entity_clients]);
@ -1551,10 +1549,10 @@ module('Integration | Component | clients/running-total', function (hooks) {
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedAuthMethod={{this.selectedAuthMethod}}
@lineChartData={{this.singleMonth}}
@barChartData={{this.singleMonthNew}}
@byMonthActivityData={{this.singleMonth}}
@runningTotals={{this.totalUsageCounts}}
@timestamp={{this.timestamp}}
@responseTimestamp={{this.timestamp}}
@isHistoricalMonth={{true}}
/>
`);
assert.dom('[data-test-running-total]').exists('running total component renders');

View File

@ -11,13 +11,15 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts even with no data passed');
assert.dom('[data-test-stat-text="total-clients"]').exists('Total clients exists');
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('0', 'Value defaults to zero');
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText('-', 'renders dash when no data');
assert.dom('[data-test-stat-text="entity-clients"]').exists('Entity clients exists');
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText('0', 'Value defaults to zero');
assert
.dom('[data-test-stat-text="entity-clients"] .stat-value')
.hasText('-', 'renders dash when no data');
assert.dom('[data-test-stat-text="non-entity-clients"]').exists('Non entity clients exists');
assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText('0', 'Value defaults to zero');
.hasText('-', 'renders dash when no data');
assert
.dom('a')
.hasAttribute('href', 'https://developer.hashicorp.com/vault/tutorials/monitoring/usage-metrics');

View File

@ -1,72 +1,79 @@
/* eslint qunit/no-conditional-assertions: "warn" */
import { module, skip, test } from 'qunit';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, render } from '@ember/test-helpers';
import { click, find, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
const ARRAY_OF_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const CURRENT_DATE = new Date();
const CURRENT_YEAR = CURRENT_DATE.getFullYear(); // integer of year
const CURRENT_MONTH = CURRENT_DATE.getMonth(); // index of month
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
module('Integration | Component | date-dropdown', function (hooks) {
setupRenderingTest(hooks);
test('it renders dropdown', async function (assert) {
this.set('text', 'Save');
hooks.before(function () {
const currentDate = new Date();
this.currentYear = currentDate.getFullYear(); // integer of year
this.currentMonth = currentDate.getMonth(); // index of month
});
test('it renders dropdown', async function (assert) {
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown/>
</div>
`);
assert.dom('[data-test-date-dropdown-submit]').hasText('Submit', 'button renders default text');
assert
.dom('[data-test-date-dropdown-cancel]')
.doesNotExist('it does not render cancel button by default');
});
test('it fires off cancel callback', async function (assert) {
assert.expect(2);
const onCancel = () => {
assert.ok('fires onCancel callback');
};
this.set('onCancel', onCancel);
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown @submitText={{this.text}}/>
<DateDropdown @handleCancel={{this.onCancel}} @submitText="Save"/>
</div>
`);
assert.dom('[data-test-date-dropdown-submit]').hasText('Save', 'button renders passed in text');
await click(find('[data-test-date-dropdown-cancel]'));
});
// skip until https://github.com/hashicorp/vault/pull/17575 is merged which refactors these tests and fixes flakiness
skip('it renders dropdown and selects month and year', async function (assert) {
assert.expect(27);
const parentAction = (month, year) => {
assert.strictEqual(month, 'January', 'sends correct month to parent callback');
assert.strictEqual(year, CURRENT_YEAR, 'sends correct year to parent callback');
test('it renders dropdown and selects month and year', async function (assert) {
assert.expect(26);
const parentAction = (args) => {
assert.propEqual(
args,
{
dateType: 'start',
monthIdx: 0,
monthName: 'January',
year: this.currentYear,
},
'sends correct args to parent'
);
};
this.set('parentAction', parentAction);
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown
@handleDateSelection={{this.parentAction}} />
</div>
<div class="is-flex-align-baseline">
<DateDropdown
@handleSubmit={{this.parentAction}}
@dateType="start"
/>
</div>
`);
const monthDropdown = this.element.querySelector('[data-test-popup-menu-trigger="month"]');
const yearDropdown = this.element.querySelector('[data-test-popup-menu-trigger="year"]');
const submitButton = this.element.querySelector('[data-test-date-dropdown-submit]');
const monthDropdown = find('[data-test-popup-menu-trigger="month"]');
const yearDropdown = find('[data-test-popup-menu-trigger="year"]');
const submitButton = find('[data-test-date-dropdown-submit]');
assert.true(submitButton.disabled, 'button is disabled when no month or year selected');
await click(monthDropdown);
const dropdownListMonths = this.element.querySelectorAll('[data-test-month-list] button');
const dropdownListMonths = findAll('[data-test-month-list] button');
assert.strictEqual(dropdownListMonths.length, 12, 'dropdown has 12 months');
for (const [index, month] of ARRAY_OF_MONTHS.entries()) {
assert.dom(dropdownListMonths[index]).hasText(`${month}`, `dropdown includes ${month}`);
@ -77,98 +84,121 @@ module('Integration | Component | date-dropdown', function (hooks) {
assert.dom('.ember-basic-dropdown-content').doesNotExist('dropdown closes after selecting month');
await click(yearDropdown);
const dropdownListYears = this.element.querySelectorAll('[data-test-year-list] button');
const dropdownListYears = findAll('[data-test-year-list] button');
assert.strictEqual(dropdownListYears.length, 5, 'dropdown has 5 years');
for (const [index, year] of dropdownListYears.entries()) {
const comparisonYear = CURRENT_YEAR - index;
const comparisonYear = this.currentYear - index;
assert.dom(year).hasText(`${comparisonYear}`, `dropdown includes ${comparisonYear}`);
}
await click(dropdownListYears[0]);
assert.dom(yearDropdown).hasText(`${CURRENT_YEAR}`, `dropdown selects ${CURRENT_YEAR}`);
assert.dom(yearDropdown).hasText(`${this.currentYear}`, `dropdown selects ${this.currentYear}`);
assert.dom('.ember-basic-dropdown-content').doesNotExist('dropdown closes after selecting year');
assert.false(submitButton.disabled, 'button enabled when month and year selected');
await click(submitButton);
});
skip('it disables correct years when selecting month first', async function (assert) {
assert.expect(60);
test('selecting month first: it enables current year when selecting valid months', async function (assert) {
// the date dropdown displays 5 years, multiply by month to calculate how many assertions to expect
const datesEnabled = (this.currentMonth + 1) * 5;
assert.expect(datesEnabled);
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown/>
</div>
`);
const monthDropdown = this.element.querySelector('[data-test-popup-menu-trigger="month"]');
const yearDropdown = this.element.querySelector('[data-test-popup-menu-trigger="year"]');
const monthDropdown = find('[data-test-popup-menu-trigger="month"]');
const yearDropdown = find('[data-test-popup-menu-trigger="year"]');
for (let i = 0; i < 12; i++) {
// select months before or equal to current month and assert year is enabled
for (let monthIdx = 0; monthIdx < this.currentMonth + 1; monthIdx++) {
await click(monthDropdown);
const dropdownListMonths = this.element.querySelectorAll('[data-test-month-list] button');
await click(dropdownListMonths[i]);
const dropdownListMonths = findAll('[data-test-month-list] button');
await click(dropdownListMonths[monthIdx]);
await click(yearDropdown);
const dropdownListYears = this.element.querySelectorAll('[data-test-year-list] button');
if (i < CURRENT_MONTH) {
for (const year of dropdownListYears) {
assert.false(year.disabled, `${ARRAY_OF_MONTHS[i]} ${year.innerText} valid`);
}
} else {
for (const [yearIndex, year] of dropdownListYears.entries()) {
if (yearIndex === 0) {
assert.true(year.disabled, `${ARRAY_OF_MONTHS[i]} ${year.innerText} disabled`);
} else {
assert.false(year.disabled, `${ARRAY_OF_MONTHS[i]} ${year.innerText} valid`);
}
}
const dropdownListYears = findAll('[data-test-year-list] button');
for (const year of dropdownListYears) {
assert.false(year.disabled, `${ARRAY_OF_MONTHS[monthIdx]} ${year.innerText} enabled`);
}
await click(yearDropdown);
}
});
skip('it disables correct months when selecting year first', async function (assert) {
assert.expect(60);
test('selecting month first: it disables current year when selecting future months', async function (assert) {
// assertions only run for future months
const yearsDisabled = 11 - this.currentMonth; // ex: in December, current year is enabled for all months, so 0 assertions will run
assert.expect(yearsDisabled);
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown/>
</div>
`);
const monthDropdown = this.element.querySelector('[data-test-popup-menu-trigger="month"]');
const yearDropdown = this.element.querySelector('[data-test-popup-menu-trigger="year"]');
for (let i = 0; i < 5; i++) {
await click(yearDropdown);
const dropdownListYears = this.element.querySelectorAll('[data-test-year-list] button');
await click(dropdownListYears[i]);
const monthDropdown = find('[data-test-popup-menu-trigger="month"]');
const yearDropdown = find('[data-test-popup-menu-trigger="year"]');
// select future months and assert current year is disabled
for (let monthIdx = this.currentMonth + 1; monthIdx < 12; monthIdx++) {
await click(monthDropdown);
const dropdownListMonths = this.element.querySelectorAll('[data-test-month-list] button');
const dropdownListMonths = findAll('[data-test-month-list] button');
await click(dropdownListMonths[monthIdx]);
await click(yearDropdown);
const dropdownListYears = findAll('[data-test-year-list] button');
const currentYear = dropdownListYears[0];
assert.true(currentYear.disabled, `${ARRAY_OF_MONTHS[monthIdx]} ${currentYear.innerText} disabled`);
await click(yearDropdown);
}
});
if (i === 0) {
for (const [monthIndex, month] of dropdownListMonths.entries()) {
if (monthIndex < CURRENT_MONTH) {
assert.false(
month.disabled,
`${ARRAY_OF_MONTHS[monthIndex]} ${dropdownListYears[i].innerText.trim()} valid`
);
} else {
assert.true(
month.disabled,
`${ARRAY_OF_MONTHS[monthIndex]} ${dropdownListYears[i].innerText.trim()} disabled`
);
}
}
} else {
for (const [monthIndex, month] of dropdownListMonths.entries()) {
assert.false(
month.disabled,
`${ARRAY_OF_MONTHS[monthIndex]} ${dropdownListYears[i].innerText.trim()} valid`
);
}
test('selecting year first: it disables future months when current year selected', async function (assert) {
assert.expect(12);
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown/>
</div>
`);
const monthDropdown = find('[data-test-popup-menu-trigger="month"]');
const yearDropdown = find('[data-test-popup-menu-trigger="year"]');
await click(yearDropdown);
await click(`[data-test-dropdown-year="${this.currentYear}"]`);
await click(monthDropdown);
const dropdownListMonths = findAll('[data-test-month-list] button');
const enabledMonths = dropdownListMonths.slice(0, this.currentMonth + 1);
const disabledMonths = dropdownListMonths.slice(this.currentMonth + 1);
for (const [monthIndex, month] of enabledMonths.entries()) {
assert.false(month.disabled, `${ARRAY_OF_MONTHS[monthIndex]} ${this.currentYear} enabled`);
}
for (const [monthIndex, month] of disabledMonths.entries()) {
assert.true(month.disabled, `${ARRAY_OF_MONTHS[monthIndex]} ${this.currentYear} disabled`);
}
});
test('selecting year first: it enables all months when past year is selected', async function (assert) {
assert.expect(48);
await render(hbs`
<div class="is-flex-align-baseline">
<DateDropdown/>
</div>
`);
const monthDropdown = find('[data-test-popup-menu-trigger="month"]');
const yearDropdown = find('[data-test-popup-menu-trigger="year"]');
// start at 1 because current year (index=0) is accounted for in previous test
for (let yearIdx = 1; yearIdx < 5; yearIdx++) {
await click(yearDropdown);
const dropdownListYears = findAll('[data-test-year-list] button');
await click(dropdownListYears[yearIdx]);
await click(monthDropdown);
const dropdownListMonths = findAll('[data-test-month-list] button');
for (const [monthIndex, month] of dropdownListMonths.entries()) {
assert.false(
month.disabled,
`${ARRAY_OF_MONTHS[monthIndex]} ${dropdownListYears[yearIdx].innerText.trim()} enabled`
);
}
await click(monthDropdown);
}

View File

@ -11,18 +11,16 @@ module('Integration | Helper | date-format', function (hooks) {
const today = new Date();
this.set('today', today);
await render(hbs`<p data-test-date-format>Date: {{date-format this.today "yyyy"}}</p>`);
assert
.dom('[data-test-date-format]')
.includesText(today.getFullYear(), 'it renders the date in the year format');
await render(hbs`{{date-format this.today "yyyy"}}`);
assert.dom(this.element).includesText(today.getFullYear(), 'it renders the date in the year format');
});
test('it supports date timestamps', async function (assert) {
const today = new Date().getTime();
this.set('today', today);
await render(hbs`<p class="date-format">{{date-format this.today 'hh:mm:ss'}}</p>`);
const formattedDate = document.querySelector('.date-format').innerText;
await render(hbs`{{date-format this.today 'hh:mm:ss'}}`);
const formattedDate = this.element.innerText;
assert.ok(formattedDate.match(/^\d{2}:\d{2}:\d{2}$/));
});
@ -30,37 +28,32 @@ module('Integration | Helper | date-format', function (hooks) {
const todayString = new Date().getFullYear().toString();
this.set('todayString', todayString);
await render(hbs`<p data-test-date-format>Date: {{date-format this.todayString "yyyy"}}</p>`);
assert
.dom('[data-test-date-format]')
.includesText(todayString, 'it renders the a date if passed in as a string');
await render(hbs`{{date-format this.todayString "yyyy"}}`);
assert.dom(this.element).includesText(todayString, 'it renders the a date if passed in as a string');
});
test('it supports ten digit dates', async function (assert) {
const tenDigitDate = 1621785298;
this.set('tenDigitDate', tenDigitDate);
await render(hbs`<p data-test-date-format>Date: {{date-format this.tenDigitDate "MM/dd/yyyy"}}</p>`);
assert.dom('[data-test-date-format]').includesText('05/23/2021');
await render(hbs`{{date-format this.tenDigitDate "MM/dd/yyyy"}}`);
assert.dom(this.element).includesText('05/23/2021');
});
test('it supports already formatted dates', async function (assert) {
const formattedDate = new Date();
this.set('formattedDate', formattedDate);
await render(
hbs`<p data-test-date-format>Date: {{date-format this.formattedDate 'MMMM dd, yyyy hh:mm:ss a' isFormatted=true}}</p>`
);
assert.dom('[data-test-date-format]').includesText(format(formattedDate, 'MMMM dd, yyyy hh:mm:ss a'));
await render(hbs`{{date-format this.formattedDate 'MMMM dd, yyyy hh:mm:ss a' isFormatted=true}}`);
assert.dom(this.element).includesText(format(formattedDate, 'MMMM dd, yyyy hh:mm:ss a'));
});
test('displays correct date when timestamp is in ISO 8601 format', async function (assert) {
const timestampDate = '2021-09-01T00:00:00Z';
test('displays time zone if withTimeZone=true', async function (assert) {
const timestampDate = '2022-12-06T11:29:15-08:00';
const zone = new Date().toLocaleTimeString(undefined, { timeZoneName: 'short' }).split(' ')[2];
this.set('timestampDate', timestampDate);
await render(
hbs`<p data-test-date-format>Date: {{date-format this.timestampDate 'MMM dd, yyyy' dateOnly=true}}</p>`
);
assert.dom('[data-test-date-format]').includesText('Date: Sep 01, 2021');
await render(hbs`{{date-format this.timestampDate 'MMM d yyyy, h:mm:ss aaa' withTimeZone=true}}`);
assert.dom(this.element).hasTextContaining(`${zone}`);
});
});

View File

@ -663,6 +663,7 @@ module('Integration | Util | client count utils', function (hooks) {
new_clients: {
month: '6/21',
namespaces: [],
timestamp: '2021-06-01T00:00:00Z',
},
timestamp: '2021-06-01T00:00:00Z',
},
@ -674,6 +675,7 @@ module('Integration | Util | client count utils', function (hooks) {
new_clients: {
month: '7/21',
namespaces: [],
timestamp: '2021-07-01T00:00:00Z',
},
timestamp: '2021-07-01T00:00:00Z',
},

View File

@ -1,12 +1,7 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { format, formatRFC3339, isSameDay, isSameMonth, isSameYear } from 'date-fns';
import {
ARRAY_OF_MONTHS,
parseAPITimestamp,
parseRFC3339,
formatChartDate,
} from 'core/utils/date-formatters';
import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
module('Integration | Util | date formatters utils', function (hooks) {
setupTest(hooks);
@ -30,25 +25,6 @@ module('Integration | Util | date formatters utils', function (hooks) {
assert.strictEqual(formattedTimestamp, format(DATE, 'MM yyyy'), 'it formats the date');
});
test('parseRFC3339: convert timestamp to array for widget', async function (assert) {
assert.expect(4);
const arrayArg = ['2021', 2];
assert.strictEqual(parseRFC3339(arrayArg), arrayArg, 'it returns arg if already an array');
assert.strictEqual(
parseRFC3339(UNIX_TIME),
null,
'it returns null parsing a timestamp of the wrong format'
);
const parsedTimestamp = parseRFC3339(API_TIMESTAMP);
assert.strictEqual(parsedTimestamp[0], format(DATE, 'yyyy'), 'first element is a string of the year');
assert.strictEqual(
ARRAY_OF_MONTHS[parsedTimestamp[1]],
format(DATE, 'MMMM'),
'second element is an integer of the month'
);
});
test('formatChartDate: expand chart date to full month and year', async function (assert) {
assert.expect(1);
const chartDate = '03/21';

View File

@ -2,11 +2,12 @@ import { clickable, create, isPresent } from 'ember-cli-page-object';
export default create({
clickPreviousYear: clickable('[data-test-previous-year]'),
clickCurrentMonth: clickable('[data-test-current-month]'),
clickCurrentBillingPeriod: clickable('[data-test-current-billing-period]'),
customEndMonthBtn: clickable('[data-test-show-calendar]'),
menuToggle: clickable('[data-test-popup-menu-trigger="true"]'),
showsCalendar: isPresent('[data-test-calendar-widget-container]'),
dateRangeTrigger: '[data-test-popup-menu-trigger="true"]',
async openCalendar() {
await this.menuToggle();
await this.customEndMonthBtn();

View File

@ -0,0 +1,132 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { lastDayOfMonth, subMonths, format, fromUnixTime, addMonths } from 'date-fns';
import { parseAPITimestamp, ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
module('Unit | Adapter | clients activity', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.modelName = 'clients/activity';
this.startDate = subMonths(new Date(), 6);
this.endDate = new Date();
this.readableUnix = (unix) => parseAPITimestamp(fromUnixTime(unix).toISOString(), 'MMMM dd yyyy');
});
test('it does not format if both params are timestamp strings', async function (assert) {
assert.expect(1);
const queryParams = {
start_time: { timestamp: this.startDate.toISOString() },
end_time: { timestamp: this.endDate.toISOString() },
};
this.server.get('sys/internal/counters/activity', (schema, req) => {
assert.propEqual(req.queryParams, {
start_time: this.startDate.toISOString(),
end_time: this.endDate.toISOString(),
});
});
this.store.queryRecord(this.modelName, queryParams);
});
test('it formats start_time if only end_time is a timestamp string', async function (assert) {
assert.expect(2);
const twoMonthsAhead = addMonths(this.startDate, 2);
const month = twoMonthsAhead.getMonth();
const year = twoMonthsAhead.getFullYear();
const queryParams = {
start_time: {
monthIdx: month,
year,
},
end_time: {
timestamp: this.endDate.toISOString(),
},
};
this.server.get('sys/internal/counters/activity', (schema, req) => {
const { start_time, end_time } = req.queryParams;
const readableStart = this.readableUnix(start_time);
assert.strictEqual(
readableStart,
`${ARRAY_OF_MONTHS[month]} 01 ${year}`,
`formatted unix start time is the first of the month: ${readableStart}`
);
assert.strictEqual(end_time, this.endDate.toISOString(), 'end time is a timestamp string');
});
this.store.queryRecord(this.modelName, queryParams);
});
test('it formats end_time only if only start_time is a timestamp string', async function (assert) {
assert.expect(2);
const twoMothsAgo = subMonths(this.endDate, 2);
const month = twoMothsAgo.getMonth() - 2;
const year = twoMothsAgo.getFullYear();
const dayOfMonth = format(lastDayOfMonth(new Date(year, month, 10)), 'dd');
const queryParams = {
start_time: {
timestamp: this.startDate.toISOString(),
},
end_time: {
monthIdx: month,
year,
},
};
this.server.get('sys/internal/counters/activity', (schema, req) => {
const { start_time, end_time } = req.queryParams;
const readableEnd = this.readableUnix(end_time);
assert.strictEqual(start_time, this.startDate.toISOString(), 'start time is a timestamp string');
assert.strictEqual(
readableEnd,
`${ARRAY_OF_MONTHS[month]} ${dayOfMonth} ${year}`,
`formatted unix end time is the last day of the month: ${readableEnd}`
);
});
this.store.queryRecord(this.modelName, queryParams);
});
test('it formats both params if neither are a timestamp', async function (assert) {
assert.expect(2);
const startDate = subMonths(this.startDate, 2);
const endDate = addMonths(this.endDate, 2);
const startMonth = startDate.getMonth() + 2;
const startYear = startDate.getFullYear();
const endMonth = endDate.getMonth() - 2;
const endYear = endDate.getFullYear();
const endDay = format(lastDayOfMonth(new Date(endYear, endMonth, 10)), 'dd');
const queryParams = {
start_time: {
monthIdx: startMonth,
year: startYear,
},
end_time: {
monthIdx: endMonth,
year: endYear,
},
};
this.server.get('sys/internal/counters/activity', (schema, req) => {
const { start_time, end_time } = req.queryParams;
const readableEnd = this.readableUnix(end_time);
const readableStart = this.readableUnix(start_time);
assert.strictEqual(
readableStart,
`${ARRAY_OF_MONTHS[startMonth]} 01 ${startYear}`,
`formatted unix start time is the first of the month: ${readableStart}`
);
assert.strictEqual(
readableEnd,
`${ARRAY_OF_MONTHS[endMonth]} ${endDay} ${endYear}`,
`formatted unix end time is the last day of the month: ${readableEnd}`
);
});
this.store.queryRecord(this.modelName, queryParams);
});
});

View File

@ -38,24 +38,10 @@ module('Unit | Utility | chart-helpers', function () {
{ label: 'foo', value: undefined },
{ label: 'bar', value: 22 },
];
const testArray3 = [{ label: 'foo' }, { label: 'bar' }];
const getAverage = (array) => array.reduce((a, b) => a + b, 0) / array.length;
assert.strictEqual(calculateAverage(null), null, 'returns null if dataset it null');
assert.strictEqual(calculateAverage([]), null, 'returns null if dataset it empty array');
assert.strictEqual(
calculateAverage([0]),
getAverage([0]),
`returns ${getAverage([0])} if array is just 0 0`
);
assert.strictEqual(
calculateAverage([1]),
getAverage([1]),
`returns ${getAverage([1])} if array is just 1`
);
assert.strictEqual(
calculateAverage([5, 1, 41, 5]),
getAverage([5, 1, 41, 5]),
`returns correct average for array of integers`
);
assert.strictEqual(
calculateAverage(testArray1, 'value'),
getAverage([10, 22]),
@ -66,5 +52,10 @@ module('Unit | Utility | chart-helpers', function () {
getAverage([0, 22]),
`returns correct average for array of objects containing undefined values`
);
assert.strictEqual(
calculateAverage(testArray3, 'value'),
null,
'returns null when object key does not exist at all'
);
});
});

View File

@ -10006,10 +10006,10 @@ ember-maybe-in-element@^2.0.3:
ember-cli-version-checker "^5.1.1"
ember-in-element-polyfill "^1.0.1"
ember-modal-dialog@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/ember-modal-dialog/-/ember-modal-dialog-4.0.0.tgz#ab599ad14055550706800bd9aac3a2cbb7799748"
integrity sha512-ofh+xG8v6Lg60R3JXaG9pW683OWCexDQT/JT7S0TOgywSHzdC9WYE4/kIbvxZ6T9Ka87CosP+cz2A2iJ/jvZNQ==
ember-modal-dialog@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/ember-modal-dialog/-/ember-modal-dialog-4.0.1.tgz#093e0b5a32e2824b573bec9f0c935b4f990dbaff"
integrity sha512-6RQjQClvufYB7Y/sOS90HF3tfmLe7LLDM3fgl8BsAEu9xSJTHZTz+xpBY467hxIkl2vAXH6rNLFCf7W/Rm9Jcw==
dependencies:
"@embroider/macros" "^1.0.0"
"@embroider/util" "^1.0.0"