open-vault/ui/app/components/clients/dashboard.js
claire bontempo 4a9610f382
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
2023-01-26 18:21:12 -08:00

372 lines
14 KiB
JavaScript

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;
}
}