diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js new file mode 100644 index 0000000000000..7df0575caddad --- /dev/null +++ b/ui/app/components/clients/attribution.js @@ -0,0 +1,45 @@ +import Component from '@glimmer/component'; + +// TODO: fill out below!! +/** + * @module Attribution + * Attribution components are used to... + * + * @example + * ```js + * + * Pass in export button + * + * ``` + * @param {object} requiredParam - requiredParam is... + * @param {string} [optionalParam] - optionalParam is... + * @param {string} [param1=defaultValue] - param1 is... + */ + +export default class Attribution extends Component { + get dateRange() { + // some conditional that returns "date range" or "month" depending on what the params are + return 'date range'; + } + + get chartText() { + // something that determines if data is by namespace or by auth method + // and returns text + // if byNamespace + return { + description: + 'This data shows the top ten namespaces by client count and can be used to understand where clients are originating. Namespaces are identified by path. To see all namespaces, export this data.', + newCopy: `The new clients in the namespace for this ${this.dateRange}. + This aids in understanding which namespaces create and use new clients + ${this.dateRange === 'date range' ? ' over time.' : '.'}`, + totalCopy: `The total clients in the namespace for this ${this.dateRange}. This number is useful for identifying overall usage volume.`, + }; + // if byAuthMethod + // return + // byAuthMethod = { + // description: "This data shows the top ten authentication methods by client count within this namespace, and can be used to understand where new clients and total clients are originating. Authentication methods are organized by path.", + // newCopy: `The new clients used by the auth method for this {{@range}}. This aids in understanding which auth methods create and use new clients ${this.dateRange === "date range" ? " over time." : "."}`, + // totalCopy: `The total clients used by the auth method for this ${this.dateRange}. This number is useful for identifying overall usage volume. ` + // } + } +} diff --git a/ui/app/components/clients/dashboard.js b/ui/app/components/clients/dashboard.js new file mode 100644 index 0000000000000..8d464f32fa821 --- /dev/null +++ b/ui/app/components/clients/dashboard.js @@ -0,0 +1,182 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { format } from 'date-fns'; + +export default class Dashboard extends Component { + maxNamespaces = 10; + chartLegend = [ + { key: 'distinct_entities', label: 'unique entities' }, + { key: 'non_entity_tokens', label: 'non-entity tokens' }, + ]; + @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.maxNamespaces); + return dataset.map((d) => { + return { + label: d['namespace_path'] === '' ? 'root' : d['namespace_path'], + // the order here determines which data is the left bar and which is the right + distinct_entities: d['counts']['distinct_entities'], + non_entity_tokens: d['counts']['non_entity_tokens'], + total: d['counts']['clients'], + }; + }); + } + + // TODO: dataset for line chart + get lineChartData() { + return [ + { month: '1/21', clients: 100, new: 100 }, + { month: '2/21', clients: 300, new: 200 }, + { month: '3/21', clients: 300, new: 0 }, + { month: '4/21', clients: 300, new: 0 }, + { month: '5/21', clients: 300, new: 0 }, + { month: '6/21', clients: 300, new: 0 }, + { month: '7/21', clients: 300, new: 0 }, + { month: '8/21', clients: 350, new: 50 }, + { month: '9/21', clients: 400, new: 50 }, + { month: '10/21', clients: 450, new: 50 }, + { month: '11/21', clients: 500, new: 50 }, + { month: '12/21', clients: 1000, new: 1000 }, + ]; + } + + // TODO: dataset for new monthly clients vertical bar chart (manage in serializer?) + get newMonthlyClients() { + return [ + { month: 'January', distinct_entities: 1000, non_entity_tokens: 322, total: 1322 }, + { month: 'February', distinct_entities: 1500, non_entity_tokens: 122, total: 1622 }, + { month: 'March', distinct_entities: 4300, non_entity_tokens: 700, total: 5000 }, + { month: 'April', distinct_entities: 1550, non_entity_tokens: 229, total: 1779 }, + { month: 'May', distinct_entities: 5560, non_entity_tokens: 124, total: 5684 }, + { month: 'June', distinct_entities: 1570, non_entity_tokens: 142, total: 1712 }, + { month: 'July', distinct_entities: 300, non_entity_tokens: 112, total: 412 }, + { month: 'August', distinct_entities: 1610, non_entity_tokens: 130, total: 1740 }, + { month: 'September', distinct_entities: 1900, non_entity_tokens: 222, total: 2122 }, + { month: 'October', distinct_entities: 500, non_entity_tokens: 166, total: 666 }, + { month: 'November', distinct_entities: 480, non_entity_tokens: 132, total: 612 }, + { month: 'December', distinct_entities: 980, non_entity_tokens: 202, total: 1182 }, + ]; + } + + // TODO: dataset for vault usage vertical bar chart (manage in serializer?) + get monthlyUsage() { + return [ + { month: 'January', distinct_entities: 1000, non_entity_tokens: 322, total: 1322 }, + { month: 'February', distinct_entities: 1500, non_entity_tokens: 122, total: 1622 }, + { month: 'March', distinct_entities: 4300, non_entity_tokens: 700, total: 5000 }, + { month: 'April', distinct_entities: 1550, non_entity_tokens: 229, total: 1779 }, + { month: 'May', distinct_entities: 5560, non_entity_tokens: 124, total: 5684 }, + { month: 'June', distinct_entities: 1570, non_entity_tokens: 142, total: 1712 }, + { month: 'July', distinct_entities: 300, non_entity_tokens: 112, total: 412 }, + { month: 'August', distinct_entities: 1610, non_entity_tokens: 130, total: 1740 }, + { month: 'September', distinct_entities: 1900, non_entity_tokens: 222, total: 2122 }, + { month: 'October', distinct_entities: 500, non_entity_tokens: 166, total: 666 }, + { month: 'November', distinct_entities: 480, non_entity_tokens: 132, total: 612 }, + { month: 'December', distinct_entities: 980, non_entity_tokens: 202, total: 1182 }, + ]; + } + + // 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; + } +} diff --git a/ui/app/components/clients/horizontal-bar-chart.js b/ui/app/components/clients/horizontal-bar-chart.js new file mode 100644 index 0000000000000..125caccf4a09b --- /dev/null +++ b/ui/app/components/clients/horizontal-bar-chart.js @@ -0,0 +1,225 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { stack } from 'd3-shape'; +// eslint-disable-next-line no-unused-vars +import { select, event, selectAll } from 'd3-selection'; +import { scaleLinear, scaleBand } from 'd3-scale'; +import { axisLeft } from 'd3-axis'; +import { max, maxIndex } from 'd3-array'; +import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE } from '../../utils/chart-helpers'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module HorizontalBarChart + * HorizontalBarChart components are used to display data in the form of a horizontal, stacked bar chart with accompanying tooltip. + * + * @example + * ```js + * + * ``` + * @param {array} dataset - dataset for the chart, must be an array of flattened objects + * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked + */ + +// TODO: delete original bar chart component + +// SIZING CONSTANTS +const CHART_MARGIN = { top: 10, left: 95 }; // makes space for y-axis legend +const TRANSLATE = { down: 13 }; +const CHAR_LIMIT = 15; // character count limit for y-axis labels to trigger truncating +const LINE_HEIGHT = 24; // each bar w/ padding is 24 pixels thick + +export default class HorizontalBarChart extends Component { + @tracked tooltipTarget = ''; + @tracked tooltipText = ''; + + get labelKey() { + return this.args.labelKey || 'label'; + } + + get chartLegend() { + return this.args.chartLegend; + } + + get topNamespace() { + return this.args.dataset[maxIndex(this.args.dataset, (d) => d.total)]; + } + + @action removeTooltip() { + this.tooltipTarget = null; + } + + @action + renderChart(element, args) { + // chart legend tells stackFunction how to stack/organize data + // creates an array of data for each key name + // each array contains coordinates for each data bar + let stackFunction = stack().keys(this.chartLegend.map((l) => l.key)); + let dataset = args[0]; + let stackedData = stackFunction(dataset); + let labelKey = this.labelKey; + + 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('width', '100%').attr('viewBox', `0 0 564 ${(dataset.length + 1) * LINE_HEIGHT}`); + // chartSvg.attr('viewBox', `0 0 700 300`); + + 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})`)); + + chartSvg.select('.domain').remove(); + + 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}`) === select(elementB).attr(`${attr}`); + + // MOUSE EVENTS FOR DATA BARS + actionBars + .on('mouseover', (data) => { + let hoveredElement = actionBars.filter((bar) => bar.label === data.label).node(); + this.tooltipTarget = hoveredElement; + this.tooltipText = `${Math.round((data.total * 100) / 19000)}% of total client counts: + ${data.non_entity_tokens} non-entity tokens, ${data.distinct_entities} unique entities.`; + + select(hoveredElement).style('opacity', 1); + + dataBars + .filter(function () { + return compareAttributes(this, hoveredElement, 'y'); + }) + .style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`); + }) + .on('mouseout', function () { + select(this).style('opacity', 0); + dataBars + .filter(function () { + return compareAttributes(this, event.target, 'y'); + }) + .style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`); + }); + + // MOUSE EVENTS FOR Y-AXIS LABELS + yLegendBars + .on('mouseover', (data) => { + if (data.label.length >= CHAR_LIMIT) { + let hoveredElement = yLegendBars.filter((bar) => bar.label === data.label).node(); + this.tooltipTarget = hoveredElement; + this.tooltipText = data.label; + } else { + this.tooltipTarget = null; + } + 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'); + }) + .on('mouseout', function () { + this.tooltipTarget = null; + 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'); + }); + + // add client count total values to the right + chartSvg + .append('g') + .attr('transform', `translate(${CHART_MARGIN.left}, ${TRANSLATE.down})`) + .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', 'middle') + .attr('x', (chartData) => `${xScale(chartData.total)}%`) + .attr('y', (chartData) => yScale(chartData.label)); + } +} diff --git a/ui/app/components/clients/line-chart.js b/ui/app/components/clients/line-chart.js new file mode 100644 index 0000000000000..78a703efa0dde --- /dev/null +++ b/ui/app/components/clients/line-chart.js @@ -0,0 +1,121 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { max } from 'd3-array'; +// eslint-disable-next-line no-unused-vars +import { select, selectAll, node } from 'd3-selection'; +import { axisLeft, axisBottom } from 'd3-axis'; +import { scaleLinear, scalePoint } from 'd3-scale'; +import { line } from 'd3-shape'; +import { LIGHT_AND_DARK_BLUE, SVG_DIMENSIONS, formatNumbers } from '../../utils/chart-helpers'; + +/** + * @module LineChart + * LineChart components are used to display data in a line plot with accompanying tooltip + * + * @example + * ```js + * + * ``` + * @param {array} dataset - dataset is an array of objects + */ + +export default class LineChart extends Component { + // TODO make just one tracked variable tooltipText? + @tracked tooltipTarget = ''; + @tracked tooltipMonth = ''; + @tracked tooltipTotal = ''; + @tracked tooltipNew = ''; + + @action removeTooltip() { + this.tooltipTarget = null; + } + + @action + renderChart(element, args) { + let dataset = args[0]; + let chartSvg = select(element); + chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions + + // DEFINE AXES SCALES + let yScale = scaleLinear() + .domain([0, max(dataset.map((d) => d.clients))]) + .range([0, 100]); + + let yAxisScale = scaleLinear() + .domain([0, max(dataset.map((d) => d.clients))]) // TODO will need to recalculate when you get the data + .range([SVG_DIMENSIONS.height, 0]); + + let xScale = scalePoint() // use scaleTime()? + .domain(dataset.map((d) => d.month)) + .range([0, SVG_DIMENSIONS.width]) + .padding(0.2); + + // CUSTOMIZE AND APPEND AXES + let yAxis = axisLeft(yAxisScale) + .ticks(7) + .tickPadding(10) + .tickSizeInner(-SVG_DIMENSIONS.width) // makes grid lines length of svg + .tickFormat(formatNumbers); + + let xAxis = axisBottom(xScale).tickSize(0); + + yAxis(chartSvg.append('g')); + xAxis(chartSvg.append('g').attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)); + + chartSvg.selectAll('.domain').remove(); + + // PATH BETWEEN PLOT POINTS + let lineGenerator = line() + .x((d) => xScale(d.month)) + .y((d) => yAxisScale(d.clients)); + + chartSvg + .append('g') + .append('path') + .attr('fill', 'none') + .attr('stroke', LIGHT_AND_DARK_BLUE[1]) + .attr('stroke-width', 0.5) + .attr('d', lineGenerator(dataset)); + + // LINE PLOTS (CIRCLES) + chartSvg + .append('g') + .selectAll('circle') + .data(dataset) + .enter() + .append('circle') + .attr('class', 'data-plot') + .attr('cy', (d) => `${100 - yScale(d.clients)}%`) + .attr('cx', (d) => xScale(d.month)) + .attr('r', 3.5) + .attr('fill', LIGHT_AND_DARK_BLUE[0]) + .attr('stroke', LIGHT_AND_DARK_BLUE[1]) + .attr('stroke-width', 1.5); + + // LARGER HOVER CIRCLES + chartSvg + .append('g') + .selectAll('circle') + .data(dataset) + .enter() + .append('circle') + .attr('class', 'hover-circle') + .style('cursor', 'pointer') + .style('opacity', '0') + .attr('cy', (d) => `${100 - yScale(d.clients)}%`) + .attr('cx', (d) => xScale(d.month)) + .attr('r', 10); + + let hoverCircles = chartSvg.selectAll('.hover-circle'); + + // MOUSE EVENT FOR TOOLTIP + hoverCircles.on('mouseover', (data) => { + this.tooltipMonth = data.month; + this.tooltipTotal = `${data.clients} total clients`; + this.tooltipNew = `${data.new} new clients`; + let node = hoverCircles.filter((plot) => plot.month === data.month).node(); + this.tooltipTarget = node; + }); + } +} diff --git a/ui/app/components/clients/vertical-bar-chart.js b/ui/app/components/clients/vertical-bar-chart.js new file mode 100644 index 0000000000000..8d5aa45c067d4 --- /dev/null +++ b/ui/app/components/clients/vertical-bar-chart.js @@ -0,0 +1,139 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { max } from 'd3-array'; +// eslint-disable-next-line no-unused-vars +import { select, selectAll, node } from 'd3-selection'; +import { axisLeft, axisBottom } from 'd3-axis'; +import { scaleLinear, scaleBand } from 'd3-scale'; +import { stack } from 'd3-shape'; +import { + GREY, + LIGHT_AND_DARK_BLUE, + SVG_DIMENSIONS, + TRANSLATE, + formatNumbers, +} from '../../utils/chart-helpers'; + +/** + * @module VerticalBarChart + * VerticalBarChart components are used to display stacked data in a vertical bar chart with accompanying tooltip + * + * @example + * ```js + * + * ``` + * @param {array} dataset - dataset for the chart, must be an array of flattened objects + * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked + */ + +export default class VerticalBarChart extends Component { + @tracked tooltipTarget = ''; + @tracked tooltipTotal = ''; + @tracked uniqueEntities = ''; + @tracked nonEntityTokens = ''; + + get chartLegend() { + return this.args.chartLegend; + } + + @action + registerListener(element, args) { + let dataset = args[0]; + // TODO pull out lines 44 - scales into helper? b/c same as line chart? + let stackFunction = stack().keys(this.chartLegend.map((l) => l.key)); + let stackedData = stackFunction(dataset); + let chartSvg = select(element); + chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions + + // DEFINE DATA BAR SCALES + let yScale = scaleLinear() + .domain([0, max(dataset.map((d) => d.total))]) // TODO will need to recalculate when you get the data + .range([0, 100]) + .nice(); + + let xScale = scaleBand() + .domain(dataset.map((d) => d.month)) + .range([0, SVG_DIMENSIONS.width]) // set width to fix number of pixels + .paddingInner(0.85); + + let dataBars = chartSvg + .selectAll('g') + .data(stackedData) + .enter() + .append('g') + .style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]); + + dataBars + .selectAll('rect') + .data((stackedData) => stackedData) + .enter() + .append('rect') + .attr('width', '7px') + .attr('class', 'data-bar') + .attr('height', (stackedData) => `${yScale(stackedData[1] - stackedData[0])}%`) + .attr('x', ({ data }) => xScale(data.month)) // uses destructuring because was data.data.month + .attr('y', (data) => `${100 - yScale(data[1])}%`); // subtract higher than 100% to give space for x axis ticks + + // MAKE AXES // + let yAxisScale = scaleLinear() + .domain([0, max(dataset.map((d) => d.total))]) // TODO will need to recalculate when you get the data + .range([`${SVG_DIMENSIONS.height}`, 0]) + .nice(); + + let yAxis = axisLeft(yAxisScale) + .ticks(7) + .tickPadding(10) + .tickSizeInner(-SVG_DIMENSIONS.width) + .tickFormat(formatNumbers); + + let xAxis = axisBottom(xScale).tickSize(0); + + yAxis(chartSvg.append('g')); + xAxis(chartSvg.append('g').attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)); + + chartSvg.selectAll('.domain').remove(); // remove domain lines + + // WIDER SELECTION AREA FOR TOOLTIP HOVER + let greyBars = chartSvg + .append('g') + .attr('transform', `translate(${TRANSLATE.left})`) + .style('fill', `${GREY}`) + .style('opacity', '0') + .style('mix-blend-mode', 'multiply'); + + let tooltipRect = greyBars + .selectAll('rect') + .data(dataset) + .enter() + .append('rect') + .style('cursor', 'pointer') + .attr('class', 'tooltip-rect') + .attr('height', '100%') + .attr('width', '30px') // three times width + .attr('y', '0') // start at bottom + .attr('x', (data) => xScale(data.month)); // not data.data because this is not stacked data + + // MOUSE EVENT FOR TOOLTIP + tooltipRect.on('mouseover', (data) => { + let hoveredMonth = data.month; + this.tooltipTotal = `${data.total} total clients`; + this.uniqueEntities = `${data.distinct_entities} unique entities`; + this.nonEntityTokens = `${data.non_entity_tokens} non-entity tokens`; + // let node = chartSvg + // .selectAll('rect.tooltip-rect') + // .filter(data => data.month === this.hoveredLabel) + // .node(); + let node = chartSvg + .selectAll('rect.data-bar') + // filter for the top data bar (so y-coord !== 0) with matching month + .filter((data) => data[0] !== 0 && data.data.month === hoveredMonth) + .node(); + this.tooltipTarget = node; // grab the node from the list of rects + }); + } + + @action removeTooltip() { + this.tooltipTarget = null; + } +} diff --git a/ui/app/styles/components/bar-chart.scss b/ui/app/styles/components/bar-chart.scss deleted file mode 100644 index 1b9484ee23d1d..0000000000000 --- a/ui/app/styles/components/bar-chart.scss +++ /dev/null @@ -1,65 +0,0 @@ -.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; - margin-bottom: $spacing-xxs; - } -} - -.chart-header { - 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: right; - - > button { - font-size: $size-8; - - &:hover { - text-decoration: underline; - } - } - } -} - -.bar-chart-container { - padding: $spacing-m 0; -} - -.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 7e07524dfebbe..7d91d8efd9aff 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -23,6 +23,7 @@ @import './core/buttons'; @import './core/footer'; @import './core/forms'; +@import './core/charts'; @import './core/helpers'; @import './core/hero'; @import './core/level'; @@ -45,7 +46,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/app/styles/core/charts.scss b/ui/app/styles/core/charts.scss new file mode 100644 index 0000000000000..4de34306cbe70 --- /dev/null +++ b/ui/app/styles/core/charts.scss @@ -0,0 +1,299 @@ +.chart-wrapper { + border: $light-border; + border-radius: $radius-large; + padding: $spacing-l $spacing-l $spacing-s $spacing-l; + margin-bottom: $spacing-m; +} + +// GRID LAYOUT // +.stacked-charts { + display: grid; + width: 100%; + // grid-template-columns: 1fr; + // grid-template-rows: 1fr; +} + +.single-chart-grid { + display: grid; + grid-template-columns: 1fr 0.3fr 3.7fr; + grid-template-rows: 0.5fr 1fr 1fr 1fr 0.25fr; + width: 100%; +} + +.dual-chart-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + grid-template-rows: 0.7fr 1fr 1fr 1fr 0.3fr; + width: 100%; +} + +.chart-header { + grid-column-start: 1; + grid-column-end: span col4-end; + grid-row-start: 1; + box-shadow: inset 0 -1px 0 $vault-gray-200; + margin-bottom: $spacing-xl; +} + +.has-export { + display: grid; + grid-template-columns: 4fr 1fr; + + .header-right { + text-align: right; + > button { + font-size: $size-8; + &:hover { + text-decoration: underline; + } + } + } +} + +.chart-container-wide { + grid-column-start: 3; + grid-column-end: 4; + grid-row-start: 2; + grid-row-end: span 3; + justify-self: center; + height: 341px; + max-width: 730px; + + svg.chart { + width: 100%; + height: 100%; + } +} + +.chart-container-left { + grid-column-start: 1; + grid-column-end: 4; + grid-row-start: 2; + grid-row-end: 5; + padding-bottom: $spacing-xl; + margin-bottom: $spacing-s; + box-shadow: inset 0 -1px 0 $vault-gray-200; + + > h2 { + padding-left: 18px; + } + > p { + padding-left: 18px; + } +} + +.chart-container-right { + grid-column-start: 4; + grid-column-end: 8; + grid-row-start: 2; + grid-row-end: 5; + padding-bottom: $spacing-xl; + margin-bottom: $spacing-s; + box-shadow: inset 0 -1px 0 $vault-gray-200; + + > h2 { + padding-left: 18px; + } + > p { + padding-left: 18px; + } +} + +.chart-subTitle { + grid-column-start: 1; + grid-column-end: 3; + grid-row-start: 2; +} + +.data-details-top { + grid-column-start: 1; + grid-column-end: 3; + grid-row-start: 3; +} + +.data-details-bottom { + grid-column-start: 1; + grid-column-end: 3; + grid-row-start: 4; +} + +.timestamp { + grid-column-start: 1; + grid-column-end: 2; + grid-row-start: 5; + color: $ui-gray-500; + font-size: $size-9; + align-self: end; +} + +.legend-center { + grid-row-start: 5; + grid-column-start: 3; + grid-column-end: 5; + align-self: center; + justify-self: center; + font-size: $size-9; +} + +.legend-right { + grid-row-start: 4; + grid-column-start: 3; + grid-column-end: 3; + align-self: end; + justify-self: center; + font-size: $size-9; +} + +// FONT STYLES // + +h2.chart-title { + font-weight: $font-weight-bold; + font-size: $size-5; + line-height: $spacing-l; +} + +p.chart-description { + color: $ui-gray-700; + font-size: $body-size; + line-height: 18px; + margin-bottom: $spacing-xs; +} + +p.chart-subtext { + color: $ui-gray-500; + font-size: $size-9; + line-height: $body-size; + margin-top: $spacing-xs; +} + +h3.data-details { + font-weight: $font-weight-bold; + font-size: $size-9; + line-height: $body-size; + margin-bottom: $spacing-xs; +} + +p.data-details { + font-weight: $font-weight-normal; + font-size: $size-4; +} + +// MISC STYLES + +.light-dot { + background-color: #bfd4ff; + height: 10px; + width: 10px; + border-radius: 50%; + display: inline-block; +} + +.dark-dot { + background-color: #1563ff; + height: 10px; + width: 10px; + border-radius: 50%; + display: inline-block; +} + +.legend-label { + padding-left: $spacing-xs; + padding-right: $spacing-xl; +} + +.chart-tooltip { + background-color: $ui-gray-700; + color: white; + font-size: $size-9; + padding: 6px; + border-radius: $radius-large; + + .bold { + font-weight: $font-weight-bold; + } + + .line-chart { + width: 117px; + } + + .vertical-chart { + text-align: center; + flex-wrap: nowrap; + width: fit-content; + } + + .horizontal-chart { + width: 200px; + padding: $spacing-s; + } +} + +.chart-tooltip-arrow { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 9px solid $ui-gray-700; + position: absolute; + opacity: 0.8; + bottom: -9px; + left: calc(50% - 5px); +} + +.has-grid { + g > text { + color: $ui-gray-500; + font-size: $size-9; + } + + g > line { + // TODO: mix-blend doesn't work in firefox browser? + mix-blend-mode: darken; + color: $ui-gray-300; + } +} + +.is-horizontal { + .tick > text { + font-weight: $font-weight-semibold; + font-size: $size-9; + } +} + +// RESPONSIVE STYLING // + +@media only screen and (max-width: 950px) { + .dual-chart-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: 0.2fr 0.75fr 0.75fr 0.2fr; + width: 100%; + } + + .chart-container-left { + grid-column-start: 1; + grid-column-end: 4; + grid-row-start: 2; + grid-row-end: 3; + margin-left: $spacing-xxl; + margin-right: $spacing-xxl; + } + .chart-container-right { + grid-column-start: 1; + grid-column-end: 4; + grid-row-start: 3; + grid-row-end: 4; + margin-left: $spacing-xxl; + margin-right: $spacing-xxl; + } + + .legend-center { + grid-column-start: 1; + grid-row-start: 4; + } + + .timestamp { + grid-column-start: 1; + grid-row-start: 4; + } +} diff --git a/ui/app/styles/utils/_bulma_variables.scss b/ui/app/styles/utils/_bulma_variables.scss index ff5aa2ca503a0..930c645739289 100644 --- a/ui/app/styles/utils/_bulma_variables.scss +++ b/ui/app/styles/utils/_bulma_variables.scss @@ -28,14 +28,15 @@ $family-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto' $family-monospace: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; $family-primary: $family-sans; $body-size: 14px; -$size-3: (24/14) + 0rem; // ~1.714rem -$size-5: 1.25rem; -$size-7: (13/14) + 0rem; // ~.929rem -$size-8: (12/14) + 0rem; // ~.857rem -$size-9: 0.75rem; -$size-10: 0.5rem; -$size-11: 0.25rem; -$console-spacing: 1.5rem; +$size-3: (24/14) + 0rem; // ~1.714rem ~27px +$size-4: 1.5rem; // 24px +$size-5: 1.25rem; // 20px +$size-7: (13/14) + 0rem; // ~.929rem ~15px +$size-8: (12/14) + 0rem; // ~.857rem ~13.7px +$size-9: 0.75rem; // 12px +$size-10: 0.5rem; // 8px +$size-11: 0.25rem; // 4px +$console-spacing: 1.5rem; // 24px $size-small: $size-8; $font-weight-normal: 400; $font-weight-semibold: 600; diff --git a/ui/app/templates/components/clients/attribution.hbs b/ui/app/templates/components/clients/attribution.hbs new file mode 100644 index 0000000000000..3bb5040c5d8d8 --- /dev/null +++ b/ui/app/templates/components/clients/attribution.hbs @@ -0,0 +1,34 @@ +
+
+
+

{{@title}}

+

{{this.chartText.description}}

+
+
+ {{#if @totalClientsData}} + {{yield}} + {{/if}} +
+
+ +
+

New clients

+

{{this.chartText.newCopy}}

+ +
+ +
+

Total clients

+

{{this.chartText.totalCopy}}

+ +
+ +
+ Updated Nov 15 2021, 4:07:32 pm +
+ +
+ {{capitalize @chartLegend.0.label}} + {{capitalize @chartLegend.1.label}} +
+
\ No newline at end of file diff --git a/ui/app/templates/components/clients/dashboard.hbs b/ui/app/templates/components/clients/dashboard.hbs new file mode 100644 index 0000000000000..bc349a4e56a70 --- /dev/null +++ b/ui/app/templates/components/clients/dashboard.hbs @@ -0,0 +1,123 @@ +{{#if (and (eq @tab "history") (eq @model.config.queriesAvailable false))}} + {{#if (eq @model.config.enabled "On")}} + + {{else}} + + {{#if @model.config.configPath.canUpdate}} +

+ + Go to configuration + +

+ {{/if}} +
+ {{/if}} +{{else}} +
+ {{! ARG TODO change current to Dashboard }} + {{#if (eq @tab "current")}} +

+ {{! ARG TODO Add link for documentation "here" }} + This dashboard will surface Vault client usage over time. Clients represent anything that has authenticated to or + communicated with Vault. Documentation is available here. +

+ {{#if (eq @model.config.enabled "Off")}} + + {{#if @model.config.configPath.canUpdate}} + + Go to configuration + + {{/if}} + + {{/if}} + {{else}} + {{#if (eq @model.config.enabled "Off")}} + + Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need + to + + edit the configuration + + to enable tracking again. + + {{/if}} +

+ Monthly history + {{! ARG TODO change }} +

+

+ 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. +

+ {{! ARG TODO replace with calendar widget }} + + {{/if}} + {{#if @isLoading}} + + {{else}} + {{#if this.hasClientData}} + {{! ARG TODO end of part that goes to Running Client }} + {{#if this.showGraphs}} + {{! ARG TODO chart playground }} + + + + + + + + {{/if}} + {{else}} + {{! ARG TODO change current to dashboard }} + {{#if (eq @tab "current")}} + {{#if (eq @model.config.enabled "On")}} + + {{/if}} + {{else}} + + {{/if}} + {{/if}} + {{/if}} +
+{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/clients/horizontal-bar-chart.hbs b/ui/app/templates/components/clients/horizontal-bar-chart.hbs new file mode 100644 index 0000000000000..7d86a85327677 --- /dev/null +++ b/ui/app/templates/components/clients/horizontal-bar-chart.hbs @@ -0,0 +1,25 @@ + + + +{{#if this.tooltipTarget}} + {{! Required to set tag name = div https://github.com/yapplabs/ember-modal-dialog/issues/290 }} + {{! Component must be in curly bracket notation }} + {{! template-lint-disable no-curly-component-invocation }} + {{#modal-dialog + tagName="div" + tetherTarget=this.tooltipTarget + targetAttachment="bottom middle" + attachment="bottom middle" + offset="35px 0" + }} +
+

{{this.tooltipText}}

+
+
+ {{/modal-dialog}} +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/clients/line-chart.hbs b/ui/app/templates/components/clients/line-chart.hbs new file mode 100644 index 0000000000000..75973949db5e4 --- /dev/null +++ b/ui/app/templates/components/clients/line-chart.hbs @@ -0,0 +1,24 @@ + + + +{{! TOOLTIP }} + +{{#if this.tooltipTarget}} + {{! Required to set tag name = div https://github.com/yapplabs/ember-modal-dialog/issues/290 }} + {{! Component must be in curly bracket notation }} + {{! template-lint-disable no-curly-component-invocation }} + {{#modal-dialog + tagName="div" + tetherTarget=this.tooltipTarget + targetAttachment="bottom middle" + attachment="bottom middle" + offset="35px 0" + }} +
+

{{this.tooltipMonth}}

+

{{this.tooltipTotal}}

+

{{this.tooltipNew}}

+
+
+ {{/modal-dialog}} +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/components/clients/monthly-usage.hbs b/ui/app/templates/components/clients/monthly-usage.hbs new file mode 100644 index 0000000000000..6ae22a513cbf1 --- /dev/null +++ b/ui/app/templates/components/clients/monthly-usage.hbs @@ -0,0 +1,37 @@ +
+
+

{{@title}}

+ {{#if @description}} +

{{@description}}

+ {{/if}} +
+ +
+ +
+ +
+

{{@subTitle}}

+

{{@subText}}

+
+ +
+

{{@dataOne}}

+

{{@dataOneData}}

+
+ +
+

{{@dataTwo}}

+

{{@dataTwoData}}

+
+ +
+ Updated Nov 15 2021, 4:07:32 pm +
+ +
+ {{capitalize @chartLegend.0.label}} + {{capitalize @chartLegend.1.label}} +
+ +
\ No newline at end of file diff --git a/ui/app/templates/components/clients/running-total.hbs b/ui/app/templates/components/clients/running-total.hbs new file mode 100644 index 0000000000000..c41814e1bbfbb --- /dev/null +++ b/ui/app/templates/components/clients/running-total.hbs @@ -0,0 +1,64 @@ +
+
+
+

{{@title}}

+

{{@description}}

+
+ +
+ +
+ +
+

Running client total

+

The number of clients which interacted with Vault during this date range.

+
+ +
+

{{capitalize @chartLegend.0.label}}

+

1,307

+
+ +
+

{{capitalize @chartLegend.1.label}}

+

8,005

+
+ +
+ {{capitalize @chartLegend.0.label}} + {{capitalize @chartLegend.1.label}} +
+
+ +
+
+ +
+ +
+

New monthly clients

+

+ Clients which interacted with Vault for the first time during this date range, displayed per month. +

+
+ +
+

Average new {{@chartLegend.0.label}} per month

+

{{@dataOneData}}

+
+ +
+

Average new {{@chartLegend.1.label}} per month

+

{{@dataTwoData}}

+
+ +
+ Updated Nov 15 2021, 4:07:32 pm +
+ +
+ {{capitalize @chartLegend.0.label}} + {{capitalize @chartLegend.1.label}} +
+
+
\ No newline at end of file diff --git a/ui/app/templates/components/clients/vertical-bar-chart.hbs b/ui/app/templates/components/clients/vertical-bar-chart.hbs new file mode 100644 index 0000000000000..b110479452699 --- /dev/null +++ b/ui/app/templates/components/clients/vertical-bar-chart.hbs @@ -0,0 +1,24 @@ + + + +{{! TOOLTIP }} + +{{#if this.tooltipTarget}} + {{! Required to set tag name = div https://github.com/yapplabs/ember-modal-dialog/issues/290 }} + {{! Component must be in curly bracket notation }} + {{! template-lint-disable no-curly-component-invocation }} + {{#modal-dialog + tagName="div" + tetherTarget=this.tooltipTarget + targetAttachment="bottom middle" + attachment="bottom middle" + offset="40px 0" + }} +
+

{{this.tooltipTotal}}

+

{{this.uniqueEntities}}

+

{{this.nonEntityTokens}}

+
+
+ {{/modal-dialog}} +{{/if}} \ No newline at end of file diff --git a/ui/app/templates/vault/cluster/clients/index.hbs b/ui/app/templates/vault/cluster/clients/index.hbs index 930fbf0a140d3..62ef43cae8bf9 100644 --- a/ui/app/templates/vault/cluster/clients/index.hbs +++ b/ui/app/templates/vault/cluster/clients/index.hbs @@ -7,6 +7,7 @@
+ {{! template-lint-configure no-unknown-arguments-for-builtin-components "warn" }}