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