diff --git a/changelog/12437.txt b/changelog/12437.txt
new file mode 100644
index 000000000..d329e1b6b
--- /dev/null
+++ b/changelog/12437.txt
@@ -0,0 +1,3 @@
+```release-note:feature
+ui: creates bar chart component for displaying client count data by namespace
+```
\ No newline at end of file
diff --git a/ui/app/styles/components/bar-chart.scss b/ui/app/styles/components/bar-chart.scss
new file mode 100644
index 000000000..1cc9bc126
--- /dev/null
+++ b/ui/app/styles/components/bar-chart.scss
@@ -0,0 +1,68 @@
+.bar-chart-wrapper {
+ border: $light-border;
+ border-radius: $radius-large;
+ padding: $spacing-l $spacing-l $spacing-s $spacing-l;
+ height: 100%;
+ width: 100%;
+
+ > div.is-border {
+ border: 0.3px solid $ui-gray-200;
+ width: 94%;
+ margin-left: 3%;
+ margin-bottom: $spacing-xxs;
+ }
+}
+
+.chart-header {
+ margin-left: $spacing-l;
+ display: grid;
+ grid-template-columns: 3fr 1fr;
+
+ .header-left {
+ .chart-title {
+ font-size: $size-5;
+ font-weight: $font-weight-bold;
+ line-height: normal;
+ }
+
+ .chart-description {
+ font-size: $size-8;
+ font-weight: $font-weight-normal;
+ color: $ui-gray-700;
+ margin-bottom: $spacing-xs;
+ }
+ }
+
+ .header-right {
+ text-align: center;
+
+ > button {
+ font-size: $size-8;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
+
+.bar-chart-container {
+ padding: $spacing-m $spacing-l $spacing-m $spacing-l;
+}
+
+.bar-chart {
+ .tick > text {
+ font-weight: $font-weight-semibold;
+ font-size: $size-8;
+ }
+}
+
+.legend-container {
+ height: $spacing-l;
+ margin-top: $spacing-xs;
+}
+
+.legend {
+ width: 100%;
+ height: 100%;
+}
diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss
index cc4400750..06b5f9a15 100644
--- a/ui/app/styles/core.scss
+++ b/ui/app/styles/core.scss
@@ -45,6 +45,7 @@
@import './components/auth-buttons';
@import './components/auth-form';
@import './components/b64-toggle';
+@import './components/bar-chart';
@import './components/box-label';
@import './components/box-radio';
@import './components/codemirror';
diff --git a/ui/lib/core/addon/components/bar-chart.js b/ui/lib/core/addon/components/bar-chart.js
new file mode 100644
index 000000000..3e58d3e26
--- /dev/null
+++ b/ui/lib/core/addon/components/bar-chart.js
@@ -0,0 +1,327 @@
+/**
+ * @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
+ *
+ * />
+ *
+ *
+ * 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 {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 BAR_COLOR_DEFAULT = ['#BFD4FF', '#8AB1FF'];
+const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1'];
+const BACKGROUND_BAR_COLOR = '#EBEEF2';
+const TOOLTIP_BACKGROUND = '#525761';
+
+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;
+ }
+
+ get dataset() {
+ return this.args.dataset || null;
+ }
+
+ 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) {
+ let elementId = guidFor(element);
+ let totalCount = this.dataset.reduce((prevValue, currValue) => prevValue + currValue.total, 0);
+ let handleClick = this.args.onClick;
+ let labelKey = this.labelKey;
+ let dataset = this.dataset;
+ 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: fixed; 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')
+ .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) => BAR_COLOR_DEFAULT[i]);
+
+ let yAxis = axisLeft(yScale);
+ yAxis(groups.append('g'));
+
+ let truncate = selection =>
+ selection.text(string =>
+ string.length < CHAR_LIMIT ? string : string.slice(0, CHAR_LIMIT - 3) + '...'
+ );
+
+ chartSvg.selectAll('.tick text').call(truncate);
+
+ let rects = groups
+ .selectAll('rect')
+ .data(d => d)
+ .enter()
+ .append('rect')
+ .attr('class', 'data-bar')
+ .style('cursor', 'pointer')
+ .attr('width', chartData => `${xScale(chartData[1] - chartData[0] - 5)}%`)
+ .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', `${BACKGROUND_BAR_COLOR}`)
+ .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) => `${BAR_COLOR_DEFAULT[i]}`);
+ })
+ .on('mousemove', function(chartData) {
+ select('.chart-tooltip')
+ .style('opacity', 1)
+ .style('max-width', '200px')
+ .style('left', `${event.pageX - 90}px`)
+ .style('top', `${event.pageY - 90}px`)
+ .text(
+ `${Math.round((chartData.total * 100) / totalCount)}% of total client counts:
+ ${chartData.distinct_entities} unique entities, ${chartData.non_entity_tokens} active tokens.
+ `
+ );
+ });
+
+ // 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) => `${BAR_COLOR_DEFAULT[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 - 100}px`)
+ .style('top', `${event.pageY - 50}px`)
+ .text(`${chartData.label}`)
+ .style('max-width', 'fit-content');
+ } else {
+ select('.chart-tooltip').style('opacity', 0);
+ }
+ });
+
+ // TODO: these render twice, need to only render and append once per line
+ // creates total count text and coordinates to display to the right of data bars
+ let totalCountData = [];
+ rects.each(function(d) {
+ let textDatum = {
+ total: d.data.total,
+ x: parseFloat(select(this).attr('width')) + parseFloat(select(this).attr('x')),
+ y: parseFloat(select(this).attr('y')) + parseFloat(select(this).attr('height')),
+ };
+ totalCountData.push(textDatum);
+ });
+
+ groups
+ .selectAll('text')
+ .data(totalCountData)
+ .enter()
+ .append('text')
+ .text(d => d.total)
+ .attr('fill', '#000')
+ .attr('class', 'total-value')
+ .style('font-size', '.8rem')
+ .attr('text-anchor', 'start')
+ .attr('y', d => `${d.y}`)
+ .attr('x', d => `${d.x + 1}%`);
+
+ // removes axes lines
+ groups.selectAll('.domain, .tick line').remove();
+
+ // TODO: make more flexible, make legend a div instead of svg?
+ // 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', `${BAR_COLOR_DEFAULT[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);
diff --git a/ui/lib/core/addon/templates/components/bar-chart.hbs b/ui/lib/core/addon/templates/components/bar-chart.hbs
new file mode 100644
index 000000000..0955be26e
--- /dev/null
+++ b/ui/lib/core/addon/templates/components/bar-chart.hbs
@@ -0,0 +1,28 @@
+
+
+
+
{{@title}}
+ {{#if @description}}
+
{{@description}}
+ {{/if}}
+
+
+ {{#if this.dataset}}
+ {{yield}}
+ {{/if}}
+
+
+ {{#unless this.dataset}}
+
+
+ {{else}}
+
+
+
+
+
+
+
+
+ {{/unless}}
+
diff --git a/ui/lib/core/app/components/bar-chart.js b/ui/lib/core/app/components/bar-chart.js
new file mode 100644
index 000000000..085613131
--- /dev/null
+++ b/ui/lib/core/app/components/bar-chart.js
@@ -0,0 +1 @@
+export { default } from 'core/components/bar-chart';
diff --git a/ui/lib/core/stories/bar-chart.md b/ui/lib/core/stories/bar-chart.md
new file mode 100644
index 000000000..1653f3c5a
--- /dev/null
+++ b/ui/lib/core/stories/bar-chart.md
@@ -0,0 +1,37 @@
+
+
+## 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 | string | | title of the chart |
+| mapLegend | array | | array of objects with key names 'key' and 'label' for the map legend |
+| dataset | object | | dataset for the chart |
+| [description] | string | | description of the chart |
+| [labelKey] | string | "label" | labelKey is the key name in the dataset passed in that corresponds to the value labeling the y-axis |
+| [onClick] | function | | takes function from parent and passes it to click event on data bars |
+
+**Example**
+
+```js
+
+ />
+
+
+ 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/master/ui/lib/core/addon/components/bar-chart.js)
+
+---
diff --git a/ui/lib/core/stories/bar-chart.stories.js b/ui/lib/core/stories/bar-chart.stories.js
new file mode 100644
index 000000000..382fbe4d3
--- /dev/null
+++ b/ui/lib/core/stories/bar-chart.stories.js
@@ -0,0 +1,99 @@
+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`
+
Bar Chart
+
+
dataset is passed to a function in the parent to format it appropriately for the chart. Any data passed should be flattened (not nested).
+
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 :)