diff --git a/changelog/17575.txt b/changelog/17575.txt new file mode 100644 index 000000000..f08b53ff8 --- /dev/null +++ b/changelog/17575.txt @@ -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 +``` diff --git a/ui/app/adapters/clients/activity.js b/ui/app/adapters/clients/activity.js index a8990b463..f191a285a 100644 --- a/ui/app/adapters/clients/activity.js +++ b/ui/app/adapters/clients/activity.js @@ -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; }); } - }, -}); + } +} diff --git a/ui/app/adapters/clients/monthly.js b/ui/app/adapters/clients/monthly.js deleted file mode 100644 index a1f3c5e1c..000000000 --- a/ui/app/adapters/clients/monthly.js +++ /dev/null @@ -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; - }); - } -} diff --git a/ui/app/adapters/clients/version-history.js b/ui/app/adapters/clients/version-history.js index b45927e3a..2387a8322 100644 --- a/ui/app/adapters/clients/version-history.js +++ b/ui/app/adapters/clients/version-history.js @@ -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; }); - }, -}); + } +} diff --git a/ui/app/components/calendar-widget.js b/ui/app/components/calendar-widget.js index c8725fbd1..981677e10 100644 --- a/ui/app/components/calendar-widget.js +++ b/ui/app/components/calendar-widget.js @@ -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 - * + * * + * @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); diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js index 337925d88..54a70dd9f 100644 --- a/ui/app/components/clients/attribution.js +++ b/ui/app/components/clients/attribution.js @@ -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(); diff --git a/ui/app/components/clients/current.js b/ui/app/components/clients/current.js deleted file mode 100644 index e21681005..000000000 --- a/ui/app/components/clients/current.js +++ /dev/null @@ -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; - } -} diff --git a/ui/app/components/clients/dashboard.js b/ui/app/components/clients/dashboard.js new file mode 100644 index 000000000..a37077ff8 --- /dev/null +++ b/ui/app/components/clients/dashboard.js @@ -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 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; + } +} diff --git a/ui/app/components/clients/history.js b/ui/app/components/clients/history.js deleted file mode 100644 index 0e40b93c1..000000000 --- a/ui/app/components/clients/history.js +++ /dev/null @@ -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(); - } -} diff --git a/ui/app/components/clients/line-chart.js b/ui/app/components/clients/line-chart.js index 378548830..ba0ce4d90 100644 --- a/ui/app/components/clients/line-chart.js +++ b/ui/app/components/clients/line-chart.js @@ -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(); diff --git a/ui/app/components/clients/monthly-usage.js b/ui/app/components/clients/monthly-usage.js index ea803875c..6cdd99bc7 100644 --- a/ui/app/components/clients/monthly-usage.js +++ b/ui/app/components/clients/monthly-usage.js @@ -9,7 +9,7 @@ import { calculateAverage } from 'vault/utils/chart-helpers'; * ```js * ``` diff --git a/ui/app/components/clients/running-total.js b/ui/app/components/clients/running-total.js index 6c3b09951..320bffc40 100644 --- a/ui/app/components/clients/running-total.js +++ b/ui/app/components/clients/running-total.js @@ -12,8 +12,7 @@ import { calculateAverage } from 'vault/utils/chart-helpers'; @@ -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]; } } diff --git a/ui/app/components/clients/vertical-bar-chart.js b/ui/app/components/clients/vertical-bar-chart.js index 2d84567d2..d8d57e821 100644 --- a/ui/app/components/clients/vertical-bar-chart.js +++ b/ui/app/components/clients/vertical-bar-chart.js @@ -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() diff --git a/ui/app/components/date-dropdown.js b/ui/app/components/date-dropdown.js index bf9df3754..20b957a3f 100644 --- a/ui/app/components/date-dropdown.js +++ b/ui/app/components/date-dropdown.js @@ -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 - * + * * ``` - * @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; } } diff --git a/ui/app/models/clients/activity.js b/ui/app/models/clients/activity.js index 70d8f4914..e599a52db 100644 --- a/ui/app/models/clients/activity.js +++ b/ui/app/models/clients/activity.js @@ -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; diff --git a/ui/app/models/clients/config.js b/ui/app/models/clients/config.js index e4f101ca2..c261847d9 100644 --- a/ui/app/models/clients/config.js +++ b/ui/app/models/clients/config.js @@ -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.', diff --git a/ui/app/models/clients/monthly.js b/ui/app/models/clients/monthly.js deleted file mode 100644 index 2ec3859b2..000000000 --- a/ui/app/models/clients/monthly.js +++ /dev/null @@ -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; -} diff --git a/ui/app/models/clients/version-history.js b/ui/app/models/clients/version-history.js index 2175a2fc3..6c15ac3be 100644 --- a/ui/app/models/clients/version-history.js +++ b/ui/app/models/clients/version-history.js @@ -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; } diff --git a/ui/app/router.js b/ui/app/router.js index c2c90dd4f..c742f9518 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -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'); }); diff --git a/ui/app/routes/vault/cluster/clients.js b/ui/app/routes/vault/cluster/clients.js index 2bb529703..c7af00950 100644 --- a/ui/app/routes/vault/cluster/clients.js +++ b/ui/app/routes/vault/cluster/clients.js @@ -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(), }); } diff --git a/ui/app/routes/vault/cluster/clients/current.js b/ui/app/routes/vault/cluster/clients/current.js deleted file mode 100644 index d631c0121..000000000 --- a/ui/app/routes/vault/cluster/clients/current.js +++ /dev/null @@ -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, - }); - } -} diff --git a/ui/app/routes/vault/cluster/clients/dashboard.js b/ui/app/routes/vault/cluster/clients/dashboard.js new file mode 100644 index 000000000..3968d6b84 --- /dev/null +++ b/ui/app/routes/vault/cluster/clients/dashboard.js @@ -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, + }; + } +} diff --git a/ui/app/routes/vault/cluster/clients/history.js b/ui/app/routes/vault/cluster/clients/history.js deleted file mode 100644 index f0e8a8f4f..000000000 --- a/ui/app/routes/vault/cluster/clients/history.js +++ /dev/null @@ -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, - }); - } -} diff --git a/ui/app/serializers/clients/activity.js b/ui/app/serializers/clients/activity.js index 9748af84f..0fdbb7ba0 100644 --- a/ui/app/serializers/clients/activity.js +++ b/ui/app/serializers/clients/activity.js @@ -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; diff --git a/ui/app/serializers/clients/monthly.js b/ui/app/serializers/clients/monthly.js deleted file mode 100644 index 9f114e594..000000000 --- a/ui/app/serializers/clients/monthly.js +++ /dev/null @@ -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); - } -} diff --git a/ui/app/serializers/clients/version-history.js b/ui/app/serializers/clients/version-history.js index 465fcf1e6..ab378d1e1 100644 --- a/ui/app/serializers/clients/version-history.js +++ b/ui/app/serializers/clients/version-history.js @@ -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] })); } - }, -}); + } +} diff --git a/ui/app/styles/components/calendar-widget.scss b/ui/app/styles/components/calendar-widget.scss index b96b374b6..be6c13b7b 100644 --- a/ui/app/styles/components/calendar-widget.scss +++ b/ui/app/styles/components/calendar-widget.scss @@ -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; - } } } diff --git a/ui/app/styles/components/modal.scss b/ui/app/styles/components/modal.scss index 61b8ca436..5037e44ec 100644 --- a/ui/app/styles/components/modal.scss +++ b/ui/app/styles/components/modal.scss @@ -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; + } +} diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss index 97540851a..2f6ec3ffc 100644 --- a/ui/app/styles/core/helpers.scss +++ b/ui/app/styles/core/helpers.scss @@ -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; } diff --git a/ui/app/templates/components/calendar-widget.hbs b/ui/app/templates/components/calendar-widget.hbs index a5fff1e6f..18eba7f0c 100644 --- a/ui/app/templates/components/calendar-widget.hbs +++ b/ui/app/templates/components/calendar-widget.hbs @@ -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"}} @@ -15,12 +15,24 @@ DATE OPTIONS