UI/Add upgrade indicator client charts (#15083)

* clean up activity serailizer

* fix line chart so only plot months with data

* cleanup monthly serializer

* account for empty months in vertical bar chart

* tidy version upgrade info

* fix version history model typo

* extract const into helper

* add upgrade indicator to line chart

* fix tests

* add todos
This commit is contained in:
claire bontempo
2022-04-20 08:35:57 -07:00
committed by GitHub
parent 50afd00b0b
commit e74c1b29b3
20 changed files with 230 additions and 212 deletions

View File

@@ -7,9 +7,6 @@ export default class Current extends Component {
{ key: 'entity_clients', label: 'entity clients' }, { key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' }, { key: 'non_entity_clients', label: 'non-entity clients' },
]; ];
@tracked firstUpgradeVersion = this.args.model.versionHistory[0].id || null; // return 1.9.0 or earliest upgrade post 1.9.0
@tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp
@tracked selectedNamespace = null; @tracked selectedNamespace = null;
@tracked namespaceArray = this.byNamespaceTotalClients.map((namespace) => { @tracked namespaceArray = this.byNamespaceTotalClients.map((namespace) => {
return { name: namespace['label'], id: namespace['label'] }; return { name: namespace['label'], id: namespace['label'] };
@@ -18,6 +15,12 @@ export default class Current extends Component {
@tracked selectedAuthMethod = null; @tracked selectedAuthMethod = null;
@tracked authMethodOptions = []; @tracked authMethodOptions = [];
get latestUpgradeData() {
// e.g. {id: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
// version id is 1.9.0 or earliest upgrade post 1.9.0, timestamp is RFC3339
return this.args.model.versionHistory[0] || null;
}
// Response total client count data by namespace for current/partial month // Response total client count data by namespace for current/partial month
get byNamespaceTotalClients() { get byNamespaceTotalClients() {
return this.args.model.monthly?.byNamespaceTotalClients || []; return this.args.model.monthly?.byNamespaceTotalClients || [];
@@ -74,11 +77,10 @@ export default class Current extends Component {
} }
get countsIncludeOlderData() { get countsIncludeOlderData() {
let firstUpgrade = this.args.model.versionHistory[0]; if (!this.latestUpgradeData) {
if (!firstUpgrade) {
return false; return false;
} }
let versionDate = new Date(firstUpgrade.timestampInstalled); let versionDate = new Date(this.latestUpgradeData.timestampInstalled);
// compare against this month and this year to show message or not. // compare against this month and this year to show message or not.
return isAfter(versionDate, startOfMonth(new Date())) ? versionDate : false; return isAfter(versionDate, startOfMonth(new Date())) ? versionDate : false;
} }

View File

@@ -4,6 +4,7 @@ import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { isSameMonth, isAfter } from 'date-fns'; import { isSameMonth, isAfter } from 'date-fns';
import getStorage from 'vault/lib/token-storage'; import getStorage from 'vault/lib/token-storage';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date'; const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
@@ -11,20 +12,7 @@ export default class History extends Component {
@service store; @service store;
@service version; @service version;
arrayOfMonths = [ arrayOfMonths = ARRAY_OF_MONTHS;
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
chartLegend = [ chartLegend = [
{ key: 'entity_clients', label: 'entity clients' }, { key: 'entity_clients', label: 'entity clients' },
@@ -56,10 +44,6 @@ export default class History extends Component {
@tracked startTimeRequested = null; @tracked startTimeRequested = null;
@tracked queriedActivityResponse = null; @tracked queriedActivityResponse = null;
// VERSION/UPGRADE INFO
@tracked firstUpgradeVersion = this.args.model.versionHistory[0].id || null; // return 1.9.0 or earliest upgrade post 1.9.0
@tracked upgradeDate = this.args.model.versionHistory[0].timestampInstalled || null; // returns RFC3339 timestamp
// SEARCH SELECT // SEARCH SELECT
@tracked selectedNamespace = null; @tracked selectedNamespace = null;
@tracked namespaceArray = this.getActivityResponse.byNamespace @tracked namespaceArray = this.getActivityResponse.byNamespace
@@ -98,17 +82,17 @@ export default class History extends Component {
}; };
} }
// on init API response uses license start_date, getter updates when user queries dates get isDateRange() {
get getActivityResponse() { return !isSameMonth(
return this.queriedActivityResponse || this.args.model.activity; new Date(this.getActivityResponse.startTime),
new Date(this.getActivityResponse.endTime)
);
} }
get hasAttributionData() { get latestUpgradeData() {
if (this.selectedAuthMethod) return false; // {id: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
if (this.selectedNamespace) { // version id is 1.9.0 or earliest upgrade post 1.9.0, timestamp is RFC3339
return this.authMethodOptions.length > 0; return this.args.model.versionHistory[0] || null;
}
return !!this.totalClientsData && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
} }
get startTimeDisplay() { get startTimeDisplay() {
@@ -129,25 +113,19 @@ export default class History extends Component {
return `${this.arrayOfMonths[month]} ${year}`; return `${this.arrayOfMonths[month]} ${year}`;
} }
get filteredActivity() { // GETTERS FOR RESPONSE & DATA
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod; // on init API response uses license start_date, getter updates when user queries dates
if (!namespace && !auth) { get getActivityResponse() {
return this.getActivityResponse; return this.queriedActivityResponse || this.args.model.activity;
}
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 isDateRange() { get hasAttributionData() {
return !isSameMonth( if (this.selectedAuthMethod) return false;
new Date(this.getActivityResponse.startTime), if (this.selectedNamespace) {
new Date(this.getActivityResponse.endTime) return this.authMethodOptions.length > 0;
); }
return !!this.totalClientsData && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
} }
// top level TOTAL client counts for given date range // top level TOTAL client counts for given date range
@@ -169,25 +147,35 @@ export default class History extends Component {
} }
get byMonthTotalClients() { get byMonthTotalClients() {
return this.getActivityResponse?.byMonthTotalClients; return this.getActivityResponse?.byMonth;
} }
get byMonthNewClients() { get byMonthNewClients() {
return this.getActivityResponse?.byMonthNewClients; return this.byMonthTotalClients.map((m) => m.new_clients);
} }
get countsIncludeOlderData() { get countsIncludeOlderData() {
let firstUpgrade = this.args.model.versionHistory[0]; if (!this.latestUpgradeData) {
if (!firstUpgrade) {
return false; return false;
} }
let versionDate = new Date(firstUpgrade.timestampInstalled); let versionDate = new Date(this.latestUpgradeData.timestampInstalled);
let startTimeFromResponseAsDateObject = new Date( let startTimeFromResponse = new Date(this.getActivityResponse.startTime);
Number(this.startTimeFromResponse[0]), // compare against this start date returned from API to show message or not.
this.startTimeFromResponse[1] return isAfter(versionDate, startTimeFromResponse) ? versionDate : false;
); }
// compare against this startTimeFromResponse to show message or not.
return isAfter(versionDate, startTimeFromResponseAsDateObject) ? versionDate : false; get filteredActivity() {
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);
} }
@action @action

View File

@@ -6,7 +6,7 @@ import { select, event, selectAll } from 'd3-selection';
import { scaleLinear, scaleBand } from 'd3-scale'; import { scaleLinear, scaleBand } from 'd3-scale';
import { axisLeft } from 'd3-axis'; import { axisLeft } from 'd3-axis';
import { max, maxIndex } from 'd3-array'; import { max, maxIndex } from 'd3-array';
import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE, formatTooltipNumber } from '../../utils/chart-helpers'; import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE, formatTooltipNumber } from 'vault/utils/chart-helpers';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
/** /**

View File

@@ -7,7 +7,13 @@ import { select, selectAll, node } from 'd3-selection';
import { axisLeft, axisBottom } from 'd3-axis'; import { axisLeft, axisBottom } from 'd3-axis';
import { scaleLinear, scalePoint } from 'd3-scale'; import { scaleLinear, scalePoint } from 'd3-scale';
import { line } from 'd3-shape'; import { line } from 'd3-shape';
import { LIGHT_AND_DARK_BLUE, SVG_DIMENSIONS, formatNumbers } from '../../utils/chart-helpers'; import {
LIGHT_AND_DARK_BLUE,
UPGRADE_WARNING,
SVG_DIMENSIONS,
formatNumbers,
} from 'vault/utils/chart-helpers';
import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
/** /**
* @module LineChart * @module LineChart
@@ -26,6 +32,7 @@ export default class LineChart extends Component {
@tracked tooltipMonth = ''; @tracked tooltipMonth = '';
@tracked tooltipTotal = ''; @tracked tooltipTotal = '';
@tracked tooltipNew = ''; @tracked tooltipNew = '';
@tracked tooltipUpgradeText = '';
get yKey() { get yKey() {
return this.args.yKey || 'clients'; return this.args.yKey || 'clients';
@@ -42,21 +49,29 @@ export default class LineChart extends Component {
@action @action
renderChart(element, args) { renderChart(element, args) {
const dataset = args[0]; const dataset = args[0];
let upgradeMonth, currentVersion, previousVersion;
if (args[1]) {
upgradeMonth = parseAPITimestamp(args[1].timestampInstalled, 'M/yy');
currentVersion = args[1].id;
previousVersion = args[1].previousVersion;
}
const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp)
const chartSvg = select(element); const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// DEFINE AXES SCALES // DEFINE AXES SCALES
const yScale = scaleLinear() const yScale = scaleLinear()
.domain([0, max(dataset.map((d) => d[this.yKey]))]) .domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([0, 100]) .range([0, 100])
.nice(); .nice();
const yAxisScale = scaleLinear() const yAxisScale = scaleLinear()
.domain([0, max(dataset.map((d) => d[this.yKey]))]) .domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([SVG_DIMENSIONS.height, 0]) .range([SVG_DIMENSIONS.height, 0])
.nice(); .nice();
const xScale = scalePoint() // use scaleTime()? // use full dataset (instead of filteredData) so x-axis spans months with and without data
const xScale = scalePoint()
.domain(dataset.map((d) => d[this.xKey])) .domain(dataset.map((d) => d[this.xKey]))
.range([0, SVG_DIMENSIONS.width]) .range([0, SVG_DIMENSIONS.width])
.padding(0.2); .padding(0.2);
@@ -75,6 +90,20 @@ export default class LineChart extends Component {
chartSvg.selectAll('.domain').remove(); chartSvg.selectAll('.domain').remove();
// VERSION UPGRADE INDICATOR
chartSvg
.append('g')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.attr('class', 'upgrade-circle')
.attr('fill', UPGRADE_WARNING)
.style('opacity', (d) => (d[this.xKey] === upgradeMonth ? '1' : '0'))
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 10);
// PATH BETWEEN PLOT POINTS // PATH BETWEEN PLOT POINTS
const lineGenerator = line() const lineGenerator = line()
.x((d) => xScale(d[this.xKey])) .x((d) => xScale(d[this.xKey]))
@@ -86,13 +115,13 @@ export default class LineChart extends Component {
.attr('fill', 'none') .attr('fill', 'none')
.attr('stroke', LIGHT_AND_DARK_BLUE[1]) .attr('stroke', LIGHT_AND_DARK_BLUE[1])
.attr('stroke-width', 0.5) .attr('stroke-width', 0.5)
.attr('d', lineGenerator(dataset)); .attr('d', lineGenerator(filteredData));
// LINE PLOTS (CIRCLES) // LINE PLOTS (CIRCLES)
chartSvg chartSvg
.append('g') .append('g')
.selectAll('circle') .selectAll('circle')
.data(dataset) .data(filteredData)
.enter() .enter()
.append('circle') .append('circle')
.attr('class', 'data-plot') .attr('class', 'data-plot')
@@ -107,7 +136,7 @@ export default class LineChart extends Component {
chartSvg chartSvg
.append('g') .append('g')
.selectAll('circle') .selectAll('circle')
.data(dataset) .data(filteredData)
.enter() .enter()
.append('circle') .append('circle')
.attr('class', 'hover-circle') .attr('class', 'hover-circle')
@@ -122,9 +151,13 @@ export default class LineChart extends Component {
// MOUSE EVENT FOR TOOLTIP // MOUSE EVENT FOR TOOLTIP
hoverCircles.on('mouseover', (data) => { hoverCircles.on('mouseover', (data) => {
// TODO: how to genericize this? // TODO: how to genericize this?
this.tooltipMonth = data[this.xKey]; this.tooltipMonth = formatChartDate(data[this.xKey]);
this.tooltipTotal = `${data[this.yKey]} total clients`; this.tooltipTotal = data[this.yKey] + ' total clients';
this.tooltipNew = `${data?.new_clients[this.yKey]} new clients`; this.tooltipNew = data?.new_clients[this.yKey] + ' new clients';
this.tooltipUpgradeText =
data[this.xKey] === upgradeMonth
? `Vault was upgraded ${previousVersion ? 'from ' + previousVersion : ''} to ${currentVersion}`
: '';
let node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node(); let node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node();
this.tooltipTarget = node; this.tooltipTarget = node;
}); });

View File

@@ -21,27 +21,25 @@ import { mean } from 'd3-array';
month: '1/22', month: '1/22',
entity_clients: 23, entity_clients: 23,
non_entity_clients: 45, non_entity_clients: 45,
total: 68, clients: 68,
namespaces: [], namespaces: [],
new_clients: { new_clients: {
entity_clients: 11, entity_clients: 11,
non_entity_clients: 36, non_entity_clients: 36,
total: 47, clients: 47,
namespaces: [], namespaces: [],
}, },
} }
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
*/ */
export default class MonthlyUsage extends Component { export default class MonthlyUsage extends Component {
barChartData = this.args.verticalBarChartData;
get averageTotalClients() { get averageTotalClients() {
let average = mean(this.barChartData?.map((d) => d.total)); let average = mean(this.args.verticalBarChartData?.map((d) => d.clients));
return Math.round(average) || null; return Math.round(average) || null;
} }
get averageNewClients() { get averageNewClients() {
let average = mean(this.barChartData?.map((d) => d.new_clients.total)); let average = mean(this.args.verticalBarChartData?.map((d) => d.new_clients.clients));
return Math.round(average) || null; return Math.round(average) || null;
} }
} }

View File

@@ -14,6 +14,7 @@ import { mean } from 'd3-array';
@barChartData={{this.byMonthNewClients}} @barChartData={{this.byMonthNewClients}}
@lineChartData={{this.byMonth}} @lineChartData={{this.byMonth}}
@runningTotals={{this.runningTotals}} @runningTotals={{this.runningTotals}}
@upgradeData={{if this.countsIncludeOlderData this.latestUpgradeData}}
/> />
* ``` * ```
@@ -35,6 +36,7 @@ import { mean } from 'd3-array';
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {object} runningTotals - top level totals from /activity response { clients: 3517, entity_clients: 1593, non_entity_clients: 1924 } * @param {object} runningTotals - top level totals from /activity response { clients: 3517, entity_clients: 1593, non_entity_clients: 1924 }
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response * @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
* @param {object} upgradeData - object containing version upgrade data e.g.: {id: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
* *
*/ */
export default class RunningTotal extends Component { export default class RunningTotal extends Component {

View File

@@ -13,7 +13,7 @@ import {
SVG_DIMENSIONS, SVG_DIMENSIONS,
TRANSLATE, TRANSLATE,
formatNumbers, formatNumbers,
} from '../../utils/chart-helpers'; } from 'vault/utils/chart-helpers';
/** /**
* @module VerticalBarChart * @module VerticalBarChart
@@ -49,24 +49,25 @@ export default class VerticalBarChart extends Component {
@action @action
registerListener(element, args) { registerListener(element, args) {
let dataset = args[0]; const dataset = args[0];
let stackFunction = stack().keys(this.chartLegend.map((l) => l.key)); const filteredData = dataset.filter((e) => Object.keys(e).includes('clients')); // months with data will contain a 'clients' key (otherwise only a timestamp)
let stackedData = stackFunction(dataset); const stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
let chartSvg = select(element); const stackedData = stackFunction(filteredData);
const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// DEFINE DATA BAR SCALES // DEFINE DATA BAR SCALES
let yScale = scaleLinear() const yScale = scaleLinear()
.domain([0, max(dataset.map((d) => d[this.yKey]))]) .domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([0, 100]) .range([0, 100])
.nice(); .nice();
let xScale = scaleBand() const xScale = scaleBand()
.domain(dataset.map((d) => d[this.xKey])) .domain(dataset.map((d) => d[this.xKey]))
.range([0, SVG_DIMENSIONS.width]) // set width to fix number of pixels .range([0, SVG_DIMENSIONS.width]) // set width to fix number of pixels
.paddingInner(0.85); .paddingInner(0.85);
let dataBars = chartSvg const dataBars = chartSvg
.selectAll('g') .selectAll('g')
.data(stackedData) .data(stackedData)
.enter() .enter()
@@ -85,18 +86,18 @@ export default class VerticalBarChart extends Component {
.attr('y', (data) => `${100 - yScale(data[1])}%`); // subtract higher than 100% to give space for x axis ticks .attr('y', (data) => `${100 - yScale(data[1])}%`); // subtract higher than 100% to give space for x axis ticks
// MAKE AXES // // MAKE AXES //
let yAxisScale = scaleLinear() const yAxisScale = scaleLinear()
.domain([0, max(dataset.map((d) => d[this.yKey]))]) .domain([0, max(filteredData.map((d) => d[this.yKey]))])
.range([`${SVG_DIMENSIONS.height}`, 0]) .range([`${SVG_DIMENSIONS.height}`, 0])
.nice(); .nice();
let yAxis = axisLeft(yAxisScale) const yAxis = axisLeft(yAxisScale)
.ticks(4) .ticks(4)
.tickPadding(10) .tickPadding(10)
.tickSizeInner(-SVG_DIMENSIONS.width) .tickSizeInner(-SVG_DIMENSIONS.width)
.tickFormat(formatNumbers); .tickFormat(formatNumbers);
let xAxis = axisBottom(xScale).tickSize(0); const xAxis = axisBottom(xScale).tickSize(0);
yAxis(chartSvg.append('g')); yAxis(chartSvg.append('g'));
xAxis(chartSvg.append('g').attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)); xAxis(chartSvg.append('g').attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`));
@@ -104,16 +105,16 @@ export default class VerticalBarChart extends Component {
chartSvg.selectAll('.domain').remove(); // remove domain lines chartSvg.selectAll('.domain').remove(); // remove domain lines
// WIDER SELECTION AREA FOR TOOLTIP HOVER // WIDER SELECTION AREA FOR TOOLTIP HOVER
let greyBars = chartSvg const greyBars = chartSvg
.append('g') .append('g')
.attr('transform', `translate(${TRANSLATE.left})`) .attr('transform', `translate(${TRANSLATE.left})`)
.style('fill', `${GREY}`) .style('fill', `${GREY}`)
.style('opacity', '0') .style('opacity', '0')
.style('mix-blend-mode', 'multiply'); .style('mix-blend-mode', 'multiply');
let tooltipRect = greyBars const tooltipRect = greyBars
.selectAll('rect') .selectAll('rect')
.data(dataset) .data(filteredData)
.enter() .enter()
.append('rect') .append('rect')
.style('cursor', 'pointer') .style('cursor', 'pointer')

View File

@@ -1,7 +1,6 @@
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
export default class Activity extends Model { export default class Activity extends Model {
@attr('array') byMonthTotalClients; @attr('array') byMonth;
@attr('array') byMonthNewClients;
@attr('array') byNamespace; @attr('array') byNamespace;
@attr('object') total; @attr('object') total;
@attr('array') formattedEndTime; @attr('array') formattedEndTime;

View File

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

View File

@@ -12,7 +12,7 @@ export default class ClientsRoute extends Route {
response.forEach((model) => { response.forEach((model) => {
arrayOfModels.push({ arrayOfModels.push({
id: model.id, id: model.id,
perviousVersion: model.previousVersion, previousVersion: model.previousVersion,
timestampInstalled: model.timestampInstalled, timestampInstalled: model.timestampInstalled,
}); });
}); });

View File

@@ -2,30 +2,28 @@ import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns'; import { formatISO } from 'date-fns';
import { parseAPITimestamp, parseRFC3339 } from 'core/utils/date-formatters'; import { parseAPITimestamp, parseRFC3339 } from 'core/utils/date-formatters';
export default class ActivitySerializer extends ApplicationSerializer { export default class ActivitySerializer extends ApplicationSerializer {
flattenDataset(namespaceArray) { flattenDataset(object) {
return namespaceArray.map((ns) => { let flattenedObject = {};
Object.keys(object['counts']).forEach((key) => (flattenedObject[key] = object['counts'][key]));
return this.homogenizeClientNaming(flattenedObject);
}
formatByNamespace(namespaceArray) {
return namespaceArray?.map((ns) => {
// 'namespace_path' is an empty string for root // 'namespace_path' is an empty string for root
if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root'; if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root';
let label = ns['namespace_path']; let label = ns['namespace_path'];
let flattenedNs = {}; let flattenedNs = this.flattenDataset(ns);
// we don't want client counts nested within the 'counts' object for stacked charts
Object.keys(ns['counts']).forEach((key) => (flattenedNs[key] = ns['counts'][key]));
flattenedNs = this.homogenizeClientNaming(flattenedNs);
// if no mounts, mounts will be an empty array // if no mounts, mounts will be an empty array
flattenedNs.mounts = ns.mounts flattenedNs.mounts = [];
? ns.mounts.map((mount) => { if (ns?.mounts && ns.mounts.length > 0) {
let flattenedMount = {}; flattenedNs.mounts = ns.mounts.map((mount) => {
let label = mount['mount_path'];
Object.keys(mount['counts']).forEach((key) => (flattenedMount[key] = mount['counts'][key]));
flattenedMount = this.homogenizeClientNaming(flattenedMount);
return { return {
label, label: mount['mount_path'],
...flattenedMount, ...this.flattenDataset(mount),
}; };
}) });
: []; }
return { return {
label, label,
...flattenedNs, ...flattenedNs,
@@ -33,37 +31,35 @@ export default class ActivitySerializer extends ApplicationSerializer {
}); });
} }
flattenByMonths(payload, isNewClients = false) { formatByMonths(monthsArray) {
const sortedPayload = [...payload]; const sortedPayload = [...monthsArray];
// months are always returned from the API: [mostRecent...oldestMonth]
sortedPayload.reverse(); sortedPayload.reverse();
if (isNewClients) { return sortedPayload.map((m) => {
return sortedPayload?.map((m) => { if (Object.keys(m).includes('counts')) {
let totalClients = this.flattenDataset(m);
let newClients = this.flattenDataset(m.new_clients);
return { return {
month: parseAPITimestamp(m.timestamp, 'M/yy'), month: parseAPITimestamp(m.timestamp, 'M/yy'),
entity_clients: m.new_clients.counts.entity_clients, ...totalClients,
non_entity_clients: m.new_clients.counts.non_entity_clients, namespaces: this.formatByNamespace(m.namespaces),
clients: m.new_clients.counts.clients,
namespaces: this.flattenDataset(m.new_clients.namespaces),
};
});
} else {
return sortedPayload?.map((m) => {
return {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
entity_clients: m.counts.entity_clients,
non_entity_clients: m.counts.non_entity_clients,
clients: m.counts.clients,
namespaces: this.flattenDataset(m.namespaces),
new_clients: { new_clients: {
entity_clients: m.new_clients.counts.entity_clients, month: parseAPITimestamp(m.timestamp, 'M/yy'),
non_entity_clients: m.new_clients.counts.non_entity_clients, ...newClients,
clients: m.new_clients.counts.clients, namespaces: this.formatByNamespace(m.new_clients.namespaces),
namespaces: this.flattenDataset(m.new_clients.namespaces), },
};
}
// TODO CMB below is an assumption, need to test
// if no monthly data (no counts key), month object will just contain a timestamp
return {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
new_clients: {
month: parseAPITimestamp(m.timestamp, 'M/yy'),
}, },
}; };
}); });
} }
}
// In 1.10 'distinct_entities' changed to 'entity_clients' and // In 1.10 'distinct_entities' changed to 'entity_clients' and
// 'non_entity_tokens' to 'non_entity_clients' // 'non_entity_tokens' to 'non_entity_clients'
@@ -86,7 +82,6 @@ export default class ActivitySerializer extends ApplicationSerializer {
non_entity_clients: non_entity_tokens, non_entity_clients: non_entity_tokens,
}; };
} }
// TODO CMB: test what to return if neither key exists
return object; return object;
} }
@@ -98,9 +93,8 @@ export default class ActivitySerializer extends ApplicationSerializer {
let transformedPayload = { let transformedPayload = {
...payload, ...payload,
response_timestamp, response_timestamp,
by_namespace: this.flattenDataset(payload.data.by_namespace), by_namespace: this.formatByNamespace(payload.data.by_namespace),
by_month_total_clients: this.flattenByMonths(payload.data.months), by_month: this.formatByMonths(payload.data.months),
by_month_new_clients: this.flattenByMonths(payload.data.months, { isNewClients: true }),
total: this.homogenizeClientNaming(payload.data.total), total: this.homogenizeClientNaming(payload.data.total),
formatted_end_time: parseRFC3339(payload.data.end_time), formatted_end_time: parseRFC3339(payload.data.end_time),
formatted_start_time: parseRFC3339(payload.data.start_time), formatted_start_time: parseRFC3339(payload.data.start_time),

View File

@@ -2,30 +2,28 @@ import ApplicationSerializer from '../application';
import { formatISO } from 'date-fns'; import { formatISO } from 'date-fns';
export default class MonthlySerializer extends ApplicationSerializer { export default class MonthlySerializer extends ApplicationSerializer {
flattenDataset(namespaceArray) { flattenDataset(object) {
let flattenedObject = {};
Object.keys(object['counts']).forEach((key) => (flattenedObject[key] = object['counts'][key]));
return this.homogenizeClientNaming(flattenedObject);
}
formatByNamespace(namespaceArray) {
return namespaceArray?.map((ns) => { return namespaceArray?.map((ns) => {
// 'namespace_path' is an empty string for root // 'namespace_path' is an empty string for root
if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root'; if (ns['namespace_id'] === 'root') ns['namespace_path'] = 'root';
let label = ns['namespace_path']; let label = ns['namespace_path'];
let flattenedNs = {}; let flattenedNs = this.flattenDataset(ns);
// we don't want client counts nested within the 'counts' object for stacked charts
Object.keys(ns['counts']).forEach((key) => (flattenedNs[key] = ns['counts'][key]));
flattenedNs = this.homogenizeClientNaming(flattenedNs);
// if no mounts, mounts will be an empty array // if no mounts, mounts will be an empty array
flattenedNs.mounts = ns.mounts flattenedNs.mounts = [];
? ns.mounts.map((mount) => { if (ns?.mounts && ns.mounts.length > 0) {
let flattenedMount = {}; flattenedNs.mounts = ns.mounts.map((mount) => {
let label = mount['mount_path'];
Object.keys(mount['counts']).forEach((key) => (flattenedMount[key] = mount['counts'][key]));
flattenedMount = this.homogenizeClientNaming(flattenedMount);
return { return {
label, label: mount['mount_path'],
...flattenedMount, ...this.flattenDataset(mount),
}; };
}) });
: []; }
return { return {
label, label,
...flattenedNs, ...flattenedNs,
@@ -67,7 +65,7 @@ export default class MonthlySerializer extends ApplicationSerializer {
let newClientsData = payload.data.months[0]?.new_clients || null; let newClientsData = payload.data.months[0]?.new_clients || null;
let by_namespace_new_clients, new_clients; let by_namespace_new_clients, new_clients;
if (newClientsData) { if (newClientsData) {
by_namespace_new_clients = this.flattenDataset(newClientsData.namespaces); by_namespace_new_clients = this.formatByNamespace(newClientsData.namespaces);
new_clients = this.homogenizeClientNaming(newClientsData.counts); new_clients = this.homogenizeClientNaming(newClientsData.counts);
} else { } else {
by_namespace_new_clients = []; by_namespace_new_clients = [];
@@ -76,7 +74,7 @@ export default class MonthlySerializer extends ApplicationSerializer {
let transformedPayload = { let transformedPayload = {
...payload, ...payload,
response_timestamp, response_timestamp,
by_namespace_total_clients: this.flattenDataset(payload.data.by_namespace), by_namespace_total_clients: this.formatByNamespace(payload.data.by_namespace),
by_namespace_new_clients, by_namespace_new_clients,
// nest within 'total' object to mimic /activity response shape // nest within 'total' object to mimic /activity response shape
total: this.homogenizeClientNaming(payload.data), total: this.homogenizeClientNaming(payload.data),

View File

@@ -54,9 +54,15 @@
</ToolbarFilters> </ToolbarFilters>
</Toolbar> </Toolbar>
</div> </div>
{{! TODO CMB this warning should only show if an upgrade to 1.9 or 1.10 happened in current month }}
{{#if this.countsIncludeOlderData}} {{#if this.countsIncludeOlderData}}
<AlertBanner @type="warning" @title="Warning"> <AlertBanner @type="warning" @title="Warning">
{{concat "You upgraded to Vault " this.firstUpgradeVersion " on " (date-format this.upgradeDate "MMMM d, yyyy.")}} {{concat
"You upgraded to Vault "
this.latestUpgradeData.id
" on "
(date-format this.latestUpgradeData.timestampInstalled "MMMM d, yyyy.")
}}
How we count clients changed in 1.9, so please keep that in mind when looking at the data below, and you can How we count clients changed in 1.9, so please keep that in mind when looking at the data below, and you can
<DocLink @path="/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"> <DocLink @path="/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts">
read more here. read more here.

View File

@@ -108,13 +108,14 @@
{{#if this.responseRangeDiffMessage}} {{#if this.responseRangeDiffMessage}}
<li>{{this.responseRangeDiffMessage}}</li> <li>{{this.responseRangeDiffMessage}}</li>
{{/if}} {{/if}}
{{! TODO this warning should only show if data spans an upgrade to 1.9 or 1.10 }}
{{#if this.countsIncludeOlderData}} {{#if this.countsIncludeOlderData}}
<li> <li>
{{concat {{concat
"You upgraded to Vault " "You upgraded to Vault "
this.firstUpgradeVersion this.latestUpgradeData.id
" on " " on "
(date-format this.upgradeDate "MMMM d, yyyy.") (date-format this.latestUpgradeData.timestampInstalled "MMMM d, yyyy.")
}} }}
How we count clients changed in 1.9, so please keep that in mind when looking at the data below, and you can How we count clients changed in 1.9, so please keep that in mind when looking at the data below, and you can
<DocLink <DocLink
@@ -140,6 +141,7 @@
@lineChartData={{this.byMonthTotalClients}} @lineChartData={{this.byMonthTotalClients}}
@barChartData={{this.byMonthNewClients}} @barChartData={{this.byMonthNewClients}}
@runningTotals={{this.totalUsageCounts}} @runningTotals={{this.totalUsageCounts}}
@upgradeData={{if this.countsIncludeOlderData this.latestUpgradeData}}
@timestamp={{this.responseTimestamp}} @timestamp={{this.responseTimestamp}}
/> />
{{/if}} {{/if}}

View File

@@ -2,8 +2,8 @@
data-test-line-chart data-test-line-chart
class="chart has-grid" class="chart has-grid"
{{on "mouseleave" this.removeTooltip}} {{on "mouseleave" this.removeTooltip}}
{{did-insert this.renderChart @dataset}} {{did-insert this.renderChart @dataset @upgradeData}}
{{did-update this.renderChart @dataset}} {{did-update this.renderChart @dataset @upgradeData}}
> >
</svg> </svg>
@@ -24,6 +24,10 @@
<p class="bold">{{this.tooltipMonth}}</p> <p class="bold">{{this.tooltipMonth}}</p>
<p>{{this.tooltipTotal}}</p> <p>{{this.tooltipTotal}}</p>
<p>{{this.tooltipNew}}</p> <p>{{this.tooltipNew}}</p>
{{#if this.tooltipUpgradeText}}
<br />
<p class="has-text-highlight">{{this.tooltipUpgradeText}}</p>
{{/if}}
</div> </div>
<div class="chart-tooltip-arrow"></div> <div class="chart-tooltip-arrow"></div>
{{/modal-dialog}} {{/modal-dialog}}

Before

Width:  |  Height:  |  Size: 851 B

After

Width:  |  Height:  |  Size: 1014 B

View File

@@ -9,7 +9,7 @@
</div> </div>
<div class="chart-container-wide"> <div class="chart-container-wide">
<Clients::LineChart @dataset={{@lineChartData}} /> <Clients::LineChart @dataset={{@lineChartData}} @upgradeData={{@upgradeData}} />
</div> </div>
<div class="chart-subTitle"> <div class="chart-subTitle">

View File

@@ -2,6 +2,7 @@ import { format } from 'd3-format';
// COLOR THEME: // COLOR THEME:
export const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#1563FF']; export const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#1563FF'];
export const UPGRADE_WARNING = '#FDEEBA';
export const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1']; export const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1'];
export const GREY = '#EBEEF2'; export const GREY = '#EBEEF2';

View File

@@ -1,5 +1,20 @@
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
export const ARRAY_OF_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
// convert RFC3339 timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format // convert RFC3339 timestamp ( '2021-03-21T00:00:00Z' ) to date object, optionally format
export const parseAPITimestamp = (timestamp, style) => { export const parseAPITimestamp = (timestamp, style) => {
if (!timestamp) return; if (!timestamp) return;
@@ -18,3 +33,11 @@ export const parseRFC3339 = (timestamp) => {
let date = parseAPITimestamp(timestamp); let date = parseAPITimestamp(timestamp);
return date ? [`${date.getFullYear()}`, date.getMonth()] : null; return date ? [`${date.getFullYear()}`, date.getMonth()] : null;
}; };
// convert MM/yy (format of dates in charts) to 'Month yyyy' (format in tooltip)
export function formatChartDate(date) {
let array = date.split('/');
array.splice(1, 0, '01');
let dateString = array.join('/');
return format(new Date(dateString), 'MMMM yyyy');
}

View File

@@ -665,25 +665,7 @@ const handleMockQuery = (queryStartTimestamp, monthlyData) => {
i++; i++;
let timestamp = formatRFC3339(sub(startDateByMonth, { months: i })); let timestamp = formatRFC3339(sub(startDateByMonth, { months: i }));
// TODO CMB update this when we confirm what combined data looks like // TODO CMB update this when we confirm what combined data looks like
// this is probably not what the empty object looks like but waiting to hear back from backend transformedMonthlyArray.push({ timestamp });
transformedMonthlyArray.push({
timestamp,
counts: {
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
clients: 0,
},
namespaces: [],
new_clients: {
counts: {
entity_clients: 0,
non_entity_clients: 0,
clients: 0,
},
namespaces: [],
},
});
} while (i < differenceInCalendarMonths(startDateByMonth, queryDate)); } while (i < differenceInCalendarMonths(startDateByMonth, queryDate));
} }
if (isAfter(queryDate, startDateByMonth)) { if (isAfter(queryDate, startDateByMonth)) {
@@ -696,19 +678,19 @@ export default function (server) {
// 1.10 API response // 1.10 API response
server.get('sys/version-history', function () { server.get('sys/version-history', function () {
return { return {
keys: ['1.9.0', '1.9.1', '1.9.2'], keys: ['1.10.0', '1.9.0', '1.9.1', '1.9.2'],
key_info: { key_info: {
'1.9.0': { '1.9.0': {
previous_version: null, previous_version: null,
timestamp_installed: '2021-11-03T10:23:16Z', timestamp_installed: '2021-07-03T10:23:16Z',
}, },
'1.9.1': { '1.9.1': {
previous_version: '1.9.0', previous_version: '1.9.0',
timestamp_installed: '2021-12-03T10:23:16Z', timestamp_installed: '2021-09-03T10:23:16Z',
}, },
'1.9.2': { '1.10.0': {
previous_version: '1.9.1', previous_version: '1.9.1',
timestamp_installed: '2021-01-03T10:23:16Z', timestamp_installed: '2021-10-03T10:23:16Z',
}, },
}, },
}; };

View File

@@ -4,25 +4,11 @@ import { render, click } from '@ember/test-helpers';
import sinon from 'sinon'; import sinon from 'sinon';
import hbs from 'htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile';
import calendarDropdown from 'vault/tests/pages/components/calendar-widget'; import calendarDropdown from 'vault/tests/pages/components/calendar-widget';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
module('Integration | Component | calendar-widget', function (hooks) { module('Integration | Component | calendar-widget', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
const ARRAY_OF_MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.set('handleClientActivityQuery', sinon.spy()); this.set('handleClientActivityQuery', sinon.spy());
this.set('handleCurrentBillingPeriod', sinon.spy()); this.set('handleCurrentBillingPeriod', sinon.spy());