UI/Client counts changelog 1.10 (#14166)

* adds changelog for client counts work

* capitalizes feature

* delete old client count files

* remove import from core.scss
This commit is contained in:
claire bontempo 2022-02-22 11:08:11 -08:00 committed by GitHub
parent 345857fa1b
commit 7c11323d71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 3 additions and 1171 deletions

View File

@ -0,0 +1,3 @@
```release-note:feature
**UI Client Count Improvements**: Restructures client count dashboard, making use of billing start date to improve accuracy. Adds mount-level distribution and filtering.
```

View File

@ -1,124 +0,0 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { format } from 'date-fns';
export default class HistoryComponent extends Component {
max_namespaces = 10;
@tracked selectedNamespace = null;
@tracked barChartSelection = false;
// Determine if we have client count data based on the current tab
get hasClientData() {
if (this.args.tab === 'current') {
// Show the current numbers as long as config is on
return this.args.model.config?.enabled !== 'Off';
}
return this.args.model.activity && this.args.model.activity.total;
}
// Show namespace graph only if we have more than 1
get showGraphs() {
return (
this.args.model.activity &&
this.args.model.activity.byNamespace &&
this.args.model.activity.byNamespace.length > 1
);
}
// Construct the namespace model for the search select component
get searchDataset() {
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
return null;
}
let dataList = this.args.model.activity.byNamespace;
return dataList.map((d) => {
return {
name: d['namespace_id'],
id: d['namespace_path'] === '' ? 'root' : d['namespace_path'],
};
});
}
// Construct the namespace model for the bar chart component
get barChartDataset() {
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
return null;
}
let dataset = this.args.model.activity.byNamespace.slice(0, this.max_namespaces);
return dataset.map((d) => {
return {
label: d['namespace_path'] === '' ? 'root' : d['namespace_path'],
non_entity_tokens: d['counts']['non_entity_tokens'],
distinct_entities: d['counts']['distinct_entities'],
total: d['counts']['clients'],
};
});
}
// Create namespaces data for csv format
get getCsvData() {
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
return null;
}
let results = '',
namespaces = this.args.model.activity.byNamespace,
fields = ['Namespace path', 'Active clients', 'Unique entities', 'Non-entity tokens'];
results = fields.join(',') + '\n';
namespaces.forEach(function (item) {
let path = item.namespace_path !== '' ? item.namespace_path : 'root',
total = item.counts.clients,
unique = item.counts.distinct_entities,
non_entity = item.counts.non_entity_tokens;
results += path + ',' + total + ',' + unique + ',' + non_entity + '\n';
});
return results;
}
// Return csv filename with start and end dates
get getCsvFileName() {
let defaultFileName = `clients-by-namespace`,
startDate =
this.args.model.queryStart || `${format(new Date(this.args.model.activity.startTime), 'MM-yyyy')}`,
endDate =
this.args.model.queryEnd || `${format(new Date(this.args.model.activity.endTime), 'MM-yyyy')}`;
if (startDate && endDate) {
defaultFileName += `-${startDate}-${endDate}`;
}
return defaultFileName;
}
// Get the namespace by matching the path from the namespace list
getNamespace(path) {
return this.args.model.activity.byNamespace.find((ns) => {
if (path === 'root') {
return ns.namespace_path === '';
}
return ns.namespace_path === path;
});
}
@action
selectNamespace(value) {
// In case of search select component, value returned is an array
if (Array.isArray(value)) {
this.selectedNamespace = this.getNamespace(value[0]);
this.barChartSelection = false;
} else if (typeof value === 'object') {
// While D3 bar selection returns an object
this.selectedNamespace = this.getNamespace(value.label);
this.barChartSelection = true;
}
}
@action
resetData() {
this.barChartSelection = false;
this.selectedNamespace = null;
}
}

View File

@ -1,121 +0,0 @@
/**
* @module PricingMetricsDates
* PricingMetricsDates components are used on the Pricing Metrics page to handle queries related to pricing metrics.
* This component assumes that query parameters (as in, from route params) are being passed in with the format MM-yyyy,
* while the inputs expect a format of MM/yyyy.
*
* @example
* ```js
* <PricingMetricsDates @resultStart="2020-03-01T00:00:00Z" @resultEnd="2020-08-31T23:59:59Z" @queryStart="03-2020" @queryEnd="08-2020" />
* ```
* @param {object} resultStart - resultStart is the start date of the metrics returned. Should be a valid date string that the built-in Date() fn can parse
* @param {object} resultEnd - resultEnd is the end date of the metrics returned. Should be a valid date string that the built-in Date() fn can parse
* @param {string} [queryStart] - queryStart is the route param (formatted MM-yyyy) that the result will be measured against for showing discrepancy warning
* @param {string} [queryEnd] - queryEnd is the route param (formatted MM-yyyy) that the result will be measured against for showing discrepancy warning
* @param {number} [defaultSpan=12] - setting for default time between start and end input dates
* @param {number} [retentionMonths=24] - setting for the retention months, which informs valid dates to query by
*/
import { set, computed } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { subMonths, startOfToday, format, endOfMonth, startOfMonth, isBefore } from 'date-fns';
import layout from '../templates/components/pricing-metrics-dates';
import { parseDateString } from 'vault/helpers/parse-date-string';
export default Component.extend({
layout,
router: service(),
queryStart: null,
queryEnd: null,
resultStart: null,
resultEnd: null,
start: null,
end: null,
defaultSpan: 12,
retentionMonths: 24,
startDate: computed('start', function () {
if (!this.start) return null;
let date;
try {
date = parseDateString(this.start, '/');
if (date) return date;
return null;
} catch (e) {
return null;
}
}),
endDate: computed('end', function () {
if (!this.end) return null;
let date;
try {
date = parseDateString(this.end, '/');
if (date) return endOfMonth(date);
return null;
} catch (e) {
return null;
}
}),
error: computed('end', 'endDate', 'retentionMonths', 'start', 'startDate', function () {
if (!this.startDate) {
return 'Start date is invalid. Please use format MM/yyyy';
}
if (!this.endDate) {
return 'End date is invalid. Please use format MM/yyyy';
}
if (isBefore(this.endDate, this.startDate)) {
return 'Start date is after end date';
}
const lastMonthAvailable = endOfMonth(subMonths(startOfToday(), 1));
if (isBefore(lastMonthAvailable, this.endDate)) {
return `Data is not available until the end of the month`;
}
const earliestRetained = startOfMonth(subMonths(lastMonthAvailable, this.retentionMonths));
if (isBefore(this.startDate, earliestRetained)) {
return `No data retained before ${format(earliestRetained, 'MM/yyyy')} due to your settings`;
}
return null;
}),
init() {
this._super(...arguments);
let initialEnd;
let initialStart;
initialEnd = subMonths(startOfToday(), 1);
if (this.queryEnd) {
initialEnd = parseDateString(this.queryEnd, '-');
} else {
// if query isn't passed in, set it so that showResultsWarning works
this.queryEnd = format(initialEnd, 'MM-yyyy');
}
initialStart = subMonths(initialEnd, this.defaultSpan);
if (this.queryStart) {
initialStart = parseDateString(this.queryStart, '-');
} else {
// if query isn't passed in, set it so that showResultsWarning works
this.queryStart = format(initialStart, 'MM-yyyy');
}
set(this, 'start', format(initialStart, 'MM/yyyy'));
set(this, 'end', format(initialEnd, 'MM/yyyy'));
},
actions: {
handleQuery() {
const start = format(this.startDate, 'MM-yyyy');
const end = format(this.endDate, 'MM-yyyy');
this.router.transitionTo('vault.cluster.clients', {
queryParams: {
start,
end,
},
});
},
},
});

View File

@ -1,4 +0,0 @@
.pricing-metrics-date-form {
display: flex;
align-items: flex-end;
}

View File

@ -82,7 +82,6 @@
@import './components/navigate-input';
@import './components/page-header';
@import './components/popup-menu';
@import './components/pricing-metrics-dates';
@import './components/radio-card';
@import './components/radial-progress';
@import './components/raft-join';

View File

@ -1,213 +0,0 @@
{{#if (and (eq @tab "history") (eq @model.config.queriesAvailable false))}}
{{#if (eq @model.config.enabled "On")}}
<EmptyState
@title="No monthly history"
@message="There is no data in the monthly history yet. We collect it at the end of each month, so your data will be available on the first of next month."
/>
{{else}}
<EmptyState
@title="Data tracking is disabled"
@message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
>
{{#if @model.config.configPath.canUpdate}}
<p>
<LinkTo @route="vault.cluster.clients.config">
Go to configuration
</LinkTo>
</p>
{{/if}}
</EmptyState>
{{/if}}
{{else}}
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
{{#if (eq @tab "current")}}
<h1 data-test-client-count-title class="title is-4 has-bottom-margin-s">
Current month
</h1>
<p class="has-bottom-margin-s">
The below data is for the current month starting from the first day. For historical data, see the monthly history
tab.
</p>
{{#if (eq @model.config.enabled "Off")}}
<EmptyState
@title="Tracking is disabled"
@message="Tracking is disabled and data is not being collected. To turn it on edit the configuration."
>
{{#if @model.config.configPath.canUpdate}}
<LinkTo @route="vault.cluster.clients.config">
Go to configuration
</LinkTo>
{{/if}}
</EmptyState>
{{/if}}
{{else}}
{{#if (eq @model.config.enabled "Off")}}
<AlertBanner data-test-tracking-disabled @type="warning" @title="Tracking is disabled">
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need
to
<LinkTo @route="vault.cluster.clients.edit">
edit the configuration
</LinkTo>
to enable tracking again.
</AlertBanner>
{{/if}}
<h1 data-test-client-count-title class="title is-4 has-bottom-margin-s">
Monthly history
</h1>
<p class="has-bottom-margin-s">
This data is presented by full month. If there is data missing, it's possible that tracking was turned off at the
time. Vault will only show data for contiguous blocks of time during which tracking was on.
</p>
<PricingMetricsDates
@queryStart={{@model.queryStart}}
@queryEnd={{@model.queryEnd}}
@resultStart={{@model.activity.startTime}}
@resultEnd={{@model.activity.endTime}}
@defaultSpan={{or @model.config.defaultReportMonths 12}}
@retentionMonths={{@model.config.retentionMonths}}
/>
{{/if}}
{{#if @isLoading}}
<LayoutLoading />
{{else if this.hasClientData}}
<div class="card has-bottom-margin-m">
<div class="card-content">
<div class="is-flex is-flex-center">
<div class="is-flex-1">
<h2 class="title is-5 is-marginless">
Total usage
</h2>
<p class="sub-text">
These totals are within this namespace and all its children.
</p>
</div>
<LearnLink @path="/tutorials/vault/usage-metrics">
Learn more
</LearnLink>
</div>
<hr />
<div class="columns">
<div class="column" data-test-client-count-stats>
<StatText
@label="Total active clients"
@value={{or @model.activity.clients @model.activity.total.clients "0"}}
@size="l"
@subText="The sum of unique entities and non-entity tokens; Vault's primary billing metric."
/>
</div>
<div class="column">
<StatText
class="column"
@label="Unique entities"
@value={{or @model.activity.distinct_entities @model.activity.total.distinct_entities "0"}}
@size="l"
@subText="Representation of a particular user, client or application that created a token via login."
/>
</div>
<div class="column">
<StatText
class="column"
@label="Non-entity tokens"
@value={{or @model.activity.non_entity_tokens @model.activity.total.non_entity_tokens "0"}}
@size="l"
@subText="Tokens created via a method that is not associated with an entity."
/>
</div>
</div>
</div>
</div>
{{#if this.showGraphs}}
<div class="columns has-bottom-margin-m" {{did-update this.resetData}} {{did-insert this.resetData}}>
<div class="column is-two-thirds" data-test-client-count-graph>
<BarChart
@title="Top 10 Namespaces"
@description="Each namespace's client count includes clients in child namespaces."
@dataset={{this.barChartDataset}}
@tooltipData={{or @model.activity.clients @model.activity.total.clients}}
@onClick={{action this.selectNamespace}}
@mapLegend={{array
(hash key="non_entity_tokens" label="Non-entity tokens")
(hash key="distinct_entities" label="Unique entities")
}}
>
<DownloadCsv
@label={{"Export all namespace data"}}
@csvData={{this.getCsvData}}
@fileName={{this.getCsvFileName}}
/>
</BarChart>
</div>
<div class="column">
<div class="card">
<div class="card-content">
{{#if (and this.barChartSelection this.selectedNamespace)}}
<label class="title is-5 has-bottom-margin-m">Single namespace</label>
<ul class="has-bottom-margin-l search-select-list">
<li class="search-select-list-item">
<div>
{{or this.selectedNamespace.namespace_path "root"}}
</div>
<div class="control">
<button type="button" class="button is-ghost" {{action "resetData"}}>
<Icon @name="trash" class="has-text-grey" />
</button>
</div>
</li>
</ul>
{{else}}
<SearchSelect
@id="namespaces"
@labelClass="title is-5"
@disallowNewItems={{true}}
@onChange={{action this.selectNamespace}}
@label="Single namespace"
@options={{or this.searchDataset (array)}}
@searchField="namespace_path"
@selectLimit={{1}}
/>
{{/if}}
{{#if this.selectedNamespace}}
<div class="columns">
<div class="column">
<StatText @label="Active clients" @value={{this.selectedNamespace.counts.clients}} @size="l" />
</div>
</div>
<div class="columns">
<div class="column">
<StatText
@label="Unique entities"
@value={{this.selectedNamespace.counts.distinct_entities}}
@size="m"
/>
</div>
<div class="column">
<StatText
@label="Non-entity tokens"
@value={{this.selectedNamespace.counts.non_entity_tokens}}
@size="m"
/>
</div>
</div>
{{else}}
<EmptyState
@title="No namespace selected"
@message="Click on a namespace in the Top 10 chart or type its name in the box to view it's individual client counts."
/>
{{/if}}
</div>
</div>
</div>
</div>
{{/if}}
{{else if (eq @tab "current")}}
{{#if (eq @model.config.enabled "On")}}
<EmptyState
@title="No data received"
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
/>
{{/if}}
{{else}}
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
{{/if}}
</div>
{{/if}}

View File

@ -1,46 +0,0 @@
<form onsubmit={{action "handleQuery"}}>
<div class="field-body pricing-metrics-date-form" data-test-pricing-metrics-form>
<div class="field is-narrow">
<label for="start" class="is-label">From</label>
<div class="control">
<Input
@type="string"
@value={{this.start}}
name="start"
class={{concat "input" (unless this.startDate " has-error")}}
autocomplete="off"
spellcheck="false"
data-test-start-input="true"
/>
</div>
</div>
<div class="field is-narrow">
<label for="end" class="is-label">Through</label>
<div class="control">
<Input
@type="string"
@value={{this.end}}
name="end"
class={{concat "input" (unless this.endDate " has-error")}}
autocomplete="off"
spellcheck="false"
data-test-end-input="true"
/>
</div>
</div>
<button disabled={{this.error}} type="submit" class="button">Query</button>
</div>
</form>
{{#if this.error}}
<FormError>{{this.error}}</FormError>
{{/if}}
<div class="box is-fullwidth is-shadowless">
{{#if (and this.resultStart this.resultEnd)}}
<h2 class="title is-4" data-test-pricing-result-dates>
{{date-format this.resultStart "MMM dd, yyyy" dateOnly=true}}
through
{{date-format this.resultEnd "MMM dd, yyyy" dateOnly=true}}
</h2>
{{/if}}
</div>

View File

@ -1,310 +0,0 @@
/**
* @module BarChart
* BarChart components are used to display data in the form of a stacked bar chart, with accompanying legend and tooltip. Anything passed into the block will display in the top right of the chart header.
*
* @example
* ```js
* <BarChartComponent @title="Top 10 Namespaces" @description="Each namespace's client count includes clients in child namespaces." @labelKey="namespace_path" @dataset={{this.testData}} @mapLegend={{ array (hash key="non_entity_tokens" label="Active direct tokens") (hash key="distinct_entities" label="Unique Entities") }} @onClick={{this.onClick}} >
* <button type="button" class="link">
* Export all namespace data
* </button>/>
* </BarChartComponent>
*
* mapLegendSample = [{
* key: "api_key_for_label",
* label: "Label Displayed on Legend"
* }]
* ```
*
* @param {string} title - title of the chart
* @param {array} mapLegend - array of objects with key names 'key' and 'label' for the map legend
* @param {object} dataset - dataset for the chart
* @param {any} tooltipData - misc. information needed to display tooltip (i.e. total clients from query params)
* @param {string} [description] - description of the chart
* @param {string} [labelKey=label] - labelKey is the key name in the dataset passed in that corresponds to the value labeling the y-axis
* @param {function} [onClick] - takes function from parent and passes it to click event on data bars
*
*/
import Component from '@glimmer/component';
import layout from '../templates/components/bar-chart';
import { setComponentTemplate } from '@ember/component';
import { assert } from '@ember/debug';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { scaleLinear, scaleBand } from 'd3-scale';
import { axisLeft } from 'd3-axis';
import { max } from 'd3-array';
import { stack } from 'd3-shape';
// eslint-disable-next-line no-unused-vars
import { select, event, selectAll } from 'd3-selection';
// eslint-disable-next-line no-unused-vars
import { transition } from 'd3-transition';
// SIZING CONSTANTS
const CHART_MARGIN = { top: 10, left: 137 }; // makes space for y-axis legend
const CHAR_LIMIT = 18; // character count limit for y-axis labels to trigger truncating
const LINE_HEIGHT = 24; // each bar w/ padding is 24 pixels thick
// COLOR THEME:
const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#8AB1FF'];
const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1'];
const TOOLTIP_BACKGROUND = '#525761';
const GREY = '#EBEEF2';
class BarChartComponent extends Component {
get labelKey() {
return this.args.labelKey || 'label';
}
get mapLegend() {
assert(
'map legend is required, must be an array of objects with key names of "key" and "label"',
this.hasLegend()
);
return this.args.mapLegend;
}
hasLegend() {
if (!this.args.mapLegend || !Array.isArray(this.args.mapLegend)) {
return false;
} else {
let legendKeys = this.args.mapLegend.map((obj) => Object.keys(obj));
return legendKeys.map((array) => array.includes('key', 'label')).every((element) => element === true);
}
}
@action
renderBarChart(element, args) {
let elementId = guidFor(element);
let dataset = args[0];
let totalCount = args[1];
let handleClick = this.args.onClick;
let labelKey = this.labelKey;
let stackFunction = stack().keys(this.mapLegend.map((l) => l.key));
// creates an array of data for each map legend key
// each array contains coordinates for each data bar
let stackedData = stackFunction(dataset);
// creates and appends tooltip
let container = select('.bar-chart-container');
container
.append('div')
.attr('class', 'chart-tooltip')
.attr('style', 'position: absolute; opacity: 0;')
.style('color', 'white')
.style('background', `${TOOLTIP_BACKGROUND}`)
.style('font-size', '.929rem')
.style('padding', '10px')
.style('border-radius', '4px');
let xScale = scaleLinear()
.domain([0, max(dataset.map((d) => d.total))])
.range([0, 75]); // 25% reserved for margins
let yScale = scaleBand()
.domain(dataset.map((d) => d[labelKey]))
.range([0, dataset.length * LINE_HEIGHT])
.paddingInner(0.765); // percent of the total width to reserve for padding between bars
let chartSvg = select(element);
chartSvg.attr('viewBox', `0 0 710 ${(dataset.length + 1) * LINE_HEIGHT}`);
chartSvg.attr('id', elementId);
// creates group for each array of stackedData
let groups = chartSvg
.selectAll('g')
.remove()
.exit()
.data(stackedData)
.enter()
.append('g')
// shifts chart to accommodate y-axis legend
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`)
.style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]);
let yAxis = axisLeft(yScale).tickSize(0);
yAxis(chartSvg.append('g').attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`));
let truncate = (selection) =>
selection.text((string) =>
string.length < CHAR_LIMIT ? string : string.slice(0, CHAR_LIMIT - 3) + '...'
);
chartSvg.selectAll('.tick text').call(truncate);
groups
.selectAll('rect')
// iterate through the stacked data and chart respectively
.data((stackedData) => stackedData)
.enter()
.append('rect')
.attr('class', 'data-bar')
.style('cursor', 'pointer')
.attr('width', (chartData) => `${xScale(chartData[1] - chartData[0]) - 0.25}%`)
.attr('height', yScale.bandwidth())
.attr('x', (chartData) => `${xScale(chartData[0])}%`)
.attr('y', ({ data }) => yScale(data[labelKey]))
.attr('rx', 3)
.attr('ry', 3);
let actionBars = chartSvg
.selectAll('.action-bar')
.data(dataset)
.enter()
.append('rect')
.style('cursor', 'pointer')
.attr('class', 'action-bar')
.attr('width', '100%')
.attr('height', `${LINE_HEIGHT}px`)
.attr('x', '0')
.attr('y', (chartData) => yScale(chartData[labelKey]))
.style('fill', `${GREY}`)
.style('opacity', '0')
.style('mix-blend-mode', 'multiply');
let yLegendBars = chartSvg
.selectAll('.label-bar')
.data(dataset)
.enter()
.append('rect')
.style('cursor', 'pointer')
.attr('class', 'label-action-bar')
.attr('width', CHART_MARGIN.left)
.attr('height', `${LINE_HEIGHT}px`)
.attr('x', '0')
.attr('y', (chartData) => yScale(chartData[labelKey]))
.style('opacity', '0')
.style('mix-blend-mode', 'multiply');
let dataBars = chartSvg.selectAll('rect.data-bar');
let actionBarSelection = chartSvg.selectAll('rect.action-bar');
let compareAttributes = (elementA, elementB, attr) =>
select(elementA).attr(`${attr}`) === elementB.getAttribute(`${attr}`);
// handles click and mouseover/out/move event for data bars
actionBars
.on('click', function (chartData) {
if (handleClick) {
handleClick(chartData);
}
})
.on('mouseover', function () {
select(this).style('opacity', 1);
dataBars
.filter(function () {
return compareAttributes(this, event.target, 'y');
})
.style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`);
select('.chart-tooltip').transition().duration(200).style('opacity', 1);
})
.on('mouseout', function () {
select(this).style('opacity', 0);
select('.chart-tooltip').style('opacity', 0);
dataBars
.filter(function () {
return compareAttributes(this, event.target, 'y');
})
.style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`);
})
.on('mousemove', function (chartData) {
select('.chart-tooltip')
.style('opacity', 1)
.style('max-width', '200px')
.style('left', `${event.pageX - 325}px`)
.style('top', `${event.pageY - 140}px`)
.text(
`${Math.round((chartData.total * 100) / totalCount)}% of total client counts:
${chartData.non_entity_tokens} non-entity tokens, ${chartData.distinct_entities} unique entities.
`
);
});
// handles mouseover/out/move event for y-axis legend
yLegendBars
.on('click', function (chartData) {
if (handleClick) {
handleClick(chartData);
}
})
.on('mouseover', function (chartData) {
dataBars
.filter(function () {
return compareAttributes(this, event.target, 'y');
})
.style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`);
actionBarSelection
.filter(function () {
return compareAttributes(this, event.target, 'y');
})
.style('opacity', '1');
if (chartData.label.length >= CHAR_LIMIT) {
select('.chart-tooltip').transition().duration(200).style('opacity', 1);
}
})
.on('mouseout', function () {
select('.chart-tooltip').style('opacity', 0);
dataBars
.filter(function () {
return compareAttributes(this, event.target, 'y');
})
.style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`);
actionBarSelection
.filter(function () {
return compareAttributes(this, event.target, 'y');
})
.style('opacity', '0');
})
.on('mousemove', function (chartData) {
if (chartData.label.length >= CHAR_LIMIT) {
select('.chart-tooltip')
.style('left', `${event.pageX - 300}px`)
.style('top', `${event.pageY - 100}px`)
.text(`${chartData.label}`)
.style('max-width', 'fit-content');
} else {
select('.chart-tooltip').style('opacity', 0);
}
});
chartSvg
.append('g')
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top + 2})`)
.selectAll('text')
.data(dataset)
.enter()
.append('text')
.text((d) => d.total)
.attr('fill', '#000')
.attr('class', 'total-value')
.style('font-size', '.8rem')
.attr('text-anchor', 'start')
.attr('alignment-baseline', 'mathematical')
.attr('x', (chartData) => `${xScale(chartData.total)}%`)
.attr('y', (chartData) => yScale(chartData.label));
chartSvg.select('.domain').remove();
// TODO: if mapLegend has more than 4 keys, y attrs ('cy' and 'y') will need to be set to a variable. Currently map keys are centered in the legend SVG (50%)
// each map key symbol & label takes up 20% of legend SVG width
let startingXCoordinate = 100 - this.mapLegend.length * 20; // subtract from 100% to find starting x-coordinate
let legendSvg = select('.legend');
this.mapLegend.map((legend, i) => {
let xCoordinate = startingXCoordinate + i * 20;
legendSvg
.append('circle')
.attr('cx', `${xCoordinate}%`)
.attr('cy', '50%')
.attr('r', 6)
.style('fill', `${LIGHT_AND_DARK_BLUE[i]}`);
legendSvg
.append('text')
.attr('x', `${xCoordinate + 2}%`)
.attr('y', '50%')
.text(`${legend.label}`)
.style('font-size', '.8rem')
.attr('alignment-baseline', 'middle');
});
}
}
export default setComponentTemplate(layout, BarChartComponent);

View File

@ -1,32 +0,0 @@
<div data-test-bar-chart class="bar-chart-wrapper">
<div class="chart-header has-bottom-margin-l">
<div class="header-left">
<h2 class="chart-title">{{@title}}</h2>
{{#if @description}}
<p class="chart-description">{{@description}}</p>
{{/if}}
</div>
<div class="header-right">
{{#if @dataset}}
{{yield}}
{{/if}}
</div>
</div>
{{#if @dataset}}
<div class="is-border"></div>
<div class="bar-chart-container">
<svg
{{did-insert this.renderBarChart @dataset @tooltipData}}
{{did-update this.renderBarChart @dataset @tooltipData}}
class="bar-chart"
></svg>
</div>
<div class="is-border"></div>
<div class="legend-container">
<svg class="legend"></svg>
</div>
{{else}}
<div class="toolbar"></div>
<EmptyState @title="No namespace data" @message="There is no data to display for namespaces." />
{{/if}}
</div>

View File

@ -1 +0,0 @@
export { default } from 'core/components/bar-chart';

View File

@ -1,37 +0,0 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in lib/core/addon/components/bar-chart.js. To make changes, first edit that file and run "yarn gen-story-md bar-chart" to re-generate the content.-->
## BarChart
BarChart components are used to display data in the form of a stacked bar chart, with accompanying legend and tooltip. Anything passed into the block will display in the top right of the chart header.
**Params**
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| title | <code>string</code> | | title of the chart |
| mapLegend | <code>array</code> | | array of objects with key names 'key' and 'label' for the map legend |
| dataset | <code>object</code> | | dataset for the chart |
| [description] | <code>string</code> | | description of the chart |
| [labelKey] | <code>string</code> | <code>&quot;label&quot;</code> | labelKey is the key name in the dataset passed in that corresponds to the value labeling the y-axis |
| [onClick] | <code>function</code> | | takes function from parent and passes it to click event on data bars |
**Example**
```js
<BarChartComponent @title="Top 10 Namespaces" @description="Each namespace's client count includes clients in child namespaces." @labelKey="namespace_path" @dataset={{this.testData}} @mapLegend={{ array (hash key="non_entity_tokens" label="Active direct tokens") (hash key="distinct_entities" label="Unique Entities") }} @onClick={{this.onClick}} >
<button type="button" class="link">
Export all namespace data
</button>/>
</BarChartComponent>
mapLegendSample = [{
key: "api_key_for_label",
label: "Label Displayed on Legend"
}]
```
**See**
- [Uses of BarChart](https://github.com/hashicorp/vault/search?l=Handlebars&q=BarChart+OR+bar-chart)
- [BarChart Source Code](https://github.com/hashicorp/vault/blob/main/ui/lib/core/addon/components/bar-chart.js)
---

View File

@ -1,99 +0,0 @@
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import { object, text, withKnobs } from '@storybook/addon-knobs';
import notes from './bar-chart.md';
const dataset = [
{
namespace_id: 'root',
namespace_path: 'root',
counts: {
distinct_entities: 268,
non_entity_tokens: 985,
clients: 1253,
},
},
{
namespace_id: 'O0i4m',
namespace_path: 'top-namespace',
counts: {
distinct_entities: 648,
non_entity_tokens: 220,
clients: 868,
},
},
{
namespace_id: '1oihz',
namespace_path: 'anotherNamespace',
counts: {
distinct_entities: 547,
non_entity_tokens: 337,
clients: 884,
},
},
{
namespace_id: '1oihz',
namespace_path: 'someOtherNamespaceawgagawegawgawgawgaweg',
counts: {
distinct_entities: 807,
non_entity_tokens: 234,
clients: 1041,
},
},
];
const flattenData = () => {
return dataset.map((d) => {
return {
label: d['namespace_path'],
non_entity_tokens: d['counts']['non_entity_tokens'],
distinct_entities: d['counts']['distinct_entities'],
total: d['counts']['clients'],
};
});
};
storiesOf('BarChart', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs())
.add(
`BarChart`,
() => ({
template: hbs`
<h5 class="title is-5">Bar Chart</h5>
<p> <code>dataset</code> is passed to a function in the parent to format it appropriately for the chart. Any data passed should be flattened (not nested).</p>
<p> The legend typically displays within the bar chart border, below the second grey divider. There is also a tooltip that pops up when hovering over the data bars and overflowing labels. Gotta love storybook :) </p>
<div class="chart-container" style="margin-top:24px; max-width:750px; max-height:500px;" >
<BarChart
@title={{title}}
@description={{description}}
@dataset={{dataset}}
@mapLegend={{array
(hash key="non_entity_tokens" label="Active direct tokens")
(hash key="distinct_entities" label="Unique Entities")}}
>
<button type="button" class="link">
Export all namespace data
</button>
</BarChart>
<br>
<h6 class="title is-6">Legend:</h6>
<svg class="legend">
<circle cx="60%" cy="10%" r="6" style="fill: rgb(191, 212, 255);"></circle>
<text x="62%" y="10%" alignment-baseline="middle" style="font-size: 0.8rem;">Active direct tokens</text>
<circle cx="80%" cy="10%" r="6" style="fill: rgb(138, 177, 255);"></circle>
<text x="82%" y="10%" alignment-baseline="middle" style="font-size: 0.8rem;">Unique Entities</text></svg>
</div>
`,
context: {
title: text('title', 'Top Namespaces'),
description: text(
'description',
'Each namespaces client count includes clients in child namespaces.'
),
dataset: object('dataset', flattenData()),
},
}),
{ notes }
);

View File

@ -1,85 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | bar-chart', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
let dataset = [
{
namespace_id: 'root',
namespace_path: 'root',
counts: {
distinct_entities: 268,
non_entity_tokens: 985,
clients: 1253,
},
},
{
namespace_id: 'O0i4m',
namespace_path: 'top-namespace',
counts: {
distinct_entities: 648,
non_entity_tokens: 220,
clients: 868,
},
},
{
namespace_id: '1oihz',
namespace_path: 'anotherNamespace',
counts: {
distinct_entities: 547,
non_entity_tokens: 337,
clients: 884,
},
},
{
namespace_id: '1oihz',
namespace_path: 'someOtherNamespaceawgagawegawgawgawgaweg',
counts: {
distinct_entities: 807,
non_entity_tokens: 234,
clients: 1041,
},
},
];
let flattenData = () => {
return dataset.map((d) => {
return {
label: d['namespace_path'],
non_entity_tokens: d['counts']['non_entity_tokens'],
distinct_entities: d['counts']['distinct_entities'],
total: d['counts']['clients'],
};
});
};
this.set('title', 'Top Namespaces');
this.set('description', 'Each namespaces client count includes clients in child namespaces.');
this.set('dataset', flattenData());
await render(hbs`
<BarChart
@title={{this.title}}
@description={{this.description}}
@dataset={{this.dataset}}
@mapLegend={{array
(hash key="non_entity_tokens" label="Active direct tokens")
(hash key="distinct_entities" label="Unique Entities")}}
>
<button type="button" class="link">
Export all namespace data
</button>
</BarChart>
`);
assert.dom('[data-test-bar-chart]').exists('bar chart renders');
assert.dom('[data-test-bar-chart] .chart-title').hasText('Top Namespaces', 'displays title');
assert
.dom('[data-test-bar-chart] .chart-description')
.hasText('Each namespaces client count includes clients in child namespaces.', 'displays description');
assert.dom('.data-bar').exists({ count: 8 }, 'bars render');
});
});

View File

@ -1,98 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { subMonths, startOfToday, format, endOfMonth } from 'date-fns';
module('Integration | Component | pricing-metrics-dates', function (hooks) {
setupRenderingTest(hooks);
test('by default it sets the start and end inputs', async function (assert) {
const expectedEnd = subMonths(startOfToday(), 1);
const expectedStart = subMonths(expectedEnd, 12);
await render(hbs`
<PricingMetricsDates />
`);
assert.dom('[data-test-end-input]').hasValue(format(expectedEnd, 'MM/yyyy'), 'End input is last month');
assert
.dom('[data-test-start-input]')
.hasValue(format(expectedStart, 'MM/yyyy'), 'Start input is 12 months before last month');
});
test('On init if end date passed, start is calculated', async function (assert) {
const expectedStart = subMonths(new Date(2020, 8, 15), 12);
this.set('queryEnd', '09-2020');
await render(hbs`
<PricingMetricsDates @queryEnd={{queryEnd}} />
`);
assert.dom('[data-test-end-input]').hasValue('09/2020', 'End input matches query');
assert
.dom('[data-test-start-input]')
.hasValue(format(expectedStart, 'MM/yyyy'), 'Start input is 12 months before end input');
});
test('On init if query start date passed, end is default', async function (assert) {
const expectedEnd = subMonths(startOfToday(), 1);
this.set('queryStart', '01-2020');
await render(hbs`
<PricingMetricsDates @queryStart={{queryStart}} />
`);
assert.dom('[data-test-end-input]').hasValue(format(expectedEnd, 'MM/yyyy'), 'End input is last month');
assert.dom('[data-test-start-input]').hasValue('01/2020', 'Start input matches query');
});
test('If result and query dates are within 1 day, warning is not shown', async function (assert) {
this.set('resultStart', new Date(2020, 1, 1));
this.set('resultEnd', new Date(2020, 9, 31));
await render(hbs`
<PricingMetricsDates
@queryStart="2-2020"
@queryEnd="10-2020"
@resultStart={{resultStart}}
@resultEnd={{resultEnd}}
/>
`);
assert.dom('[data-test-results-date-warning]').doesNotExist('Does not show result states warning');
});
test('it shows appropriate errors on input form', async function (assert) {
const lastAvailable = endOfMonth(subMonths(startOfToday(), 1));
const firstAvailable = subMonths(lastAvailable, 12);
await render(hbs`
<PricingMetricsDates @retentionMonths=12 @defaultSpan=6 />
`);
assert.dom('[data-test-form-error]').doesNotExist('No form error shows by default');
await fillIn('[data-test-start-input]', format(subMonths(firstAvailable, 1), 'MM/yyyy'));
assert
.dom('[data-test-form-error]')
.includesText(
`No data retained before ${format(firstAvailable, 'MM/yyyy')}`,
'shows the correct error message for starting before the configured retainment period'
);
await fillIn('[data-test-end-input]', format(subMonths(lastAvailable, -1), 'MM/yyyy'));
assert
.dom('[data-test-form-error]')
.includesText(
'Data is not available until the end of the month',
'shows the correct error message for ending after the end of the last month'
);
await fillIn('[data-test-end-input]', 'not/date');
assert
.dom('[data-test-form-error]')
.includesText(
'End date is invalid. Please use format MM/yyyy',
'shows the correct error message for non-date input'
);
await fillIn('[data-test-start-input]', `13/${format(lastAvailable, 'yyyy')}`);
assert
.dom('[data-test-form-error]')
.includesText(
'Start date is invalid. Please use format MM/yyyy',
'shows the correct error message for an invalid month'
);
});
});