/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ 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 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; } }