Secrets Sync Client Count Updates (#24752)

* Client Count Routing Updates (#24733)

* updates client count routing for sync and future additions

* adds copyright header to clients sync template

* adds missing copyright headers

* UI: Adds secret_syncs to mirage /activity endpoint (#24846)

* add secret_syncs to mirage endpoint

* import clients handler

* UI: Set up client charts for incoming sync data (#24852)

* sum stacked bar values for tooltip total

* make tooltip dynamic based on chartLegend

* remove redundant helper

* add secret_syncs to client count utils

* move sum function to helper

* update horizontal bar chart to include sync_clients

* calculate sum of bars in tooltip

* rename color palette const, define chart legends in each parent component instead of token.js

* update tooltips

* update mirage handler to add sys/ namespace

* update mirage handler to add sys/ namespace

* use pushObject

* update test

* UI: Secret sync bar chart (#24926)

* install lineal

* add ember-style-modifier dep

* Add client count types for serialized data

* Add sync bar chart component with tests

* Chart is responsive

* address comments

* Clients Counts Parent Route (#24899)

* adds interfaces for clients models

* moves date formatting logic from clients activity adapter to utils file

* adds clients counts route

* updates links to clients route to point to top level and updates redirect to counts overview route

* removes clients base route and moves overview and sync routes under counts

* adds clients counts page component

* converts clients route to ts

* adds billing start timestamp to clients config mirage response and updates counts route to always attempt to fetch activity

* fixes issue with updating namespace and auth mount query params always triggering client counts route model hook

* adds tests for clients counts page component

* adds missing copyright header to client-counts type file

* Update ui/app/components/clients/page/counts.hbs

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* fixes bad import in sync-bar-chart

* updates clients counts route to bypass query if there is not start_time

* pins d3-shape to 1.3.7 for now -- makes lineal play nice with old charts

* fixes sync bar chart tooltip assertion

---------

Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>

* UI: convert line-chart to lineal (#24961)

* lineal chart alongside svg

* Add version-history to sync handler for testing

* line chart is TS, test updated

* remove d3-shape resolution

* fix clients/token-test

* use chartHeight in running-total template

* use M/yy key instead of timestamp, chart is responsive

* Add test for swapping datasets

* add more edge case tests

* more test

* remove untrue assertion

* fix weird decimal when between 1.1k and 2k

* address feedback

* Update line-chart to use timestamp instead of month key

* Add timestamp to all places where month is on the clients activity response

* Client Counts Overview (#24969)

* adds counts base component for use in client counts child routes

* adds clients counts overview page component

* splits out monthly new chart from clients running total component

* adds missing copyright headers

* moves running total related assertions from token to overview acceptance test

* removes new client assertions from running-total test and adds tests for monthly-new component

* updates copy in running-total component

* fixes clients overview tests

* fixes timestamp stub not being restored in monthly-new test

* fixes mfa-login test

* renames counts component to activity

* removes unused selectedAuthMethod arg from running-total component

* adds timestamp back to running-total component

* Secrets sync UI: add sync page component (#24982)

* adds counts base component for use in client counts child routes

* adds clients counts overview page component

* splits out monthly new chart from clients running total component

* adds missing copyright headers

* move sync-bar-chart to charts/ folder

* update types and rename chart

* rename template file

* moves running total related assertions from token to overview acceptance test

* removes new client assertions from running-total test and adds tests for monthly-new component

* updates copy in running-total component

* fixes clients overview tests

* fixes timestamp stub not being restored in monthly-new test

* fixes mfa-login test

* fix 0 values erroring charts

* separate timestamp again

* address merge conflicts

* finish building sync chart component WIP css

* renames counts component to activity

* update import

* revert name to dataKey

* update styling for charts without legends

* use monthly stat chart component for layout

* use monthly chart stats in monthly new

* implement stat wrapper;

* remove extra grid div

* rename component

* fix legend css;

* update test[

* remove arbitrarily setting max

* add single month view

* use stat text

* update line chart tests

* rename line chart

* update tests

---------

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>

* update selectors

* add sync page tests

* Secrets Sync UI: Add secrets syncs to csv export (#25056)

* update mirage and add sync clients to export csv

* fix sync legend label

* remove word

* update copy in modal

* update mirage

* fix attribution tooltip text

* Clients Counts Token Route (#25019)

* renames token route and page component back to dashboard

* adds client counts token route and page component

* updates charts in token page to use ChartContainer component

* adds tests for clients token page component

* restore clients dashboard test

* use var for chart title sync page

* updates clients token page to show usage stats when querying single month

* updates token page clients averages to only include entity and non-entity clients in calculation

* fixes monthly total counts lower than new clients in mirage handler

* fixes token test

---------

Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com>

* Clients Usage Stats/Running Total Updates (#25094)

* updates clients usage counts and running totals

* updates usage stats total copy

* fixes client counts overview tests

* Secrets sync UI: cleanup and consolidation of components (#25090)

* rename authMethod to mountPath

* generalize count template copy

* add todo to delete monthly new component

* rename to tokenTab

* wrap filters in conditional checking for start timestamp

* some users may not have access to /config endpoint

* fix querying when user has no billing date permissions and clicks current billing period

* extend activity component from counts page

* Revert "extend activity component from counts page"

This reverts commit 1d0e85c82faf88c4385a04b1a5841cdde7fd00e0.

* rename to startTimestampISO

* remove timestamp from route and just use activity model responseTimestamp

* fix chart y domain max

* fix typos in usage stat and running totals component

* delete backing class for display only template;

* updates tests

* adds comment for fetching license to get start date for billing

* cleans up unused client counts files (#25157)

* adds changelog

* fix assertion copy

* adds changelog description

* updates enterprise sidebar nav test

---------

Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com>
Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>
This commit is contained in:
Jordan Reimer
2024-02-01 10:01:07 -07:00
committed by GitHub
parent c60d1ce11a
commit 947a00ccb3
87 changed files with 3960 additions and 4944 deletions

3
changelog/24752.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Separates out client counts dashboard to overview and entity/non-entity tabs
```

View File

@@ -4,7 +4,7 @@
*/
import ApplicationAdapter from '../application';
import { getUnixTime } from 'date-fns';
import { formatDateObject } from 'core/utils/client-count-utils';
export default class ActivityAdapter extends ApplicationAdapter {
// javascript localizes new Date() objects but all activity log data is stored in UTC
@@ -12,10 +12,8 @@ export default class ActivityAdapter extends ApplicationAdapter {
// time params from the backend are formatted as a zulu timestamp
formatQueryParams(queryParams) {
let { start_time, end_time } = queryParams;
start_time = start_time.timestamp || getUnixTime(Date.UTC(start_time.year, start_time.monthIdx, 1));
// day=0 for Date.UTC() returns the last day of the month before
// increase monthIdx by one to get last day of queried month
end_time = end_time.timestamp || getUnixTime(Date.UTC(end_time.year, end_time.monthIdx + 1, 0));
start_time = start_time.timestamp || formatDateObject(start_time);
end_time = end_time.timestamp || formatDateObject(end_time, true);
return { start_time, end_time };
}

View File

@@ -117,7 +117,7 @@ export default class App extends Application {
services: ['flash-messages', 'router', 'store', 'version'],
externalRoutes: {
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details',
clientCountDashboard: 'vault.cluster.clients.dashboard',
clientCountOverview: 'vault.cluster.clients',
},
},
},

View File

@@ -0,0 +1,198 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// base component for counts child routes that can be extended as needed
// contains getters that filter and extract data from activity model for use in charts
import Component from '@glimmer/component';
import { isAfter, isBefore, isSameMonth, fromUnixTime } from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { calculateAverage } from 'vault/utils/chart-helpers';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type {
ClientActivityNewClients,
ClientActivityMonthly,
ClientActivityResourceByKey,
} from 'vault/models/clients/activity';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
interface Args {
activity: ClientsActivityModel;
versionHistory: ClientsVersionHistoryModel[];
startTimestamp: number;
endTimestamp: number;
namespace: string;
mountPath: string;
}
export default class ClientsActivityComponent extends Component<Args> {
average = (
data:
| ClientActivityMonthly[]
| (ClientActivityResourceByKey | undefined)[]
| (ClientActivityNewClients | undefined)[]
| undefined,
key: string
) => {
return calculateAverage(data, key);
};
get startTimeISO() {
return fromUnixTime(this.args.startTimestamp).toISOString();
}
get endTimeISO() {
return fromUnixTime(this.args.endTimestamp).toISOString();
}
get byMonthActivityData() {
const { activity, namespace } = this.args;
return namespace ? this.filteredActivityByMonth : activity.byMonth;
}
get byMonthNewClients() {
return this.byMonthActivityData ? this.byMonthActivityData?.map((m) => m?.new_clients) : [];
}
get filteredActivityByMonth() {
const { namespace, mountPath, activity } = this.args;
if (!namespace && !mountPath) {
return activity.byMonth;
}
const namespaceData = activity.byMonth
.map((m) => m.namespaces_by_key[namespace as keyof typeof m.namespaces_by_key])
.filter((d) => d !== undefined);
if (!mountPath) {
return namespaceData.length === 0 ? undefined : namespaceData;
}
const mountData = mountPath
? namespaceData.map((namespace) => namespace?.mounts_by_key[mountPath]).filter((d) => d !== undefined)
: namespaceData;
return mountData.length === 0 ? undefined : mountData;
}
get filteredActivityByNamespace() {
const { namespace, activity } = this.args;
return activity.byNamespace.find((ns) => ns.label === namespace);
}
get filteredActivityByAuthMount() {
return this.filteredActivityByNamespace?.mounts?.find((mount) => mount.label === this.args.mountPath);
}
get filteredActivity() {
return this.args.mountPath ? this.filteredActivityByAuthMount : this.filteredActivityByNamespace;
}
get isCurrentMonth() {
const { activity } = this.args;
const current = parseAPITimestamp(activity.responseTimestamp) as Date;
const start = parseAPITimestamp(activity.startTime) as Date;
const end = parseAPITimestamp(activity.endTime) as Date;
return isSameMonth(start, current) && isSameMonth(end, current);
}
get isDateRange() {
const { activity } = this.args;
return !isSameMonth(
parseAPITimestamp(activity.startTime) as Date,
parseAPITimestamp(activity.endTime) as Date
);
}
// (object) top level TOTAL client counts for given date range
get totalUsageCounts() {
const { namespace, activity } = this.args;
return namespace ? this.filteredActivity : activity.total;
}
get upgradeDuringActivity() {
const { versionHistory, activity } = this.args;
if (versionHistory) {
// filter for upgrade data of noteworthy upgrades (1.9 and/or 1.10)
const upgradeVersionHistory = versionHistory.filter(
({ version }) => version.match('1.9') || version.match('1.10')
);
if (upgradeVersionHistory.length) {
const activityStart = parseAPITimestamp(activity.startTime) as Date;
const activityEnd = parseAPITimestamp(activity.endTime) as Date;
// filter and return all upgrades that happened within date range of queried activity
const upgradesWithinData = upgradeVersionHistory.filter(({ timestampInstalled }) => {
const upgradeDate = parseAPITimestamp(timestampInstalled) as Date;
return isAfter(upgradeDate, activityStart) && isBefore(upgradeDate, activityEnd);
});
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
}
}
return null;
}
// (object) single month new client data with total counts + array of namespace breakdown
get newClientCounts() {
if (this.isDateRange || !this.byMonthActivityData) {
return null;
}
return this.byMonthActivityData[0]?.new_clients;
}
// total client data for horizontal bar chart in attribution component
get totalClientAttribution() {
const { namespace, activity } = this.args;
if (namespace) {
return this.filteredActivityByNamespace?.mounts || null;
} else {
return activity.byNamespace || null;
}
}
// new client data for horizontal bar chart
get newClientAttribution() {
// new client attribution only available in a single, historical month (not a date range or current month)
if (this.isDateRange || this.isCurrentMonth) return null;
if (this.args.namespace) {
return this.newClientCounts?.mounts || null;
} else {
return this.newClientCounts?.namespaces || null;
}
}
get hasAttributionData() {
const { mountPath, namespace } = this.args;
if (!mountPath) {
if (namespace) {
const mounts = this.filteredActivityByNamespace?.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
return mounts && mounts.length > 0;
}
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
}
return false;
}
get upgradeExplanation() {
if (this.upgradeDuringActivity) {
if (this.upgradeDuringActivity.length === 1) {
const version = this.upgradeDuringActivity[0]?.version || '';
if (version.match('1.9')) {
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data.';
}
if (version.match('1.10')) {
return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data.';
}
}
// return combined explanation if spans multiple upgrades
return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data.';
}
return null;
}
}

View File

@@ -31,7 +31,7 @@
<p class="chart-description">{{this.chartText.newCopy}}</p>
<Clients::HorizontalBarChart
@dataset={{this.barChartNewClients}}
@chartLegend={{@chartLegend}}
@chartLegend={{this.attributionLegend}}
@totalCounts={{@newUsageCounts}}
@noDataMessage="There are no new clients for this namespace during this time period."
/>
@@ -40,22 +40,14 @@
<div class="chart-container-right" data-test-chart-container="total-clients">
<h2 class="chart-title">Total clients</h2>
<p class="chart-description">{{this.chartText.totalCopy}}</p>
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}}
@totalCounts={{@totalUsageCounts}}
/>
<Clients::HorizontalBarChart @dataset={{this.barChartTotalClients}} @chartLegend={{this.attributionLegend}} />
</div>
{{else}}
<div
class={{concat (unless this.barChartTotalClients "chart-empty-state ") "chart-container-wide"}}
data-test-chart-container="single-chart"
>
<Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}}
@totalCounts={{@totalUsageCounts}}
/>
<Clients::HorizontalBarChart @dataset={{this.barChartTotalClients}} @chartLegend={{this.attributionLegend}} />
</div>
<div class="chart-subTitle">
<p class="chart-subtext" data-test-attribution-subtext>{{this.chartText.totalCopy}}</p>
@@ -71,9 +63,10 @@
<p class="data-details">{{format-number this.topClientCounts.clients}}</p>
</div>
{{/if}}
<div class="legend-center">
<span class="light-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "0.label")}}</span>
<span class="dark-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "1.label")}}</span>
<div class="legend">
{{#each this.attributionLegend as |legend idx|}}
<span class="legend-colors dot-{{idx}}"></span><span class="legend-label">{{capitalize legend.label}}</span>
{{/each}}
</div>
{{else}}
<div class="chart-empty-state">
@@ -96,9 +89,16 @@
</M.Header>
<M.Body>
<p class="has-bottom-margin-s">
This export will include the namespace path, authentication method path, and the associated total, entity, and
non-entity clients for the below
{{if this.formattedEndDate "date range" "month"}}.
This export will include the namespace path, mount path and associated total, entity, non-entity and secrets sync
clients for the
{{if this.formattedEndDate "date range" "month"}}
below.
</p>
<p class="has-bottom-margin-s">
The
<code>mount_path</code>
for secrets sync clients is the KV v2 engine path and for entity/non-entity clients is the corresponding
authentication method path.
</p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if this.formattedEndDate " RANGE"}}</p>
<p class="has-bottom-margin-s" data-test-export-date-range>

View File

@@ -18,7 +18,6 @@ import { format, isSameMonth } from 'date-fns';
* @example
* ```js
* <Clients::Attribution
* @chartLegend={{this.chartLegend}}
* @totalUsageCounts={{this.totalUsageCounts}}
* @newUsageCounts={{this.newUsageCounts}}
* @totalClientAttribution={{this.totalClientAttribution}}
@@ -31,7 +30,6 @@ import { format, isSameMonth } from 'date-fns';
* @upgradeExplanation="We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data."
* />
* ```
* @param {array} chartLegend - (passed to child) array of objects with key names 'key' and 'label' so data can be stacked
* @param {object} totalUsageCounts - object with total client counts for chart tooltip text
* @param {object} newUsageCounts - object with new client counts for chart tooltip text
* @param {array} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients
@@ -47,6 +45,11 @@ import { format, isSameMonth } from 'date-fns';
export default class Attribution extends Component {
@tracked showCSVDownloadModal = false;
@service download;
attributionLegend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
{ key: 'secret_syncs', label: 'secrets sync clients' },
];
get formattedStartDate() {
if (!this.args.startTimestamp) return null;
@@ -123,10 +126,10 @@ export default class Attribution extends Component {
}
destructureCountsToArray(object) {
// destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, clients: 191}
// destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, secret_syncs: 10, clients: 201}
// to get integers for CSV file
const { clients, entity_clients, non_entity_clients } = object;
return [clients, entity_clients, non_entity_clients];
const { clients, entity_clients, non_entity_clients, secret_syncs } = object;
return [clients, entity_clients, non_entity_clients, secret_syncs];
}
constructCsvRow(namespaceColumn, mountColumn = null, totalColumns, newColumns = null) {
@@ -146,19 +149,25 @@ export default class Attribution extends Component {
const csvData = [];
// added to clarify that the row of namespace totals without an auth method (blank) are not additional clients
// but indicate the total clients for that ns, including its auth methods
const upgrade = this.args.upgradeExplanation
? `\n **data contains an upgrade, mount summation may not equal namespace totals`
: '';
const descriptionOfBlanks = this.isSingleNamespace
? ''
: `\n *namespace totals, inclusive of auth method clients`;
: `\n *namespace totals, inclusive of mount clients ${upgrade}`;
const csvHeader = [
'Namespace path',
`"Authentication method ${descriptionOfBlanks}"`,
`"Mount path ${descriptionOfBlanks}"`,
'Total clients',
'Entity clients',
'Non-entity clients',
'Secrets sync clients',
];
if (newAttribution) {
csvHeader.push('Total new clients, New entity clients, New non-entity clients');
csvHeader.push(
'Total new clients, New entity clients, New non-entity clients, New secrets sync clients'
);
}
totalAttribution.forEach((totalClientsObject) => {
@@ -199,7 +208,7 @@ export default class Attribution extends Component {
const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : '';
const csvDateRange = this.formattedStartDate + endRange;
return this.isSingleNamespace
? `clients_by_auth_method_${csvDateRange}`
? `clients_by_mount_path_${csvDateRange}`
: `clients_by_namespace_${csvDateRange}`;
}

View File

@@ -0,0 +1,54 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{! HBS display template for rendering title, description and stat boxes with a chart on the right }}
<div class="chart-wrapper single-chart-grid" ...attributes>
<div class="chart-header has-bottom-margin-xl">
<h2 class="chart-title">{{@title}}</h2>
<p class="chart-description">
{{@description}}
</p>
</div>
{{#if @hasChartData}}
{{#if (has-block "subTitle")}}
<div class="chart-subTitle">
{{yield to="subTitle"}}
</div>
{{/if}}
{{#if (has-block "stats")}}
{{yield to="stats"}}
{{/if}}
{{#if (has-block "chart")}}
<div class="chart-container-wide">
{{yield to="chart"}}
</div>
{{/if}}
{{#if @legend}}
<div class="legend" data-test-chart-container-legend>
{{#each @legend as |legend idx|}}
<span class="legend-colors dot-{{idx}}"></span>
<span class="legend-label">{{capitalize legend.label}}</span>
{{/each}}
</div>
{{/if}}
{{else}}
<div class="chart-empty-state chart-container-wide">
{{yield to="emptyState"}}
</div>
{{/if}}
{{#if @timestamp}}
<div class="timestamp" data-test-chart-container-timestamp>
Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
</div>
{{/if}}
</div>

View File

@@ -0,0 +1,127 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div>
{{#if this.data}}
<div class="lineal-chart" data-test-chart={{@chartTitle}}>
<Lineal::Fluid as |width|>
{{#let
(scale-time domain=this.timeDomain range=(array 0 width) nice=true)
(scale-linear domain=this.yDomain range=(array this.chartHeight 0) nice=true)
(scale-linear range=(array 0 this.chartHeight))
as |xScale yScale tooltipScale|
}}
<svg width={{width}} height={{this.chartHeight}} class="chart has-grid" data-test-line-chart>
{{#if (and xScale.isValid yScale.isValid)}}
<Lineal::Axis
@scale={{yScale}}
@tickCount="4"
@tickPadding={{10}}
@tickSizeInner={{concat "-" width}}
@tickFormat={{this.formatCount}}
@orientation="left"
@includeDomain={{false}}
class="lineal-axis"
data-test-y-axis
/>
<Lineal::Axis
@scale={{xScale}}
@orientation="bottom"
transform="translate(0,{{yScale.range.min}})"
@includeDomain={{false}}
@tickSize="0"
@tickPadding={{10}}
@tickFormat={{this.formatMonth}}
@tickCount={{this.data.length}}
class="lineal-axis"
data-test-x-axis
/>
{{#each this.upgradedMonths as |d|}}
<circle
class="upgrade-circle"
cx={{xScale.compute d.x}}
cy={{yScale.compute d.y}}
r="10"
fill="#FDEEBA"
stroke="none"
data-test-line-chart="upgrade-{{d.month}}"
></circle>
{{/each}}
{{/if}}
<Lineal::Line
@data={{this.data}}
@x="x"
@y="y"
@xScale={{xScale}}
@yScale={{yScale}}
stroke="#0c56e9"
stroke-width="0.5"
fill="none"
/>
{{! this is here to qualify the scales }}
<Lineal::Line
@data={{this.data}}
@x="x"
@y="y"
@xScale={{xScale}}
@yScale={{tooltipScale}}
stroke="none"
fill="none"
/>
{{#if (and xScale.isValid yScale.isValid)}}
{{#each this.data as |d|}}
{{#if (this.hasValue d.y)}}
<circle
cx={{xScale.compute d.x}}
cy={{yScale.compute d.y}}
r="3.5"
fill="#cce3fe"
stroke="#0c56e9"
stroke-width="1.5"
data-test-line-chart="plot-point"
></circle>
<circle
role="button"
aria-label="Show exact counts for {{date-format d.x 'MMMM yyyy'}}"
cx={{xScale.compute d.x}}
cy={{yScale.compute d.y}}
r="10"
fill="transparent"
{{on "mouseover" (fn (mut this.activeDatum) d)}}
{{on "mouseout" (fn (mut this.activeDatum) null)}}
data-test-hover-circle={{date-format d.x "M/yy"}}
></circle>
{{/if}}
{{/each}}
{{/if}}
</svg>
{{#if this.activeDatum}}
<div
class="lineal-tooltip-position from-top-left chart-tooltip"
role="status"
{{style
--x=(this.tooltipX (xScale.compute this.activeDatum.x))
--y=(this.tooltipY (yScale.compute this.activeDatum.y))
}}
data-test-tooltip
>
<p class="bold" data-test-tooltip-month>{{date-format this.activeDatum.x "MMMM yyyy"}}</p>
<p>{{format-number this.activeDatum.y}} total clients</p>
<p>{{format-number (or this.activeDatum.new)}} new clients</p>
{{#if this.activeDatum.tooltipUpgrade}}
<br />
<p class="has-text-highlight">{{this.activeDatum.tooltipUpgrade}}</p>
{{/if}}
<div class="chart-tooltip-arrow"></div>
</div>
{{/if}}
{{/let}}
</Lineal::Fluid>
</div>
{{else}}
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
{{/if}}
</div>

View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { SVG_DIMENSIONS, formatNumbers } from 'vault/utils/chart-helpers';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { format, isValid } from 'date-fns';
import { debug } from '@ember/debug';
import type { Count, MonthlyChartData, Timestamp } from 'vault/vault/charts/client-counts';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
interface Args {
dataset: MonthlyChartData[];
upgradeData: ClientsVersionHistoryModel[];
xKey?: string;
yKey?: string;
chartHeight?: number;
}
interface ChartData {
x: Date;
y: number | null;
new: number;
tooltipUpgrade: string | null;
month: string; // used for test selectors and to match key on upgradeData
}
interface UpgradeByMonth {
[key: string]: ClientsVersionHistoryModel;
}
/**
* @module LineChart
* LineChart components are used to display time-based data in a line plot with accompanying tooltip
*
* @example
* ```js
* <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
* ```
* @param {array} dataset - array of objects containing data to be plotted
* @param {string} [xKey=clients] - string denoting key for x-axis data of dataset. Should reference a timestamp string.
* @param {string} [yKey=timestamp] - string denoting key for y-axis data of dataset. Should reference a number or null.
* @param {array} [upgradeData=null] - array of objects containing version history from the /version-history endpoint
* @param {number} [chartHeight=190] - height of chart in pixels
*/
export default class LineChart extends Component<Args> {
// Chart settings
get yKey() {
return this.args.yKey || 'clients';
}
get xKey() {
return this.args.xKey || 'timestamp';
}
get chartHeight() {
return this.args.chartHeight || SVG_DIMENSIONS.height;
}
// Plot points
get data(): ChartData[] {
try {
return this.args.dataset?.map((datum) => {
const timestamp = parseAPITimestamp(datum[this.xKey as keyof Timestamp]) as Date;
if (isValid(timestamp) === false)
throw new Error(`Unable to parse value "${datum[this.xKey as keyof Timestamp]}" as date`);
const upgradeMessage = this.getUpgradeMessage(datum);
return {
x: timestamp,
y: (datum[this.yKey as keyof Count] as number) ?? null,
new: this.getNewClients(datum),
tooltipUpgrade: upgradeMessage,
month: datum.month,
};
});
} catch (e) {
debug(e as string);
return [];
}
}
get upgradedMonths() {
return this.data.filter((datum) => datum.tooltipUpgrade);
}
// Domains
get yDomain() {
const counts: number[] = this.data
.map((d) => d.y)
.flatMap((num) => (typeof num === 'number' ? [num] : []));
const max = Math.max(...counts);
// if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals
return [0, max <= 4 ? 4 : max];
}
get timeDomain() {
// assume data is sorted by time
const firstTime = this.data[0]?.x;
const lastTime = this.data[this.data.length - 1]?.x;
return [firstTime, lastTime];
}
get upgradeByMonthYear(): UpgradeByMonth {
const empty: UpgradeByMonth = {};
if (!Array.isArray(this.args.upgradeData)) return empty;
return (
this.args.upgradeData?.reduce((acc, upgrade) => {
if (upgrade.timestampInstalled) {
const key = parseAPITimestamp(upgrade.timestampInstalled, 'M/yy');
acc[key as string] = upgrade;
}
return acc;
}, empty) || empty
);
}
getUpgradeMessage(datum: MonthlyChartData) {
const upgradeInfo = this.upgradeByMonthYear[datum.month as string];
if (upgradeInfo) {
const { version, previousVersion } = upgradeInfo;
return `Vault was upgraded
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
}
return null;
}
getNewClients(datum: MonthlyChartData) {
if (!datum?.new_clients) return 0;
return (datum?.new_clients[this.yKey as keyof Count] as number) || 0;
}
hasValue = (count: number | null) => {
return typeof count === 'number' ? true : false;
};
// These functions are used by the tooltip
formatCount = (count: number) => {
return formatNumbers([count]);
};
formatMonth = (date: Date) => {
return format(date, 'M/yy');
};
tooltipX = (original: number) => {
return original.toString();
};
tooltipY = (original: number) => {
return `${this.chartHeight - original + 15}`;
};
}

View File

@@ -0,0 +1,115 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="lineal-chart" data-test-chart={{@chartTitle}}>
<Lineal::Fluid as |width|>
{{#let
(scale-band domain=this.xDomain range=(array 0 width) padding=0.1)
(scale-linear range=(array this.chartHeight 0) domain=this.yDomain)
(scale-linear range=(array 0 this.chartHeight) domain=this.yDomain)
as |xScale yScale hScale|
}}
<svg width={{width}} height={{this.chartHeight}} data-test-sync-bar-chart>
<title>
{{@chartTitle}}
</title>
{{#if (and xScale.isValid yScale.isValid)}}
<Lineal::Axis
@scale={{yScale}}
@tickCount="4"
@tickPadding={{10}}
@tickSizeInner={{concat "-" width}}
@tickFormat={{this.formatTicksY}}
@orientation="left"
@includeDomain={{false}}
class="lineal-axis"
data-test-y-axis
/>
<Lineal::Axis
@scale={{xScale}}
@orientation="bottom"
transform="translate(0,{{yScale.range.min}})"
@includeDomain={{false}}
@tickSize="0"
@tickPadding={{10}}
class="lineal-axis"
data-test-x-axis
/>
{{/if}}
<Lineal::Bars
@data={{this.chartData}}
@x="x"
@y="y"
@height="y"
@width={{this.barWidth}}
@xScale={{xScale}}
@yScale={{yScale}}
@heightScale={{hScale}}
transform="translate({{this.barOffset xScale.bandwidth}},0)"
fill="transparent"
stroke="transparent"
class="lineal-chart-bar"
data-test-vertical-bar
/>
{{#if (and xScale.isValid yScale.isValid)}}
{{#each this.chartData as |d|}}
<rect
role="button"
aria-label="Show exact counts for {{d.legendX}}"
x="0"
y="0"
height={{this.chartHeight}}
width={{xScale.bandwidth}}
fill="transparent"
stroke="transparent"
transform="translate({{xScale.compute d.x}})"
{{on "mouseover" (fn (mut this.activeDatum) d)}}
{{on "mouseout" (fn (mut this.activeDatum) null)}}
data-test-interactive-area={{d.x}}
/>
{{/each}}
{{/if}}
</svg>
{{#if this.activeDatum}}
<div
class="lineal-tooltip-position chart-tooltip"
role="status"
{{style
--x=(this.tooltipX (xScale.compute this.activeDatum.x) xScale.bandwidth)
--y=(this.tooltipY (hScale.compute this.activeDatum.y))
}}
>
<div data-test-tooltip>
<p class="bold" data-test-tooltip-month>{{this.activeDatum.legendX}}</p>
<p data-test-tooltip-count>{{this.activeDatum.tooltip}}</p>
</div>
<div class="chart-tooltip-arrow"></div>
</div>
{{/if}}
{{/let}}
</Lineal::Fluid>
</div>
{{#if @showTable}}
<details data-test-underlying-data>
<summary>Underlying data</summary>
<Hds::Table @caption="Underlying data">
<:head as |H|>
<H.Tr>
<H.Th>Month</H.Th>
<H.Th>Count of secret syncs</H.Th>
</H.Tr>
</:head>
<:body as |B|>
{{#each this.chartData as |row|}}
<B.Tr>
<B.Td>{{row.legendX}}</B.Td>
<B.Td>{{row.legendY}}</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>
</details>
{{/if}}

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { BAR_WIDTH, formatNumbers } from 'vault/utils/chart-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import type { Count, MonthlyChartData } from 'vault/vault/charts/client-counts';
interface Args {
data: MonthlyChartData[];
dataKey: string;
chartTitle: string;
chartHeight?: number;
}
interface ChartData {
x: string;
y: number | null;
tooltip: string;
legendX: string;
legendY: string;
}
/**
* @module VerticalBarBasic
* Renders a vertical bar chart of counts fora single data point (@dataKey) over time.
*
* @example
<Clients::Charts::VerticalBarBasic
@chartTitle="Secret Sync client counts"
@data={{this.model}}
@dataKey="secret_syncs"
@showTable={{true}}
@chartHeight={{200}}
/>
*/
export default class VerticalBarBasic extends Component<Args> {
barWidth = BAR_WIDTH;
@tracked activeDatum: ChartData | null = null;
get chartHeight() {
return this.args.chartHeight || 190;
}
get chartData() {
return this.args.data.map((d): ChartData => {
const xValue = d.timestamp as string;
const yValue = (d[this.args.dataKey as keyof Count] as number) ?? null;
return {
x: parseAPITimestamp(xValue, 'M/yy') as string,
y: yValue,
tooltip:
yValue === null ? 'No data' : `${formatNumber([yValue])} ${this.args.dataKey.replace(/_/g, ' ')}`,
legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string,
legendY: (yValue ?? 'No data').toString(),
};
});
}
get yDomain() {
const counts: number[] = this.chartData
.map((d) => d.y)
.flatMap((num) => (typeof num === 'number' ? [num] : []));
const max = Math.max(...counts);
// if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals
return [0, max <= 4 ? 4 : max];
}
get xDomain() {
const months = this.chartData.map((d) => d.x);
return new Set(months);
}
// TEMPLATE HELPERS
barOffset = (bandwidth: number) => {
return (bandwidth - this.barWidth) / 2;
};
tooltipX = (original: number, bandwidth: number) => {
return (original + bandwidth / 2).toString();
};
tooltipY = (original: number) => {
if (!original) return `0`;
return `${original}`;
};
formatTicksY = (num: number): string => {
return formatNumbers(num) || num.toString();
};
}

View File

@@ -1,376 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { isAfter, isBefore, isSameMonth, format } from 'date-fns';
import getStorage from 'vault/lib/token-storage';
import { parseAPITimestamp } from 'core/utils/date-formatters';
// my sincere apologies to the next dev who has to refactor/debug this (⇀‸↼‶)
export default class Dashboard extends Component {
@service store;
@service version;
chartLegend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
// RESPONSE
@tracked startMonthTimestamp; // when user queries, updates to first month object of response
@tracked endMonthTimestamp; // when user queries, updates to last month object of response
@tracked queriedActivityResponse = null;
// track params sent to /activity request
@tracked activityQueryParams = {
start: {}, // updates when user edits billing start month
end: {}, // updates when user queries end dates via calendar widget
};
// SEARCH SELECT FILTERS
get namespaceArray() {
return this.getActivityResponse.byNamespace
? this.getActivityResponse.byNamespace.map((namespace) => ({
name: namespace.label,
id: namespace.label,
}))
: [];
}
@tracked selectedNamespace = null;
@tracked selectedAuthMethod = null;
@tracked authMethodOptions = [];
// TEMPLATE VIEW
@tracked noActivityData;
@tracked showBillingStartModal = false;
@tracked isLoadingQuery = false;
@tracked errorObject = null;
constructor() {
super(...arguments);
this.startMonthTimestamp = this.args.model.licenseStartTimestamp;
this.endMonthTimestamp = this.args.model.currentDate;
this.activityQueryParams.start.timestamp = this.args.model.licenseStartTimestamp;
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
this.noActivityData = this.args.model.activity.id === 'no-data' ? true : false;
}
// returns text for empty state message if noActivityData
get dateRangeMessage() {
if (!this.startMonthTimestamp && !this.endMonthTimestamp) return null;
const endMonth = isSameMonth(
parseAPITimestamp(this.startMonthTimestamp),
parseAPITimestamp(this.endMonthTimestamp)
)
? ''
: ` to ${parseAPITimestamp(this.endMonthTimestamp, 'MMMM yyyy')}`;
// completes the message 'No data received from { dateRangeMessage }'
return `from ${parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy')}` + endMonth;
}
get versionText() {
return this.version.isEnterprise
? {
label: 'Billing start month',
description:
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
title: 'No billing start date found',
message:
'In order to get the most from this data, please enter your billing period start month. This will ensure that the resulting data is accurate.',
}
: {
label: 'Client counting start date',
description:
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
title: 'No start date found',
message:
'In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month.',
};
}
get isDateRange() {
return !isSameMonth(
parseAPITimestamp(this.getActivityResponse.startTime),
parseAPITimestamp(this.getActivityResponse.endTime)
);
}
get isCurrentMonth() {
return (
isSameMonth(
parseAPITimestamp(this.getActivityResponse.startTime),
parseAPITimestamp(this.args.model.currentDate)
) &&
isSameMonth(
parseAPITimestamp(this.getActivityResponse.endTime),
parseAPITimestamp(this.args.model.currentDate)
)
);
}
get startTimeDiscrepancy() {
// show banner if startTime returned from activity log (response) is after the queried startTime
const activityStartDateObject = parseAPITimestamp(this.getActivityResponse.startTime);
const queryStartDateObject = parseAPITimestamp(this.startMonthTimestamp);
let message = 'You requested data from';
if (this.startMonthTimestamp === this.args.model.licenseStartTimestamp && this.version.isEnterprise) {
// on init, date is automatically pulled from license start date and user hasn't queried anything yet
message = 'Your license start date is';
}
if (
isAfter(activityStartDateObject, queryStartDateObject) &&
!isSameMonth(activityStartDateObject, queryStartDateObject)
) {
return `${message} ${parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy')}.
We only have data from ${parseAPITimestamp(this.getActivityResponse.startTime, 'MMMM yyyy')},
and that is what is being shown here.`;
} else {
return null;
}
}
get upgradeDuringActivity() {
const versionHistory = this.args.model.versionHistory;
if (!versionHistory || versionHistory.length === 0) {
return null;
}
// filter for upgrade data of noteworthy upgrades (1.9 and/or 1.10)
const upgradeVersionHistory = versionHistory.filter(
({ version }) => version.match('1.9') || version.match('1.10')
);
if (!upgradeVersionHistory || upgradeVersionHistory.length === 0) {
return null;
}
const activityStart = parseAPITimestamp(this.getActivityResponse.startTime);
const activityEnd = parseAPITimestamp(this.getActivityResponse.endTime);
// filter and return all upgrades that happened within date range of queried activity
const upgradesWithinData = upgradeVersionHistory.filter(({ timestampInstalled }) => {
const upgradeDate = parseAPITimestamp(timestampInstalled);
return isAfter(upgradeDate, activityStart) && isBefore(upgradeDate, activityEnd);
});
return upgradesWithinData.length === 0 ? null : upgradesWithinData;
}
get upgradeVersionAndDate() {
if (!this.upgradeDuringActivity) return null;
if (this.upgradeDuringActivity.length === 2) {
const [firstUpgrade, secondUpgrade] = this.upgradeDuringActivity;
const firstDate = parseAPITimestamp(firstUpgrade.timestampInstalled, 'MMM d, yyyy');
const secondDate = parseAPITimestamp(secondUpgrade.timestampInstalled, 'MMM d, yyyy');
return `Vault was upgraded to ${firstUpgrade.version} (${firstDate}) and ${secondUpgrade.version} (${secondDate}) during this time range.`;
} else {
const [upgrade] = this.upgradeDuringActivity;
return `Vault was upgraded to ${upgrade.version} on ${parseAPITimestamp(
upgrade.timestampInstalled,
'MMM d, yyyy'
)}.`;
}
}
get upgradeExplanation() {
if (!this.upgradeDuringActivity) return null;
if (this.upgradeDuringActivity.length === 1) {
const version = this.upgradeDuringActivity[0].version;
if (version.match('1.9')) {
return ' How we count clients changed in 1.9, so keep that in mind when looking at the data.';
}
if (version.match('1.10')) {
return ' We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data.';
}
}
// return combined explanation if spans multiple upgrades
return ' How we count clients changed in 1.9 and we added monthly breakdowns and mount level attribution starting in 1.10. Keep this in mind when looking at the data.';
}
get formattedStartDate() {
if (!this.startMonthTimestamp) return null;
return parseAPITimestamp(this.startMonthTimestamp, 'MMMM yyyy');
}
// GETTERS FOR RESPONSE & DATA
// on init API response uses license start_date, getter updates when user queries dates
get getActivityResponse() {
return this.queriedActivityResponse || this.args.model.activity;
}
get byMonthActivityData() {
if (this.selectedNamespace) {
return this.filteredActivityByMonth;
} else {
return this.getActivityResponse?.byMonth;
}
}
get hasAttributionData() {
if (this.selectedAuthMethod) return false;
if (this.selectedNamespace) {
return this.authMethodOptions.length > 0;
}
return !!this.totalClientAttribution && this.totalUsageCounts && this.totalUsageCounts.clients !== 0;
}
// (object) top level TOTAL client counts for given date range
get totalUsageCounts() {
return this.selectedNamespace ? this.filteredActivityByNamespace : this.getActivityResponse.total;
}
// (object) single month new client data with total counts + array of namespace breakdown
get newClientCounts() {
return this.isDateRange ? null : this.byMonthActivityData[0]?.new_clients;
}
// total client data for horizontal bar chart in attribution component
get totalClientAttribution() {
if (this.selectedNamespace) {
return this.filteredActivityByNamespace?.mounts || null;
} else {
return this.getActivityResponse?.byNamespace || null;
}
}
// new client data for horizontal bar chart
get newClientAttribution() {
// new client attribution only available in a single, historical month (not a date range or current month)
if (this.isDateRange || this.isCurrentMonth) return null;
if (this.selectedNamespace) {
return this.newClientCounts?.mounts || null;
} else {
return this.newClientCounts?.namespaces || null;
}
}
get responseTimestamp() {
return this.getActivityResponse.responseTimestamp;
}
// FILTERS
get filteredActivityByNamespace() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse;
}
if (!auth) {
return this.getActivityResponse.byNamespace.find((ns) => ns.label === namespace);
}
return this.getActivityResponse.byNamespace
.find((ns) => ns.label === namespace)
.mounts?.find((mount) => mount.label === auth);
}
get filteredActivityByMonth() {
const namespace = this.selectedNamespace;
const auth = this.selectedAuthMethod;
if (!namespace && !auth) {
return this.getActivityResponse?.byMonth;
}
const namespaceData = this.getActivityResponse?.byMonth
.map((m) => m.namespaces_by_key[namespace])
.filter((d) => d !== undefined);
if (!auth) {
return namespaceData.length === 0 ? null : namespaceData;
}
const mountData = namespaceData
.map((namespace) => namespace.mounts_by_key[auth])
.filter((d) => d !== undefined);
return mountData.length === 0 ? null : mountData;
}
@action
async handleClientActivityQuery({ dateType, monthIdx, year }) {
this.showBillingStartModal = false;
switch (dateType) {
case 'cancel':
return;
case 'reset': // clicked 'Current billing period' in calendar widget -> reset to initial start/end dates
this.activityQueryParams.start.timestamp = this.args.model.licenseStartTimestamp;
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
break;
case 'currentMonth': // clicked 'Current month' from calendar widget
this.activityQueryParams.start.timestamp = this.args.model.currentDate;
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
break;
case 'startDate': // from "Edit billing start" modal
this.activityQueryParams.start = { monthIdx, year };
this.activityQueryParams.end.timestamp = this.args.model.currentDate;
break;
case 'endDate': // selected month and year from calendar widget
this.activityQueryParams.end = { monthIdx, year };
break;
default:
break;
}
try {
this.isLoadingQuery = true;
const response = await this.store.queryRecord('clients/activity', {
start_time: this.activityQueryParams.start,
end_time: this.activityQueryParams.end,
});
// preference for byMonth timestamps because those correspond to a user's query
const { byMonth } = response;
this.startMonthTimestamp = byMonth[0]?.timestamp || response.startTime;
this.endMonthTimestamp = byMonth[byMonth.length - 1]?.timestamp || response.endTime;
if (response.id === 'no-data') {
this.noActivityData = true;
} else {
this.noActivityData = false;
getStorage().setItem('vault:ui-inputted-start-date', this.startMonthTimestamp);
}
this.queriedActivityResponse = response;
// reset search-select filters
this.selectedNamespace = null;
this.selectedAuthMethod = null;
this.authMethodOptions = [];
} catch (e) {
this.errorObject = e;
return e;
} finally {
this.isLoadingQuery = false;
}
}
get hasMultipleMonthsData() {
return this.byMonthActivityData && this.byMonthActivityData.length > 1;
}
@action
selectNamespace([value]) {
this.selectedNamespace = value;
if (!value) {
this.authMethodOptions = [];
// on clear, also make sure auth method is cleared
this.selectedAuthMethod = null;
} else {
// Side effect: set auth namespaces
const mounts = this.filteredActivityByNamespace.mounts?.map((mount) => ({
id: mount.label,
name: mount.label,
}));
this.authMethodOptions = mounts;
}
}
@action
setAuthMethod([authMount]) {
this.selectedAuthMethod = authMount;
}
// validation function sent to <DateDropdown> selecting 'endDate'
@action
isEndBeforeStart(selection) {
let { start } = this.activityQueryParams;
start = start?.timestamp ? parseAPITimestamp(start.timestamp) : new Date(start.year, start.monthIdx);
return isBefore(selection, start) && !isSameMonth(start, selection)
? `End date must be after ${format(start, 'MMMM yyyy')}`
: false;
}
}

View File

@@ -22,8 +22,10 @@
{{#modal-dialog
tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="35px 0"
}}
<div class={{concat "chart-tooltip horizontal-chart " (if this.isLabel "is-label-fit-content")}}>
<p>{{this.tooltipText}}</p>
<div class="chart-tooltip {{if this.isLabel ' is-label-fit-content'}}">
{{#each this.tooltipText as |text|}}
<p>{{text}}</p>
{{/each}}
</div>
<div class="chart-tooltip-arrow"></div>
{{/modal-dialog}}

View File

@@ -11,7 +11,7 @@ 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, formatTooltipNumber } from 'vault/utils/chart-helpers';
import { GREY, BLUE_PALETTE } from 'vault/utils/chart-helpers';
import { tracked } from '@glimmer/tracking';
import { formatNumber } from 'core/helpers/format-number';
@@ -27,7 +27,6 @@ import { formatNumber } from 'core/helpers/format-number';
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} labelKey - string of key name for label value in chart data
* @param {string} xKey - string of key name for x value in chart data
* @param {object} totalCounts - object to calculate percentage for tooltip
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/
@@ -39,7 +38,7 @@ const LINE_HEIGHT = 24; // each bar w/ padding is 24 pixels thick
export default class HorizontalBarChart extends Component {
@tracked tooltipTarget = '';
@tracked tooltipText = '';
@tracked tooltipText = [];
@tracked isLabel = null;
get labelKey() {
@@ -50,18 +49,10 @@ export default class HorizontalBarChart extends Component {
return this.args.xKey || 'clients';
}
get chartLegend() {
return this.args.chartLegend;
}
get topNamespace() {
return this.args.dataset[maxIndex(this.args.dataset, (d) => d[this.xKey])];
}
get total() {
return this.args.totalCounts[this.xKey] || null;
}
@action removeTooltip() {
this.tooltipTarget = null;
}
@@ -71,7 +62,7 @@ export default class HorizontalBarChart extends Component {
// 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
const stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
const stackFunction = stack().keys(this.args.chartLegend.map((l) => l.key));
const dataset = chartData;
const stackedData = stackFunction(dataset);
const labelKey = this.labelKey;
@@ -98,7 +89,7 @@ export default class HorizontalBarChart extends Component {
.attr('data-test-group', (d) => `${d.key}`)
// 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]);
.style('fill', (d, i) => BLUE_PALETTE[i]);
const yAxis = axisLeft(yScale).tickSize(0);
@@ -171,7 +162,6 @@ export default class HorizontalBarChart extends Component {
.style('opacity', '0')
.style('mix-blend-mode', 'multiply');
const dataBars = chartSvg.selectAll('rect.data-bar');
const actionBarSelection = chartSvg.selectAll('rect.action-bar');
const compareAttributes = (elementA, elementB, attr) =>
@@ -183,28 +173,15 @@ export default class HorizontalBarChart extends Component {
const hoveredElement = actionBars.filter((bar) => bar[labelKey] === data[labelKey]).node();
this.tooltipTarget = hoveredElement;
this.isLabel = false;
this.tooltipText = this.total
? `${Math.round((data[xKey] * 100) / this.total)}%
of total client counts:
${formatTooltipNumber(data.entity_clients)} entity clients,
${formatTooltipNumber(data.non_entity_clients)} non-entity clients.`
: '';
this.tooltipText = []; // clear stats
this.args.chartLegend.forEach(({ key, label }) => {
this.tooltipText.pushObject(`${formatNumber([data[key]])} ${label}`);
});
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
@@ -214,15 +191,10 @@ export default class HorizontalBarChart extends Component {
const hoveredElement = labelActionBar.filter((bar) => bar[labelKey] === data[labelKey]).node();
this.tooltipTarget = hoveredElement;
this.isLabel = true;
this.tooltipText = data[labelKey];
this.tooltipText = [data[labelKey]];
} 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');
@@ -231,11 +203,6 @@ export default class HorizontalBarChart extends Component {
})
.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');

View File

@@ -1,201 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
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,
UPGRADE_WARNING,
SVG_DIMENSIONS,
formatNumbers,
} from 'vault/utils/chart-helpers';
import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
import { formatNumber } from 'core/helpers/format-number';
/**
* @module LineChart
* LineChart components are used to display data in a line plot with accompanying tooltip
*
* @example
* ```js
* <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
* ```
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
* @param {array} upgradeData - array of objects containing version history from the /version-history endpoint
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/
export default class LineChart extends Component {
@tracked tooltipTarget = '';
@tracked tooltipMonth = '';
@tracked tooltipTotal = '';
@tracked tooltipNew = '';
@tracked tooltipUpgradeText = '';
get yKey() {
return this.args.yKey || 'clients';
}
get xKey() {
return this.args.xKey || 'month';
}
get upgradeData() {
const upgradeData = this.args.upgradeData;
if (!upgradeData) return null;
if (!Array.isArray(upgradeData)) {
console.debug('upgradeData must be an array of objects containing upgrade history'); // eslint-disable-line
return null;
} else if (!Object.keys(upgradeData[0]).includes('timestampInstalled')) {
// eslint-disable-next-line
console.debug(
`upgrade must be an object with the following key names: ['version', 'previousVersion', 'timestampInstalled']`
);
return null;
} else {
return upgradeData?.map((versionData) => {
return {
[this.xKey]: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'),
...versionData,
};
});
}
}
@action removeTooltip() {
this.tooltipTarget = null;
}
@action
renderChart(element, [chartData]) {
const dataset = chartData;
const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp)
const domainMax = max(filteredData.map((d) => d[this.yKey]));
const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// clear out DOM before appending anything
chartSvg.selectAll('g').remove().exit().data(filteredData).enter();
// DEFINE AXES SCALES
const yScale = scaleLinear().domain([0, domainMax]).range([0, 100]).nice();
const yAxisScale = scaleLinear().domain([0, domainMax]).range([SVG_DIMENSIONS.height, 0]).nice();
// use full dataset (instead of filteredData) so x-axis spans months with and without data
const xScale = scalePoint()
.domain(dataset.map((d) => d[this.xKey]))
.range([0, SVG_DIMENSIONS.width])
.padding(0.2);
// CUSTOMIZE AND APPEND AXES
const yAxis = axisLeft(yAxisScale)
.ticks(4)
.tickPadding(10)
.tickSizeInner(-SVG_DIMENSIONS.width) // makes grid lines length of svg
.tickFormat(formatNumbers);
const xAxis = axisBottom(xScale).tickSize(0);
yAxis(chartSvg.append('g').attr('data-test-line-chart', 'y-axis-labels'));
xAxis(
chartSvg
.append('g')
.attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)
.attr('data-test-line-chart', 'x-axis-labels')
);
chartSvg.selectAll('.domain').remove();
const findUpgradeData = (datum) => {
return this.upgradeData
? this.upgradeData.find((upgrade) => upgrade[this.xKey] === datum[this.xKey])
: null;
};
// VERSION UPGRADE INDICATOR
chartSvg
.append('g')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.attr('class', 'upgrade-circle')
.attr('data-test-line-chart', (d) => `upgrade-${d[this.xKey]}`)
.attr('fill', UPGRADE_WARNING)
.style('opacity', (d) => (findUpgradeData(d) ? '1' : '0'))
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 10);
// PATH BETWEEN PLOT POINTS
const lineGenerator = line()
.x((d) => xScale(d[this.xKey]))
.y((d) => yAxisScale(d[this.yKey]));
chartSvg
.append('g')
.append('path')
.attr('fill', 'none')
.attr('stroke', LIGHT_AND_DARK_BLUE[1])
.attr('stroke-width', 0.5)
.attr('d', lineGenerator(filteredData));
// LINE PLOTS (CIRCLES)
chartSvg
.append('g')
.selectAll('circle')
.data(filteredData)
.enter()
.append('circle')
.attr('data-test-line-chart', 'plot-point')
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.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(filteredData)
.enter()
.append('circle')
.attr('class', 'hover-circle')
.style('cursor', 'pointer')
.style('opacity', '0')
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
.attr('cx', (d) => xScale(d[this.xKey]))
.attr('r', 10);
const hoverCircles = chartSvg.selectAll('.hover-circle');
// MOUSE EVENT FOR TOOLTIP
hoverCircles.on('mouseover', (data) => {
// TODO: how to generalize this?
this.tooltipMonth = formatChartDate(data[this.xKey]);
this.tooltipTotal = formatNumber([data[this.yKey]]) + ' total clients';
this.tooltipNew = (formatNumber([data?.new_clients[this.yKey]]) || '0') + ' new clients';
this.tooltipUpgradeText = '';
const upgradeInfo = findUpgradeData(data);
if (upgradeInfo) {
const { version, previousVersion } = upgradeInfo;
this.tooltipUpgradeText = `Vault was upgraded
${previousVersion ? 'from ' + previousVersion : ''} to ${version}`;
}
const node = hoverCircles.filter((plot) => plot[this.xKey] === data[this.xKey]).node();
this.tooltipTarget = node;
});
}
}

View File

@@ -1,53 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { calculateAverage } from 'vault/utils/chart-helpers';
/**
* @module MonthlyUsage
* MonthlyUsage components show how many total clients use Vault each month. Displaying the average totals to the left of a stacked, vertical bar chart.
*
* @example
* ```js
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@responseTimestamp={{this.responseTimestamp}}
@verticalBarChartData={{this.byMonthActivityData}}
/>
* ```
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
* @param {array} verticalBarChartData - array of flattened objects
sample object =
{
month: '1/22',
entity_clients: 23,
non_entity_clients: 45,
clients: 68,
namespaces: [],
new_clients: {
entity_clients: 11,
non_entity_clients: 36,
clients: 47,
namespaces: [],
},
}
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
*/
export default class MonthlyUsage extends Component {
get averageTotalClients() {
return calculateAverage(this.args.verticalBarChartData, 'clients') || '0';
}
get averageNewClients() {
return (
calculateAverage(
this.args.verticalBarChartData?.map((d) => d.new_clients),
'clients'
) || '0'
);
}
}

View File

@@ -0,0 +1,146 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
<p class="has-bottom-margin-xl">
This dashboard surfaces Vault client usage over time.
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
Date queries are sent in UTC.
</p>
<h2 class="title is-6 has-bottom-margin-xs" data-test-counts-start-label>
{{this.versionText.label}}
</h2>
<div class="is-flex-align-baseline">
{{#if this.formattedStartDate}}
<p class="is-size-6" data-test-counts-start-month>{{this.formattedStartDate}}</p>
<Hds::Button
class="has-left-margin-xs"
@text="Edit"
@color="tertiary"
@icon="edit"
@iconPosition="trailing"
data-test-counts-start-edit
{{on "click" (fn (mut this.showBillingStartModal) true)}}
/>
{{else}}
<DateDropdown
@handleSubmit={{this.onDateChange}}
@dateType="startDate"
@submitText="Save"
data-test-counts-start-dropdown
/>
{{/if}}
</div>
<p class="is-8 has-text-grey has-bottom-margin-l" data-test-counts-description>
{{this.versionText.description}}
</p>
{{#if (eq @activity.id "no-data")}}
<Clients::NoData @config={{@config}} @dateRangeMessage={{this.dateRangeMessage}} />
{{else if @activityError}}
<Clients::Error @error={{@activityError}} />
{{else}}
{{#if (eq @config.enabled "Off")}}
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
<A.Title data-test-counts-disabled>Tracking is disabled</A.Title>
<A.Description>
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need
to
<Hds::Link::Inline @route="vault.cluster.clients.edit">edit the configuration</Hds::Link::Inline>
to enable tracking again.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if @startTimestamp}}
<div class="is-subtitle-gray has-bottom-margin-m">
FILTERS
<Toolbar data-test-clients-filter-bar>
<ToolbarFilters>
<CalendarWidget
@startTimestamp={{this.startTimestampISO}}
@endTimestamp={{this.endTimestampISO}}
@selectMonth={{this.onDateChange}}
/>
{{#if (or @namespace this.namespaces)}}
<SearchSelect
@id="namespace-search-select"
@options={{this.namespaces}}
@inputValue={{if @namespace (array @namespace)}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{fn this.setFilterValue "ns"}}
@placeholder="Filter by namespace"
@displayInherit={{true}}
class="is-marginless"
data-test-counts-namespaces
/>
{{/if}}
{{#if (or @mountPath this.mountPaths)}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.mountPaths}}
@inputValue={{if @mountPath (array @mountPath)}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{fn this.setFilterValue "mountPath"}}
@placeholder="Filter by mount path"
@displayInherit={{true}}
data-test-counts-auth-mounts
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
</div>
{{/if}}
{{#if this.filteredActivity}}
{{#if this.startTimeDiscrepancy}}
<Hds::Alert data-test-counts-start-discrepancy @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
<A.Title>Warning</A.Title>
<A.Description data-test-counts-start-discrepancy>
{{this.startTimeDiscrepancy}}
</A.Description>
</Hds::Alert>
{{/if}}
{{yield}}
{{else if (and (not @config.billingStartTimestamp) (not @startTimestamp))}}
{{! Empty state for no billing/license start date }}
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
{{else}}
<EmptyState
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
@message="Update the filter values or click the button to reset them."
>
<Hds::Button @text="Reset filters" @color="tertiary" @icon="reload" {{on "click" this.resetFilters}} />
</EmptyState>
{{/if}}
{{/if}}
</div>
{{#if this.showBillingStartModal}}
<Hds::Modal id="clients-edit-date-modal" @onClose={{fn (mut this.showBillingStartModal) false}} as |M|>
<M.Header>
Edit start month
</M.Header>
<M.Body>
<p class="has-bottom-margin-s">
{{this.versionText.description}}
</p>
<p><strong>{{this.versionText.label}}</strong></p>
<DateDropdown class="has-top-padding-s" @handleSubmit={{this.onDateChange}} @dateType="startDate" @submitText="Save" />
</M.Body>
<M.Footer as |F|>
<Hds::Button data-test-date-dropdown-cancel @text="Cancel" @color="secondary" {{on "click" F.close}} />
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { fromUnixTime, getUnixTime, isSameMonth, isAfter } from 'date-fns';
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { formatDateObject } from 'core/utils/client-count-utils';
import type VersionService from 'vault/services/version';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsConfigModel from 'vault/models/clients/config';
import type StoreService from 'vault/services/store';
import timestamp from 'core/utils/timestamp';
interface Args {
activity: ClientsActivityModel;
config: ClientsConfigModel;
startTimestamp: number;
endTimestamp: number;
namespace: string;
mountPath: string;
onFilterChange: CallableFunction;
}
export default class ClientsCountsPageComponent extends Component<Args> {
@service declare readonly version: VersionService;
@service declare readonly store: StoreService;
get startTimestampISO() {
return this.args.startTimestamp ? fromUnixTime(this.args.startTimestamp).toISOString() : null;
}
get endTimestampISO() {
return this.args.endTimestamp ? fromUnixTime(this.args.endTimestamp).toISOString() : null;
}
get formattedStartDate() {
return this.startTimestampISO ? parseAPITimestamp(this.startTimestampISO, 'MMMM yyyy') : null;
}
// returns text for empty state message if noActivityData
get dateRangeMessage() {
if (this.startTimestampISO && this.endTimestampISO) {
const endMonth = isSameMonth(
parseAPITimestamp(this.startTimestampISO) as Date,
parseAPITimestamp(this.endTimestampISO) as Date
)
? ''
: `to ${parseAPITimestamp(this.endTimestampISO, 'MMMM yyyy')}`;
// completes the message 'No data received from { dateRangeMessage }'
return `from ${parseAPITimestamp(this.startTimestampISO, 'MMMM yyyy')} ${endMonth}`;
}
return null;
}
get versionText() {
return this.version.isEnterprise
? {
label: 'Billing start month',
description:
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
title: 'No billing start date found',
message:
'In order to get the most from this data, please enter your billing period start month. This will ensure that the resulting data is accurate.',
}
: {
label: 'Client counting start date',
description:
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
title: 'No start date found',
message:
'In order to get the most from this data, please enter a start month above. Vault will calculate new clients starting from that month.',
};
}
get namespaces() {
return this.args.activity.byNamespace
? this.args.activity.byNamespace.map((namespace) => ({
name: namespace.label,
id: namespace.label,
}))
: [];
}
get mountPaths() {
if (this.namespaces.length) {
return this.activityForNamespace?.mounts.map((mount) => ({
id: mount.label,
name: mount.label,
}));
}
return [];
}
get startTimeDiscrepancy() {
// show banner if startTime returned from activity log (response) is after the queried startTime
const { activity, config } = this.args;
const activityStartDateObject = parseAPITimestamp(activity.startTime) as Date;
const queryStartDateObject = parseAPITimestamp(this.startTimestampISO) as Date;
const isEnterprise =
this.startTimestampISO === config.billingStartTimestamp?.toISOString() && this.version.isEnterprise;
const message = isEnterprise ? 'Your license start date is' : 'You requested data from';
if (
isAfter(activityStartDateObject, queryStartDateObject) &&
!isSameMonth(activityStartDateObject, queryStartDateObject)
) {
return `${message} ${this.formattedStartDate}.
We only have data from ${parseAPITimestamp(activity.startTime, 'MMMM yyyy')},
and that is what is being shown here.`;
} else {
return null;
}
}
get activityForNamespace() {
const { activity, namespace } = this.args;
return namespace ? activity.byNamespace.find((ns) => ns.label === namespace) : null;
}
get filteredActivity() {
// return activity counts based on selected namespace and auth mount values
const { namespace, mountPath, activity } = this.args;
if (namespace) {
return mountPath
? this.activityForNamespace?.mounts.find((mount) => mount.label === mountPath)
: this.activityForNamespace;
}
return activity.total;
}
@action
onDateChange(dateObject: { dateType: string; monthIdx: string; year: string }) {
const { dateType, monthIdx, year } = dateObject;
const { config } = this.args;
const currentTimestamp = getUnixTime(timestamp.now());
// converts the selectedDate to unix timestamp for activity query
const selectedDate = formatDateObject({ monthIdx, year }, dateType === 'endDate');
if (dateType !== 'cancel') {
const start_time = {
reset: getUnixTime(config?.billingStartTimestamp) || null, // clicked 'Current billing period' in calendar widget -> resets to billing start date
currentMonth: currentTimestamp, // clicked 'Current month' from calendar widget -> defaults to currentTimestamp
startDate: selectedDate, // from "Edit billing start" modal
}[dateType];
// endDate type is selection from calendar widget
const end_time = dateType === 'endDate' ? selectedDate : currentTimestamp; // defaults to currentTimestamp
const params = start_time !== undefined ? { start_time, end_time } : { end_time };
this.args.onFilterChange(params);
}
}
@action
setFilterValue(type: 'ns' | 'mountPath', [value]: [string | undefined]) {
const params = { [type]: value };
// unset mountPath value when namespace is cleared
if (type === 'ns' && !value) {
params['mountPath'] = undefined;
}
this.args.onFilterChange(params);
}
@action resetFilters() {
this.args.onFilterChange({
start_time: undefined,
end_time: undefined,
ns: undefined,
mountPath: undefined,
});
}
}

View File

@@ -0,0 +1,27 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Clients::RunningTotal
@byMonthActivityData={{this.byMonthActivityData}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@isCurrentMonth={{this.isCurrentMonth}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradeDuringActivity}}
@responseTimestamp={{@activity.responseTimestamp}}
/>
{{#if this.hasAttributionData}}
<Clients::Attribution
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}
@selectedNamespace={{@namespace}}
@startTimestamp={{this.startTimeISO}}
@endTimestamp={{this.endTimeISO}}
@responseTimestamp={{@activity.responseTimestamp}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@upgradeExplanation={{this.upgradeExplanation}}
/>
{{/if}}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
export default ActivityComponent;

View File

@@ -0,0 +1,59 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.isDateRange}}
<Clients::ChartContainer
@title={{this.title}}
@description={{this.description}}
@timestamp={{@activity.responseTimestamp}}
@hasChartData={{this.byMonthActivityData}}
class="no-legend"
>
<:subTitle>
<StatText
@label="Total sync clients"
@subText="The total number of secrets synced from Vault to other destinations during this date range."
@value={{this.totalUsageCounts.secret_syncs}}
@size="l"
data-test-total-sync-clients
/>
</:subTitle>
<:stats>
<StatText
@label="Average sync clients per month"
@value={{this.average this.byMonthActivityData "secret_syncs"}}
@size="m"
class="data-details-top has-top-padding-l"
data-test-average-sync-clients
/>
</:stats>
<:chart>
<Clients::Charts::VerticalBarBasic
@chartTitle={{this.title}}
@data={{this.byMonthActivityData}}
@dataKey="secret_syncs"
@chartHeight={{200}}
/>
</:chart>
<:emptyState>
<EmptyState
@title="No monthly secrets sync clients"
@subTitle="There is no secrets sync data available for this date range."
@bottomBorder={{true}}
/>
</:emptyState>
</Clients::ChartContainer>
{{else}}
<div class="chart-wrapper" data-test-usage-stats>
<div class="chart-header has-bottom-margin-m">
<h2 class="chart-title">{{this.title}}</h2>
<p class="chart-description has-bottom-padding-m">{{this.description}}</p>
</div>
<StatText @label="Total sync clients" @value={{this.totalUsageCounts.secret_syncs}} @size="l" />
</div>
{{/if}}

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
export default class SyncComponent extends ActivityComponent {
title = 'Secrets sync usage';
description =
'This data can be used to understand how many secrets sync clients have been used for this date range. A secret with a configured sync destination would qualify as a unique and active client.';
}

View File

@@ -0,0 +1,101 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if (and this.byMonthActivityData this.isDateRange)}}
<Clients::ChartContainer
@title="Entity/Non-entity clients"
@description="This data can be used to understand how many entity and non-entity clients are using Vault each month for this date range."
@timestamp={{@activity.responseTimestamp}}
@legend={{this.legend}}
@hasChartData={{true}}
data-test-chart="monthly total"
>
<:subTitle>
<h2 class="chart-title">Total monthly clients</h2>
<p class="chart-subtext">
Each client is counted once per month. This can help with capacity planning.
</p>
</:subTitle>
<:stats>
<StatText
@label="Average total clients per month"
@value={{this.averageTotalClients}}
@size="m"
class="data-details-top has-top-padding-l"
data-test-chart-stat="monthly total"
/>
<StatText
@label="Average new clients per month"
@value={{this.averageNewClients}}
@size="m"
class="data-details-bottom has-top-padding-l"
data-test-chart-stat="monthly new"
/>
</:stats>
<:chart>
<Clients::VerticalBarChart @dataset={{this.byMonthActivityData}} @chartLegend={{this.legend}} />
</:chart>
</Clients::ChartContainer>
<Clients::ChartContainer
@title="Monthly new"
@description="Entity or non-entity clients which interacted with Vault for the first time during this date range, displayed per month."
@timestamp={{@activity.responseTimestamp}}
@legend={{this.legend}}
@hasChartData={{this.averageNewClients}}
class={{unless this.averageNewClients "no-legend"}}
data-test-chart="monthly new"
>
<:stats>
<StatText
@label="Average new entity clients per month"
@value={{this.average this.byMonthNewClients "entity_clients"}}
@size="m"
class="chart-subTitle has-top-padding-l"
data-test-chart-stat="entity"
/>
<StatText
@label="Average new non-entity clients per month"
@value={{this.average this.byMonthNewClients "non_entity_clients"}}
@size="m"
class="data-details-top has-top-padding-l"
data-test-chart-stat="nonentity"
/>
</:stats>
<:chart>
<Clients::VerticalBarChart @dataset={{this.byMonthNewClients}} @chartLegend={{this.legend}} />
</:chart>
<:emptyState>
<EmptyState
@title="No new clients"
@subTitle="There is no new client data available for this {{if
this.countsController.mountPath
'auth method'
'namespace'
}} in this date range."
@bottomBorder={{true}}
/>
</:emptyState>
</Clients::ChartContainer>
{{else}}
{{! UsageStats render when viewing a single, historical month AND activity data predates new client breakdown (< v1.10.0)
or viewing the current month filtered down to auth method }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within
{{or @mountPath 'this namespace and all its children'}}.
{{if
this.isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
}}"
@totalUsageCounts={{this.tokenUsageCounts}}
/>
{{/if}}

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ActivityComponent from '../activity';
import type {
ClientActivityNewClients,
ClientActivityMonthly,
ClientActivityResourceByKey,
} from 'vault/vault/models/clients/activity';
export default class ClientsTokenPageComponent extends ActivityComponent {
legend = [
{ key: 'entity_clients', label: 'entity clients' },
{ key: 'non_entity_clients', label: 'non-entity clients' },
];
calculateClientAverages(
dataset:
| ClientActivityMonthly[]
| (ClientActivityResourceByKey | undefined)[]
| (ClientActivityNewClients | undefined)[]
| undefined
) {
return ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
const average = this.average(dataset, key);
return (count += average || 0);
}, 0);
}
get averageTotalClients() {
return this.calculateClientAverages(this.byMonthActivityData);
}
get averageNewClients() {
return this.calculateClientAverages(this.byMonthNewClients);
}
get tokenUsageCounts() {
if (this.totalUsageCounts) {
const { entity_clients, non_entity_clients } = this.totalUsageCounts;
return {
clients: entity_clients + non_entity_clients,
entity_clients,
non_entity_clients,
};
}
return null;
}
}

View File

@@ -0,0 +1,119 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if (gt @byMonthActivityData.length 1)}}
<Clients::ChartContainer
@title="Vault client counts"
@description="The total clients in the specified date range. This includes entity, non-entity, and secrets sync clients. The total client count number is an important consideration for Vault billing."
@timestamp={{@responseTimestamp}}
@hasChartData={{true}}
data-test-chart="running total"
>
<:subTitle>
<StatText
@label="Running client total"
@subText="The number of clients which interacted with Vault during this date range."
@value={{@runningTotals.clients}}
@size="l"
/>
</:subTitle>
<:stats>
<div class="data-details-top has-top-padding-l">
<div class="is-flex-row">
<StatText
@label="Entity clients"
@value={{@runningTotals.entity_clients}}
@size="m"
data-test-chart-stat="running total entity"
/>
<StatText
@label="Secrets sync clients"
@value={{@runningTotals.secret_syncs}}
@size="m"
class="has-left-margin-l"
data-test-chart-stat="running total sync"
/>
</div>
</div>
<StatText
@label="Non-entity clients"
@value={{@runningTotals.non_entity_clients}}
@size="m"
class="data-details-bottom"
data-test-chart-stat="running total sync"
/>
</:stats>
<:chart>
<Clients::Charts::Line @dataset={{@byMonthActivityData}} @upgradeData={{@upgradeData}} @chartHeight="250" />
</:chart>
</Clients::ChartContainer>
{{else}}
{{#let (get @byMonthActivityData "0") as |singleMonthData|}}
{{#if (and @isHistoricalMonth singleMonthData.new_clients.clients)}}
<div class="chart-wrapper single-month-grid" data-test-running-total="single-month-stats">
<div class="chart-header has-bottom-margin-sm">
<h2 class="chart-title">Vault client counts</h2>
<p class="chart-description">
The total billable clients in the specified date range. This includes entity, non-entity, and secrets sync
clients. The total client count number is an important consideration for Vault billing.
</p>
</div>
<div class="single-month-stats" data-test-new>
<div class="single-month-section-title">
<StatText
@label="New clients"
@subText="This is the number of clients which were created in Vault for the first time in the selected month."
@value={{singleMonthData.new_clients.clients}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{singleMonthData.new_clients.entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{singleMonthData.new_clients.non_entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-sync">
<StatText @label="Secrets sync clients" @value={{singleMonthData.new_clients.secret_syncs}} @size="m" />
</div>
</div>
<div class="single-month-stats" data-test-total>
<div class="single-month-section-title">
<StatText
@label="Total monthly clients"
@subText="This is the number of total clients which used Vault for the given month, both new and previous."
@value={{singleMonthData.clients}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{singleMonthData.entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{singleMonthData.non_entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-sync">
<StatText @label="Secrets sync clients" @value={{singleMonthData.secret_syncs}} @size="m" />
</div>
</div>
</div>
{{else}}
{{! This renders when either:
-> viewing the current month and all namespaces (no filters)
-> filtering by a namespace with no month over month data
if filtering by a mount with no month over month data <UsageStats> in dashboard.hbs renders }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within this namespace and all its children. {{if
@isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current billing period' filter."
}}"
@totalUsageCounts={{@runningTotals}}
/>
{{/if}}
{{/let}}
{{/if}}

View File

@@ -1,86 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { calculateAverage } from 'vault/utils/chart-helpers';
/**
* @module RunningTotal
* RunningTotal components display total and new client counts in a given date range by month.
* A line chart shows total monthly clients and below a stacked, vertical bar chart shows new clients per month.
*
*
* @example
* ```js
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedNamespace={{this.selectedNamespace}}
@byMonthActivityData={{this.byMonth}}
@runningTotals={{this.runningTotals}}
@upgradeData={{if this.countsIncludeOlderData this.latestUpgradeData}}
/>
* ```
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} selectedAuthMethod - string of auth method label for empty state message in bar chart
* @param {array} byMonthActivityData - array of objects from /activity response, from the 'months' key, includes total and new clients per month
object structure: {
month: '1/22',
entity_clients: 23,
non_entity_clients: 45,
clients: 68,
namespaces: [],
new_clients: {
entity_clients: 11,
non_entity_clients: 36,
clients: 47,
namespaces: [],
},
};
* @param {object} runningTotals - top level totals from /activity response { clients: 3517, entity_clients: 1593, non_entity_clients: 1924 }
* @param {object} upgradeData - object containing version upgrade data e.g.: {version: '1.9.0', previousVersion: null, timestampInstalled: '2021-11-03T10:23:16Z'}
* @param {string} timestamp - ISO timestamp created in serializer to timestamp the response
*
*/
export default class RunningTotal extends Component {
get byMonthNewClients() {
if (this.args.byMonthActivityData) {
return this.args.byMonthActivityData?.map((m) => m.new_clients);
}
return null;
}
get entityClientData() {
return {
runningTotal: this.args.runningTotals.entity_clients,
averageNewClients: calculateAverage(this.byMonthNewClients, 'entity_clients'),
};
}
get nonEntityClientData() {
return {
runningTotal: this.args.runningTotals.non_entity_clients,
averageNewClients: calculateAverage(this.byMonthNewClients, 'non_entity_clients'),
};
}
get hasRunningTotalClients() {
return (
typeof this.entityClientData.runningTotal === 'number' ||
typeof this.nonEntityClientData.runningTotal === 'number'
);
}
get hasAverageNewClients() {
return (
typeof this.entityClientData.averageNewClients === 'number' ||
typeof this.nonEntityClientData.averageNewClients === 'number'
);
}
get singleMonthData() {
return this.args?.byMonthActivityData[0];
}
}

View File

@@ -19,10 +19,11 @@
<div class="columns">
<div class="column">
<StatText
class="column"
@label="Total clients"
@value={{@totalUsageCounts.clients}}
@size="l"
@subText="The sum of entity clientIDs and non-entity clientIDs; this is Vaults primary billing metric."
@subText="The number of clients which interacted with Vault during this month. This is Vaults primary billing metric."
data-test-stat-text="total-clients"
/>
</div>
@@ -42,9 +43,21 @@
@label="Non-entity clients"
@value={{@totalUsageCounts.non_entity_clients}}
@size="l"
@subText="Clients created with a shared set of permissions, but not associated with an entity. "
@subText="Clients created with a shared set of permissions, but not associated with an entity."
data-test-stat-text="non-entity-clients"
/>
</div>
{{#if (gte @totalUsageCounts.secret_syncs 0)}}
<div class="column">
<StatText
class="column"
@label="Secrets sync clients"
@value={{@totalUsageCounts.secret_syncs}}
@size="l"
@subText="A secret with a configured sync destination qualifies as a unique and active client."
data-test-stat-text="secret-syncs"
/>
</div>
{{/if}}
</div>
</div>

View File

@@ -25,10 +25,11 @@
{{#modal-dialog
tagName="div" tetherTarget=this.tooltipTarget targetAttachment="bottom middle" attachment="bottom middle" offset="10px 0"
}}
<div class="chart-tooltip vertical-chart">
<p>{{this.tooltipTotal}}</p>
<p>{{this.entityClients}}</p>
<p>{{this.nonEntityClients}}</p>
<div class="chart-tooltip">
<p class="bold">{{this.tooltipTotal}}</p>
{{#each this.tooltipStats as |stat|}}
<p>{{stat}}</p>
{{/each}}
</div>
<div class="chart-tooltip-arrow"></div>
{{/modal-dialog}}

View File

@@ -13,10 +13,12 @@ import { axisLeft, axisBottom } from 'd3-axis';
import { scaleLinear, scalePoint } from 'd3-scale';
import { stack } from 'd3-shape';
import {
BAR_WIDTH,
GREY,
LIGHT_AND_DARK_BLUE,
BLUE_PALETTE,
SVG_DIMENSIONS,
TRANSLATE,
calculateSum,
formatNumbers,
} from 'vault/utils/chart-helpers';
import { formatNumber } from 'core/helpers/format-number';
@@ -36,16 +38,10 @@ import { formatNumber } from 'core/helpers/format-number';
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/
const BAR_WIDTH = 7; // data bar width is 7 pixels
export default class VerticalBarChart extends Component {
@tracked tooltipTarget = '';
@tracked tooltipTotal = '';
@tracked entityClients = '';
@tracked nonEntityClients = '';
get chartLegend() {
return this.args.chartLegend;
}
@tracked tooltipStats = [];
get xKey() {
return this.args.xKey || 'month';
@@ -59,7 +55,7 @@ export default class VerticalBarChart extends Component {
renderChart(element, [chartData]) {
const dataset = chartData;
const filteredData = dataset.filter((e) => Object.keys(e).includes('clients')); // months with data will contain a 'clients' key (otherwise only a timestamp)
const stackFunction = stack().keys(this.chartLegend.map((l) => l.key));
const stackFunction = stack().keys(this.args.chartLegend.map((l) => l.key));
const stackedData = stackFunction(filteredData);
const chartSvg = select(element);
const domainMax = max(filteredData.map((d) => d[this.yKey]));
@@ -81,7 +77,7 @@ export default class VerticalBarChart extends Component {
.data(stackedData)
.enter()
.append('g')
.style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]);
.style('fill', (d, i) => BLUE_PALETTE[i]);
dataBars
.selectAll('rect')
@@ -155,9 +151,15 @@ export default class VerticalBarChart extends Component {
// MOUSE EVENT FOR TOOLTIP
tooltipRect.on('mouseover', (data) => {
const hoveredMonth = data[this.xKey];
this.tooltipTotal = `${formatNumber([data[this.yKey]])} ${data.new_clients ? 'total' : 'new'} clients`;
this.entityClients = `${formatNumber([data.entity_clients])} entity clients`;
this.nonEntityClients = `${formatNumber([data.non_entity_clients])} non-entity clients`;
const stackedNumbers = []; // accumulates stacked dataset values to calculate total
this.tooltipStats = []; // clear stats
this.args.chartLegend.forEach(({ key, label }) => {
stackedNumbers.push(data[key]);
this.tooltipStats.pushObject(`${formatNumber([data[key]])} ${label}`);
});
this.tooltipTotal = `${formatNumber([calculateSum(stackedNumbers)])} ${
data.new_clients ? 'total' : 'new'
} clients`;
// filter for the tether point that matches the hoveredMonth
const hoveredElement = tooltipTether.filter((data) => data.month === hoveredMonth).node();
this.tooltipTarget = hoveredElement; // grab the node from the list of rects

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller from '@ember/controller';
import { action, set } from '@ember/object';
import type { ClientsCountsRouteParams } from 'vault/routes/vault/cluster/clients/counts';
const queryParamKeys = ['start_time', 'end_time', 'ns', 'mountPath'];
export default class ClientsCountsController extends Controller {
queryParams = queryParamKeys;
start_time: string | number | undefined = undefined;
end_time: string | number | undefined = undefined;
ns: string | undefined = undefined;
mountPath: string | undefined = undefined;
// using router.transitionTo to update the query params results in the model hook firing each time
// this happens when the queryParams object is not added to the route or refreshModel is explicitly set to false
// updating the bound properties does however respect the refreshModel settings and functions expectedly
@action
updateQueryParams(updatedParams: ClientsCountsRouteParams) {
if (!updatedParams) {
this.queryParams.forEach((key) => (this[key as keyof ClientsCountsRouteParams] = undefined));
} else {
Object.keys(updatedParams).forEach((key) => {
if (queryParamKeys.includes(key)) {
const value = updatedParams[key as keyof ClientsCountsRouteParams];
set(this, key as keyof ClientsCountsRouteParams, value as keyof ClientsCountsRouteParams);
}
});
}
}
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller, { inject as controller } from '@ember/controller';
import type ClientsCountsController from '../counts';
export default class ClientsCountsOverviewController extends Controller {
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
@controller('vault.cluster.clients.counts' as never)
declare readonly countsController: ClientsCountsController;
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller, { inject as controller } from '@ember/controller';
import type ClientsCountsController from '../counts';
export default class ClientsCountsSyncController extends Controller {
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
@controller('vault.cluster.clients.counts' as never)
declare readonly countsController: ClientsCountsController;
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Controller, { inject as controller } from '@ember/controller';
import type ClientsCountsController from '../counts';
export default class ClientsCountsTokenController extends Controller {
// not sure why this needs to be cast to never but this definitely accepts a string to point to the controller
@controller('vault.cluster.clients.counts' as never)
declare readonly countsController: ClientsCountsController;
}

View File

@@ -27,7 +27,11 @@ Router.map(function () {
this.route('license');
this.route('mfa-setup');
this.route('clients', function () {
this.route('dashboard');
this.route('counts', function () {
this.route('overview');
this.route('sync');
this.route('token');
});
this.route('config');
this.route('edit');
});

View File

@@ -5,14 +5,21 @@
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
import { action } from '@ember/object';
import getStorage from 'vault/lib/token-storage';
import { inject as service } from '@ember/service';
const INPUTTED_START_DATE = 'vault:ui-inputted-start-date';
import type StoreService from 'vault/services/store';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
export interface ClientsRouteModel {
config: ClientsConfigModel;
versionHistory: ClientsVersionHistoryModel;
}
export default class ClientsRoute extends Route {
@service store;
async getVersionHistory() {
@service declare readonly store: StoreService;
getVersionHistory() {
return this.store
.findAll('clients/version-history')
.then((response) => {
@@ -30,14 +37,8 @@ export default class ClientsRoute extends Route {
model() {
// swallow config error so activity can show if no config permissions
return hash({
config: this.store.queryRecord('clients/config', {}).catch(() => {}),
config: this.store.queryRecord('clients/config', {}).catch(() => ({})),
versionHistory: this.getVersionHistory(),
});
}
@action
deactivate() {
// when navigating away from parent route, delete manually inputted license start date
getStorage().removeItem(INPUTTED_START_DATE);
}
}

View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import timestamp from 'core/utils/timestamp';
import { getUnixTime } from 'date-fns';
import type StoreService from 'vault/services/store';
import type { ClientsRouteModel } from '../clients';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsVersionHistoryModel from 'vault/models/clients/version-history';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type Controller from '@ember/controller';
import type AdapterError from 'ember-data/adapter'; // eslint-disable-line ember/use-ember-data-rfc-395-imports
export interface ClientsCountsRouteParams {
start_time?: string | number | undefined;
end_time?: string | number | undefined;
ns?: string | undefined;
mountPath?: string | undefined;
}
interface ClientsCountsRouteModel {
config: ClientsConfigModel;
versionHistory: ClientsVersionHistoryModel;
activity?: ClientsActivityModel;
activityError?: AdapterError;
startTimestamp: number;
endTimestamp: number;
}
interface ClientsCountsController extends Controller {
model: ClientsCountsRouteModel;
start_time: number | undefined;
end_time: number | undefined;
ns: string | undefined;
mountPath: string | undefined;
}
export default class ClientsCountsRoute extends Route {
@service declare readonly store: StoreService;
queryParams = {
start_time: { refreshModel: true, replace: true },
end_time: { refreshModel: true, replace: true },
ns: { refreshModel: false, replace: true },
mountPath: { refreshModel: false, replace: true },
};
async getActivity(start_time: number, end_time: number) {
let activity, activityError;
// if there is no billingStartTimestamp or selected start date initially we allow the user to manually choose a date
// in that case bypass the query so that the user isn't stuck viewing the activity error
if (start_time) {
try {
activity = await this.store.queryRecord('clients/activity', {
start_time: { timestamp: start_time },
end_time: { timestamp: end_time },
});
} catch (error) {
activityError = error;
}
return [activity, activityError];
}
return [{}, null];
}
async model(params: ClientsCountsRouteParams) {
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ClientsRouteModel;
// we could potentially make an additional request to fetch the license and get the start date from there if the config request fails
const startTimestamp = Number(params.start_time) || getUnixTime(config.billingStartTimestamp);
const endTimestamp = Number(params.end_time) || getUnixTime(timestamp.now());
const [activity, activityError] = await this.getActivity(startTimestamp, endTimestamp);
return {
config,
versionHistory,
activity,
activityError,
startTimestamp,
endTimestamp,
};
}
resetController(controller: ClientsCountsController, isExiting: boolean) {
if (isExiting) {
controller.setProperties({
start_time: undefined,
end_time: undefined,
ns: undefined,
mountPath: undefined,
});
}
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import type RouterService from '@ember/routing/router-service';
export default class ClientsCountsOverviewRoute extends Route {
@service declare readonly router: RouterService;
redirect() {
this.router.transitionTo('vault.cluster.clients.counts.overview');
}
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ClientsCountsOverviewRoute extends Route {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ClientsCountsSyncRoute extends Route {}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
export default class ClientsCountsOverviewRoute extends Route {}

View File

@@ -1,49 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import getStorage from 'vault/lib/token-storage';
import { inject as service } from '@ember/service';
import timestamp from 'core/utils/timestamp';
export default class DashboardRoute extends Route {
@service store;
currentDate = timestamp.now().toISOString();
async getActivity(start_time) {
// on init ONLY make network request if we have a start_time
return start_time
? await this.store.queryRecord('clients/activity', {
start_time: { timestamp: start_time },
end_time: { timestamp: this.currentDate },
})
: {};
}
async getLicenseStartTime() {
try {
const license = await this.store.queryRecord('license', {});
// if license.startTime is 'undefined' return 'null' for consistency
return license.startTime || getStorage().getItem('vault:ui-inputted-start-date') || null;
} catch (e) {
// return null so user can input date manually
// if already inputted manually, will be in localStorage
return getStorage().getItem('vault:ui-inputted-start-date') || null;
}
}
async model() {
const { config, versionHistory } = this.modelFor('vault.cluster.clients');
const licenseStart = await this.getLicenseStartTime();
const activity = await this.getActivity(licenseStart);
return {
config,
versionHistory,
activity,
licenseStartTimestamp: licenseStart,
currentDate: this.currentDate,
};
}
}

View File

@@ -10,6 +10,6 @@ export default class ClientsIndexRoute extends Route {
@service router;
redirect() {
this.router.transitionTo('vault.cluster.clients.dashboard');
this.router.transitionTo('vault.cluster.clients.counts.overview');
}
}

View File

@@ -34,9 +34,8 @@
.single-month-breakdown-nonentity {
grid-column-start: 2;
}
.stacked-charts {
display: grid;
width: 100%;
.single-month-breakdown-sync {
grid-column-start: 3;
}
.single-chart-grid {
@@ -44,6 +43,9 @@
grid-template-columns: 1fr 0.3fr 3.7fr;
grid-template-rows: 0.5fr 1fr 1fr 1fr 0.25fr;
width: 100%;
&.no-legend {
grid-template-rows: 0.5fr 1fr 1fr 0.25fr;
}
}
.dual-chart-grid {
@@ -85,6 +87,7 @@
justify-self: center;
height: 300px;
max-width: 700px;
width: 100%;
svg.chart {
width: 100%;
@@ -128,7 +131,8 @@
.chart-empty-state {
place-self: center stretch;
grid-row-end: span 3;
grid-row-end: span 2;
grid-column-start: 1;
grid-column-end: span 3;
max-width: none;
padding-right: 20px;
@@ -165,32 +169,22 @@
}
.timestamp {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 5;
grid-column: 1 / span 2;
grid-row-start: -2;
color: $ui-gray-500;
font-size: $size-9;
align-self: end;
}
.legend-center {
.legend {
grid-row-start: 5;
grid-column-start: 3;
grid-column-end: 5;
grid-column-start: 2;
grid-column-end: 6;
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 {
@@ -228,20 +222,21 @@ p.data-details {
// MISC STYLES
.light-dot {
background-color: $blue-100;
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
}
.dark-dot {
background-color: $blue-500;
.legend-colors {
height: 10px;
width: 10px;
border-radius: 50%;
display: inline-block;
// numbers are indices because chart legend is iterated over
&.dot-0 {
background-color: var(--token-color-palette-blue-100);
}
&.dot-1 {
background-color: var(--token-color-palette-blue-300);
}
&.dot-2 {
background-color: var(--token-color-palette-blue-500);
}
}
.legend-label {
@@ -255,22 +250,12 @@ p.data-details {
font-size: $size-9;
padding: 6px;
border-radius: $radius-large;
width: 140px;
flex-wrap: nowrap;
width: fit-content;
.bold {
font-weight: $font-weight-bold;
}
.line-chart {
width: 117px;
}
.vertical-chart {
text-align: center;
flex-wrap: nowrap;
width: fit-content;
}
.horizontal-chart {
padding: $spacing-12;
}
}
.is-label-fit-content {
@@ -336,8 +321,8 @@ p.data-details {
margin-right: $spacing-48;
}
.legend-center {
grid-column-start: 1;
.legend {
grid-column-start: 2;
grid-row-start: 4;
}
@@ -346,3 +331,36 @@ p.data-details {
grid-row-start: 4;
}
}
// LINEAL STYLING //
.lineal-chart {
position: relative;
padding: 10px 10px 20px 50px;
width: 100%;
svg {
overflow: visible;
}
}
.lineal-chart-bar {
fill: var(--token-color-palette-blue-300);
}
.lineal-axis {
color: $ui-gray-500;
text {
font-size: 0.75rem;
}
line {
color: $ui-gray-300;
}
}
.lineal-tooltip-position {
position: absolute;
transform-style: preserve-3d;
bottom: 30px;
left: -20px;
pointer-events: none;
width: 140px;
transform: translate(calc(1px * var(--x, 0)), calc(-1px * var(--y, 0)));
transform-origin: bottom left;
z-index: 100;
}

View File

@@ -1,207 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
<p class="has-bottom-margin-xl">
This dashboard will surface Vault client usage over time. Clients represent a user or service that has authenticated to
Vault.
<Hds::Link::Inline @href={{doc-link "/vault/docs/concepts/client-count"}}>Documentation is available here</Hds::Link::Inline>.
Date queries are sent in UTC.
</p>
<h2 class="title is-6 has-bottom-margin-xs">
{{this.versionText.label}}
</h2>
<div data-test-start-date-editor class="is-flex-align-baseline">
{{#if this.formattedStartDate}}
<p class="is-size-6" data-test-date-display>{{this.formattedStartDate}}</p>
<Hds::Button
class="has-left-margin-xs"
@text="Edit"
@color="tertiary"
@icon="edit"
@iconPosition="trailing"
{{on "click" (fn (mut this.showBillingStartModal) true)}}
/>
{{else}}
<DateDropdown @handleSubmit={{this.handleClientActivityQuery}} @dateType="startDate" @submitText="Save" />
{{/if}}
</div>
<p class="is-8 has-text-grey has-bottom-margin-l">
{{this.versionText.description}}
</p>
{{#if this.noActivityData}}
<Clients::NoData @config={{@model.config}} @dateRangeMessage={{this.dateRangeMessage}} />
{{else if this.errorObject}}
<Clients::Error @error={{this.errorObject}} />
{{else}}
{{#if (eq @model.config.enabled "Off")}}
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
<A.Title>Tracking is disabled</A.Title>
<A.Description>
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need
to
<Hds::Link::Inline @route="vault.cluster.clients.edit">edit the configuration</Hds::Link::Inline>
to enable tracking again.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if (or this.totalUsageCounts this.hasAttributionData)}}
<div class="is-subtitle-gray has-bottom-margin-m">
FILTERS
<Toolbar data-test-clients-filter-bar>
<ToolbarFilters>
<CalendarWidget
@startTimestamp={{this.startMonthTimestamp}}
@endTimestamp={{this.endMonthTimestamp}}
@selectMonth={{this.handleClientActivityQuery}}
/>
{{#if this.namespaceArray}}
<SearchSelect
@id="namespace-search-select"
@options={{this.namespaceArray}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.selectNamespace}}
@placeholder={{"Filter by namespace"}}
@displayInherit={{true}}
class="is-marginless"
/>
{{/if}}
{{#if (not (is-empty this.authMethodOptions))}}
<SearchSelect
@id="auth-method-search-select"
@options={{this.authMethodOptions}}
@selectLimit="1"
@disallowNewItems={{true}}
@fallbackComponent="input-search"
@onChange={{this.setAuthMethod}}
@placeholder={{"Filter by auth method"}}
@displayInherit={{true}}
/>
{{/if}}
</ToolbarFilters>
</Toolbar>
</div>
{{#if (or this.upgradeDuringActivity this.startTimeDiscrepancy)}}
<Hds::Alert data-test-clients-upgrade-warning @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
<ul class={{if (and this.upgradeDuringActivity this.startTimeDiscrepancy) "bullet"}}>
{{#if this.startTimeDiscrepancy}}
<li>{{this.startTimeDiscrepancy}}</li>
{{/if}}
{{#if this.upgradeDuringActivity}}
<li>
{{this.upgradeVersionAndDate}}
{{this.upgradeExplanation}}
<DocLink
@path="/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
>
Learn more here.
</DocLink>
</li>
{{/if}}
</ul>
</A.Description>
</Hds::Alert>
{{/if}}
{{#if this.isLoadingQuery}}
<LayoutLoading />
{{else}}
{{#if this.totalUsageCounts}}
{{#unless this.byMonthActivityData}}
{{! UsageStats render when viewing a single, historical month AND activity data predates new client breakdown (< v1.10.0)
or viewing the current month filtered down to auth method }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within
{{if this.selectedAuthMethod this.selectedAuthMethod 'this namespace and all its children'}}.
{{if
this.isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
}}"
@totalUsageCounts={{this.totalUsageCounts}}
/>
{{/unless}}
{{#if this.byMonthActivityData}}
<Clients::RunningTotal
@chartLegend={{this.chartLegend}}
@selectedAuthMethod={{this.selectedAuthMethod}}
@byMonthActivityData={{this.byMonthActivityData}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@isCurrentMonth={{this.isCurrentMonth}}
@runningTotals={{this.totalUsageCounts}}
@upgradeData={{this.upgradeDuringActivity}}
@responseTimestamp={{this.responseTimestamp}}
/>
{{/if}}
{{#if this.hasAttributionData}}
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}
@selectedNamespace={{this.selectedNamespace}}
@startTimestamp={{this.startMonthTimestamp}}
@endTimestamp={{this.endMonthTimestamp}}
@responseTimestamp={{this.responseTimestamp}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@upgradeExplanation={{this.upgradeExplanation}}
/>
{{/if}}
{{#if this.hasMultipleMonthsData}}
<Clients::MonthlyUsage
@chartLegend={{this.chartLegend}}
@verticalBarChartData={{this.byMonthActivityData}}
@responseTimestamp={{this.responseTimestamp}}
/>
{{/if}}
{{/if}}
{{/if}}
{{else if (and (not @model.licenseStartTimestamp) (not this.startMonthTimestamp))}}
{{! Empty state for no billing/license start date }}
<EmptyState @title={{this.versionText.title}} @message={{this.versionText.message}} />
{{else}}
<EmptyState
@title="No data received {{if this.dateRangeMessage this.dateRangeMessage}}"
@message="Select a different start date above, or choose a different end date here:"
>
<DateDropdown
@handleSubmit={{this.handleClientActivityQuery}}
@validateDate={{this.isEndBeforeStart}}
@dateType="endDate"
@submitText="View"
/>
</EmptyState>
{{/if}}
{{/if}}
</div>
{{! BILLING START DATE MODAL }}
{{#if this.showBillingStartModal}}
<Hds::Modal id="clients-edit-date-modal" @onClose={{fn (mut this.showBillingStartModal) false}} as |M|>
<M.Header>
Edit start month
</M.Header>
<M.Body>
<p class="has-bottom-margin-s">
{{this.versionText.description}}
</p>
<p><strong>{{this.versionText.label}}</strong></p>
<DateDropdown
class="has-top-padding-s"
@handleSubmit={{this.handleClientActivityQuery}}
@dateType="startDate"
@submitText="Save"
/>
</M.Body>
<M.Footer as |F|>
<Hds::Button data-test-date-dropdown-cancel @text="Cancel" @color="secondary" {{on "click" F.close}} />
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -1,39 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if @dataset}}
<svg
data-test-line-chart
class="chart has-grid"
{{on "mouseleave" this.removeTooltip}}
{{did-insert this.renderChart @dataset}}
{{did-update this.renderChart @dataset}}
>
</svg>
{{else}}
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
{{/if}}
{{! 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"
}}
<div class="chart-tooltip line-chart">
<p class="bold">{{this.tooltipMonth}}</p>
<p>{{this.tooltipTotal}}</p>
<p>{{this.tooltipNew}}</p>
{{#if this.tooltipUpgradeText}}
<br />
<p class="has-text-highlight">{{this.tooltipUpgradeText}}</p>
{{/if}}
</div>
<div class="chart-tooltip-arrow"></div>
{{/modal-dialog}}
{{/if}}

View File

@@ -1,52 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="chart-wrapper single-chart-grid" data-test-monthly-usage>
<div class="chart-header has-bottom-margin-xl">
<h2 class="chart-title">Vault usage</h2>
<p class="chart-description">
This data can be used to understand how many total clients are using Vault each month for this date range.
</p>
</div>
<div class={{concat (unless @verticalBarChartData "chart-empty-state ") "chart-container-wide"}}>
<Clients::VerticalBarChart @dataset={{@verticalBarChartData}} @chartLegend={{@chartLegend}} />
</div>
<div class="chart-subTitle">
<h2 class="chart-title">Total monthly clients</h2>
<p class="chart-subtext">
Each client is counted once per month. This can help with capacity planning.
</p>
</div>
<div class="data-details-top" data-test-monthly-usage-average-total>
<h3 class="data-details">Average total clients per month</h3>
<p class="data-details">
{{format-number this.averageTotalClients}}
</p>
</div>
<div class="data-details-bottom" data-test-monthly-usage-average-new>
<h3 class="data-details">Average new clients per month</h3>
<p class="data-details">
{{format-number this.averageNewClients}}
</p>
</div>
<div data-test-monthly-usage-timestamp class="timestamp">
{{#if @responseTimestamp}}
Updated
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>
{{#if @verticalBarChartData}}
<div data-test-monthly-usage-legend class="legend-right">
<span class="light-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "0.label")}}</span>
<span class="dark-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "1.label")}}</span>
</div>
{{/if}}
</div>

View File

@@ -1,149 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if (gt @byMonthActivityData.length 1)}}
<div class="chart-wrapper stacked-charts" data-test-running-total="monthly-charts">
<div class="single-chart-grid">
<div class="chart-header has-bottom-margin-xl">
<h2 class="chart-title">Vault client counts</h2>
<p class="chart-description">
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity
clients. The total client count number is an important consideration for Vault billing.
</p>
</div>
<div class={{concat (unless @byMonthActivityData "chart-empty-state ") "chart-container-wide"}}>
<Clients::LineChart @dataset={{@byMonthActivityData}} @upgradeData={{@upgradeData}} />
</div>
<div class="chart-subTitle">
<h2 class="chart-title">Running client total</h2>
<p class="chart-subtext">The number of clients which interacted with Vault during this date range. </p>
</div>
<div class="data-details-top" data-test-running-total-entity>
<h3 class="data-details">Entity clients</h3>
<p class="data-details">
{{format-number this.entityClientData.runningTotal}}
</p>
</div>
<div class="data-details-bottom" data-test-running-total-nonentity>
<h3 class="data-details">Non-entity clients</h3>
<p class="data-details">
{{format-number this.nonEntityClientData.runningTotal}}
</p>
</div>
</div>
<div class="single-chart-grid">
<div class={{concat (unless this.hasAverageNewClients "chart-empty-state ") "chart-container-wide"}}>
<Clients::VerticalBarChart
@dataset={{if this.hasAverageNewClients this.byMonthNewClients false}}
@chartLegend={{@chartLegend}}
@noDataTitle="No new clients"
@noDataMessage={{concat
"There is no new client data available for this "
(if @selectedAuthMethod "auth method" "namespace")
" in this date range"
}}
/>
</div>
<div class="chart-subTitle">
<h2 class="chart-title">New monthly clients</h2>
<p class="chart-subtext">
Clients which interacted with Vault for the first time during this date range, displayed per month.
</p>
</div>
{{#if this.hasAverageNewClients}}
<div class="data-details-top" data-test-running-new-entity>
<h3 class="data-details">Average new entity clients per month</h3>
<p class="data-details">
{{format-number this.entityClientData.averageNewClients}}
</p>
</div>
<div class="data-details-bottom" data-test-running-new-nonentity>
<h3 class="data-details">Average new non-entity clients per month</h3>
<p class="data-details">
{{format-number this.nonEntityClientData.averageNewClients}}
</p>
</div>
{{/if}}
<div class="timestamp" data-test-running-total-timestamp>
{{#if @responseTimestamp}}
Updated
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>
{{#if this.hasAverageNewClients}}
<div class="legend-right" data-test-running-total-legend>
<span class="light-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "0.label")}}</span>
<span class="dark-dot"></span><span class="legend-label">{{capitalize (get @chartLegend "1.label")}}</span>
</div>
{{/if}}
</div>
</div>
{{else}}
{{#if (and @isHistoricalMonth this.singleMonthData.new_clients.clients)}}
<div class="chart-wrapper single-month-grid" data-test-running-total="single-month-stats">
<div class="chart-header has-bottom-margin-sm">
<h2 class="chart-title">Vault client counts</h2>
<p class="chart-description">
A client is any user or service that interacts with Vault. They are made up of entity clients and non-entity
clients. The total client count number is an important consideration for Vault billing.
</p>
</div>
<div class="single-month-stats" data-test-new>
<div class="single-month-section-title">
<StatText
@label="New clients"
@subText="This is the number of clients which were created in Vault for the first time in the selected month."
@value={{this.singleMonthData.new_clients.clients}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{this.singleMonthData.new_clients.entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{this.singleMonthData.new_clients.non_entity_clients}} @size="m" />
</div>
</div>
<div class="single-month-stats" data-test-total>
<div class="single-month-section-title">
<StatText
@label="Total monthly clients"
@subText="This is the number of total clients which used Vault for the given month, both new and previous."
@value={{this.singleMonthData.clients}}
@size="l"
/>
</div>
<div class="single-month-breakdown-entity">
<StatText @label="Entity clients" @value={{this.singleMonthData.entity_clients}} @size="m" />
</div>
<div class="single-month-breakdown-nonentity">
<StatText @label="Non-entity clients" @value={{this.singleMonthData.non_entity_clients}} @size="m" />
</div>
</div>
</div>
{{else}}
{{! This renders when either:
-> viewing the current month and all namespaces (no filters)
-> filtering by a namespace with no month over month data
if filtering by a mount with no month over month data <UsageStats> in dashboard.hbs renders }}
<Clients::UsageStats
@title="Total usage"
@description="These totals are within this namespace and all its children. {{if
@isCurrentMonth
"Only totals are available when viewing the current month. To see a breakdown of new and total clients for this month, select the 'Current Billing Period' filter."
}}"
@totalUsageCounts={{@runningTotals}}
/>
{{/if}}
{{/if}}

View File

@@ -9,7 +9,7 @@
Client count
</h3>
<LinkTo @route="vault.cluster.clients.dashboard">Details</LinkTo>
<LinkTo @route="vault.cluster.clients">Details</LinkTo>
</div>
<hr class="has-background-gray-100" />

View File

@@ -14,13 +14,20 @@
<div class="tabs-container box is-bottomless is-marginless is-fullwidth is-paddingless">
<nav class="tabs" aria-label="navigation for managing client counts">
<ul>
<LinkTo @route="vault.cluster.clients.dashboard" data-test-dashboard>
Dashboard
<LinkTo @route="vault.cluster.clients.counts.overview" data-test-tab="overview">
Overview
</LinkTo>
<LinkTo @route="vault.cluster.clients.counts.token" data-test-tab="token">
Entity/Non-entity clients
</LinkTo>
<LinkTo @route="vault.cluster.clients.counts.sync" data-test-tab="sync">
Secrets sync clients
</LinkTo>
{{#if (or @model.config.canRead @model.canRead)}}
<LinkTo
@route="vault.cluster.clients.config"
@current-when="vault.cluster.clients.config vault.cluster.clients.edit"
data-test-tab="config"
>
Configuration
</LinkTo>

View File

@@ -0,0 +1,17 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Clients::Page::Counts
@activity={{this.model.activity}}
@activityError={{this.model.activityError}}
@config={{this.model.config}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.ns}}
@mountPath={{this.mountPath}}
@onFilterChange={{this.updateQueryParams}}
>
{{outlet}}
</Clients::Page::Counts>

View File

@@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Clients::Page::Overview
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>

View File

@@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Clients::Page::Sync
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>

View File

@@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Clients::Page::Token
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>

View File

@@ -1,6 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Clients::Dashboard @model={{@model}} />

View File

@@ -7,31 +7,26 @@ import { format } from 'd3-format';
import { mean } from 'd3-array';
// COLOR THEME:
export const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#1563FF'];
export const BLUE_PALETTE = ['#cce3fe', '#0c56e9', '#1c345f']; // blues from https://helios.hashicorp.design/foundations/colors?tab=palette#core-palette
export const UPGRADE_WARNING = '#FDEEBA';
export const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1'];
export const GREY = '#EBEEF2';
// TRANSLATIONS:
export const TRANSLATE = { left: -11 };
export const SVG_DIMENSIONS = { height: 190, width: 500 };
export const BAR_WIDTH = 7; // data bar width is 7 pixels
// Reference for tickFormat https://www.youtube.com/watch?v=c3MCROTNN8g
export function formatNumbers(number) {
if (number < 1000) return number;
if (number < 1100) return format('.1s')(number);
if (number < 2000) return format('.2s')(number); // between 1k and 2k, show 2 decimals
if (number < 10000) return format('.1s')(number);
// replace SI prefix of 'G' for billions to 'B'
return format('.2s')(number).replace('G', 'B');
}
export function formatTooltipNumber(value) {
if (typeof value !== 'number') {
return value;
}
// formats a number according to the locale
return new Intl.NumberFormat().format(value);
}
export function calculateAverage(dataset, objectKey) {
// before mapping for values, check that the objectKey exists at least once in the dataset because
// map returns 0 when dataset[objectKey] is undefined in order to calculate average
@@ -43,3 +38,10 @@ export function calculateAverage(dataset, objectKey) {
const checkIntegers = integers.every((n) => Number.isInteger(n)); // decimals will be false
return checkIntegers ? Math.round(mean(integers)) : null;
}
export function calculateSum(integerArray) {
if (!Array.isArray(integerArray) || integerArray.some((n) => typeof n !== 'number')) {
return null;
}
return integerArray.reduce((a, b) => a + b, 0);
}

View File

@@ -4,7 +4,17 @@
*/
import { parseAPITimestamp } from 'core/utils/date-formatters';
import { compareAsc } from 'date-fns';
import { compareAsc, getUnixTime } from 'date-fns';
export const formatDateObject = (dateObj, isEnd) => {
if (dateObj) {
const { year, monthIdx } = dateObj;
// day=0 for Date.UTC() returns the last day of the month before
// increase monthIdx by one to get last day of queried month
const utc = isEnd ? Date.UTC(year, monthIdx + 1, 0) : Date.UTC(year, monthIdx, 1);
return getUnixTime(utc);
}
};
export const formatByMonths = (monthsArray) => {
// the monthsArray will always include a timestamp of the month and either new/total client data or counts = null
@@ -23,7 +33,12 @@ export const formatByMonths = (monthsArray) => {
timestamp: m.timestamp,
...totalCounts,
namespaces: formatByNamespace(m.namespaces) || [],
namespaces_by_key: namespaceArrayToObject(totalClientsByNamespace, newClientsByNamespace, month),
namespaces_by_key: namespaceArrayToObject(
totalClientsByNamespace,
newClientsByNamespace,
month,
m.timestamp
),
new_clients: {
month,
timestamp: m.timestamp,
@@ -64,11 +79,12 @@ export const formatByNamespace = (namespaceArray) => {
export const homogenizeClientNaming = (object) => {
// if new key names exist, only return those key/value pairs
if (Object.keys(object).includes('entity_clients')) {
const { clients, entity_clients, non_entity_clients } = object;
const { clients, entity_clients, non_entity_clients, secret_syncs } = object;
return {
clients,
entity_clients,
non_entity_clients,
secret_syncs,
};
}
// if object only has outdated key names, update naming
@@ -99,7 +115,7 @@ export const sortMonthsByTimestamp = (monthsArray) => {
);
};
export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByNamespace, month) => {
export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByNamespace, month, timestamp) => {
if (!totalClientsByNamespace) return {}; // return if no data for that month
// all 'new_client' data resides within a separate key of each month (see data structure below)
// FIRST: iterate and nest respective 'new_clients' data within each namespace and mount object
@@ -107,7 +123,7 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
const nestNewClientsWithinNamespace = totalClientsByNamespace?.map((ns) => {
const newNamespaceCounts = newClientsByNamespace?.find((n) => n.label === ns.label);
if (newNamespaceCounts) {
const { label, clients, entity_clients, non_entity_clients } = newNamespaceCounts;
const { label, clients, entity_clients, non_entity_clients, secret_syncs } = newNamespaceCounts;
const newClientsByMount = [...newNamespaceCounts.mounts];
const nestNewClientsWithinMounts = ns.mounts?.map((mount) => {
const new_clients = newClientsByMount?.find((m) => m.label === mount.label) || {};
@@ -123,6 +139,7 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
clients,
entity_clients,
non_entity_clients,
secret_syncs,
mounts: newClientsByMount,
},
mounts: [...nestNewClientsWithinMounts],
@@ -141,17 +158,20 @@ export const namespaceArrayToObject = (totalClientsByNamespace, newClientsByName
namespaceObject.mounts.forEach((mountObject) => {
mounts_by_key[mountObject.label] = {
month,
timestamp,
...mountObject,
new_clients: { month, ...mountObject.new_clients },
};
});
const { label, clients, entity_clients, non_entity_clients, new_clients } = namespaceObject;
const { label, clients, entity_clients, non_entity_clients, secret_syncs, new_clients } = namespaceObject;
namespaces_by_key[label] = {
month,
timestamp,
clients,
entity_clients,
non_entity_clients,
secret_syncs,
new_clients: { month, ...new_clients },
mounts_by_key,
};

View File

@@ -140,7 +140,7 @@
@cardTitle="Total sync associations"
@subText="Total sync associations that count towards client count"
@actionText="View billing"
@actionTo="clientCountDashboard"
@actionTo="clientCountOverview"
@actionExternal={{true}}
class="is-flex-half"
>

View File

@@ -15,7 +15,7 @@ export default class SyncEngine extends Engine {
Resolver = Resolver;
dependencies = {
services: ['flash-messages', 'router', 'store', 'version'],
externalRoutes: ['kvSecretDetails', 'clientCountDashboard'],
externalRoutes: ['kvSecretDetails', 'clientCountOverview'],
};
}

View File

@@ -34,6 +34,7 @@ function getTotalCounts(array) {
entity_clients: getSum(array, 'entity_clients'),
non_entity_tokens: getSum(array, 'non_entity_clients'),
non_entity_clients: getSum(array, 'non_entity_clients'),
secret_syncs: getSum(array, 'secret_syncs'),
clients: getSum(array, 'clients'),
};
}
@@ -60,8 +61,27 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns) {
namespace_id: ns?.namespace_id || (idx === 0 ? 'root' : Math.random().toString(36).slice(2, 7) + idx),
namespace_path: ns?.namespace_path || (idx === 0 ? '' : `ns/${idx}`),
counts: {},
mounts: {},
};
const mounts = [];
Array.from(Array(5)).forEach((mount, index) => {
const [secretSyncs] = arrayOfCounts(randomBetween(min, max), 1);
mounts.push({
mount_path: `kvv2-engine-${index}`,
counts: {
clients: secretSyncs,
// TODO test with live backend to confirm entity keys are present (and 0) for kv mounts
entity_clients: 0,
non_entity_clients: 0,
distinct_entities: 0,
non_entity_tokens: 0,
secret_syncs: secretSyncs,
},
});
});
// generate auth mounts array
Array.from(Array(10)).forEach((mount, index) => {
const mountClients = randomBetween(min, max);
const [nonEntity, entity] = arrayOfCounts(mountClients, 2);
@@ -73,6 +93,8 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns) {
non_entity_clients: nonEntity,
distinct_entities: entity,
non_entity_tokens: nonEntity,
// TODO test with live backend to confirm this key is present (and 0) for auth mounts (non-kv mounts)
secret_syncs: 0,
},
});
});
@@ -104,7 +126,7 @@ function generateMonths(startDate, endDate, namespaces) {
continue;
}
const monthNs = namespaces.map((ns, idx) => generateNamespaceBlock(idx, true, ns));
const monthNs = namespaces.map((ns, idx) => generateNamespaceBlock(idx, false, ns));
const newClients = namespaces.map((ns, idx) => generateNamespaceBlock(idx, true, ns));
months.push({
timestamp: formatRFC3339(month),
@@ -151,6 +173,7 @@ export default function (server) {
enabled: 'default-enable',
queries_available: true,
retention_months: 24,
billing_start_timestamp: formatRFC3339(LICENSE_START),
},
};
});

View File

@@ -6,6 +6,7 @@
import { Response } from 'miragejs';
import { camelize } from '@ember/string';
import { findDestination } from 'core/helpers/sync-destinations';
import clientsHandler from './clients';
export const associationsResponse = (schema, req) => {
const { type, name } = req.params;
@@ -201,4 +202,296 @@ export default function (server) {
server.get('sys/sync/associations/:mount/*name', (schema, req) => {
return syncStatusResponse(schema, req);
});
// SYNC CLIENTS ACTIVITY RESPONSE
// DYNAMIC RESPONSE (with date querying)
clientsHandler(server); // imports all of the endpoints defined in mirage/handlers/clients file
// STATIC RESPONSE (0 entity/non-entity clients)
/*
server.get('/sys/internal/counters/activity', (schema, req) => {
let { start_time, end_time } = req.queryParams;
// backend returns a timestamp if given unix time, so first convert to timestamp string here
if (!start_time.includes('T')) start_time = fromUnixTime(start_time).toISOString();
if (!end_time.includes('T')) end_time = fromUnixTime(end_time).toISOString();
return {
request_id: 'some-activity-id',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
start_time, // set by query params
end_time, // set by query params
total: {
clients: 15,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 15,
},
by_namespace: [
{
counts: {
clients: 15,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 15,
},
mounts: [
{
counts: {
clients: 15,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 15,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
months: [
{ counts: null, namespaces: null, new_clients: null, timestamp: '2023-09-01T00:00:00Z' },
{
counts: {
clients: 10,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 10,
},
namespaces: [
{
counts: {
clients: 10,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 10,
},
mounts: [
{
counts: {
clients: 10,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 10,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
new_clients: {
counts: {
clients: 10,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 10,
},
namespaces: [
{
counts: {
clients: 10,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 10,
},
mounts: [
{
counts: {
clients: 10,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 10,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
},
timestamp: '2023-10-01T00:00:00Z',
},
{
counts: {
clients: 7,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 7,
},
namespaces: [
{
counts: {
clients: 7,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 7,
},
mounts: [
{
counts: {
clients: 7,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 7,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
new_clients: {
counts: {
clients: 3,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 3,
},
namespaces: [
{
counts: {
clients: 3,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 3,
},
mounts: [
{
counts: {
clients: 3,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 3,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
},
timestamp: '2023-11-01T00:00:00Z',
},
{
counts: {
clients: 7,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 7,
},
namespaces: [
{
counts: {
clients: 7,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 7,
},
mounts: [
{
counts: {
clients: 7,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 7,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
new_clients: {
counts: {
clients: 2,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 2,
},
namespaces: [
{
counts: {
clients: 2,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 2,
},
mounts: [
{
counts: {
clients: 2,
distinct_entities: 0,
entity_clients: 0,
non_entity_clients: 0,
non_entity_tokens: 0,
secret_syncs: 2,
},
mount_path: 'sys/',
},
],
namespace_id: 'root',
namespace_path: '',
},
],
},
timestamp: '2023-12-01T00:00:00Z',
},
],
},
wrap_info: null,
warnings: null,
auth: null,
};
});
*/
}

View File

@@ -68,6 +68,7 @@
"@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2",
"@icholy/duration": "^5.1.0",
"@lineal-viz/lineal": "^0.5.1",
"@tsconfig/ember": "^1.0.1",
"@types/ember": "^4.0.2",
"@types/ember-data": "^4.4.6",
@@ -162,6 +163,7 @@
"ember-service-worker": "meirish/ember-service-worker#configurable-scope",
"ember-sinon": "^4.0.0",
"ember-source": "~4.12.0",
"ember-style-modifier": "^4.1.0",
"ember-svg-jar": "2.4.0",
"ember-template-lint": "5.7.2",
"ember-template-lint-plugin-prettier": "4.0.0",

View File

@@ -1,400 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import sinon from 'sinon';
import { visit, currentURL, click, findAll, find, settled } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import authPage from 'vault/tests/pages/auth';
import { addMonths, formatRFC3339, startOfMonth, subMonths } from 'date-fns';
import { setupMirage } from 'ember-cli-mirage/test-support';
import clientsHandlers from 'vault/mirage/handlers/clients';
import { SELECTORS, overrideResponse } from '../helpers/clients';
import { create } from 'ember-cli-page-object';
import ss from 'vault/tests/pages/components/search-select';
import { clickTrigger } from 'ember-power-select/test-support/helpers';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
import { formatNumber } from 'core/helpers/format-number';
import timestamp from 'core/utils/timestamp';
const searchSelect = create(ss);
const STATIC_NOW = new Date('2023-01-13T14:15:00');
// for testing, we're in the middle of a license/billing period
const LICENSE_START = startOfMonth(subMonths(STATIC_NOW, 6)); // 2022-07-01
// upgrade happened 1 month after license start
const UPGRADE_DATE = addMonths(LICENSE_START, 1); // 2022-08-01
module('Acceptance | client counts dashboard tab', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
});
hooks.beforeEach(function () {
clientsHandlers(this.server);
this.store = this.owner.lookup('service:store');
});
hooks.after(function () {
timestamp.now.restore();
});
hooks.beforeEach(function () {
return authPage.login();
});
test('shows warning when config off, no data', async function (assert) {
assert.expect(4);
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
this.server.get('sys/internal/counters/config', () => {
return {
request_id: 'some-config-id',
data: {
default_report_months: 12,
enabled: 'default-disable',
queries_available: false,
retention_months: 24,
},
};
});
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasText('Data tracking is disabled');
assert.dom(SELECTORS.filterBar).doesNotExist('Filter bar is hidden when no data available');
});
test('shows empty state when config enabled and no data', async function (assert) {
assert.expect(4);
this.server.get('sys/internal/counters/activity', () => overrideResponse(204));
this.server.get('sys/internal/counters/config', () => {
return {
request_id: 'some-config-id',
data: {
default_report_months: 12,
enabled: 'default-enable',
retention_months: 24,
},
};
});
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert.dom(SELECTORS.emptyStateTitle).hasTextContaining('No data received');
assert.dom(SELECTORS.filterBar).doesNotExist('Does not show filter bar');
});
test('visiting dashboard tab config on and data with mounts', async function (assert) {
assert.expect(8);
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
assert
.dom(SELECTORS.dateDisplay)
.hasText('July 2022', 'billing start month is correctly parsed from license');
assert
.dom(SELECTORS.rangeDropdown)
.hasText(`Jul 2022 - Jan 2023`, 'Date range shows dates correctly parsed activity response');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(find('[data-test-line-chart="x-axis-labels"] g.tick text'))
.hasText(`7/22`, 'x-axis labels start with billing start date');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
6,
`line chart plots 6 points to match query`
);
});
test('updates correctly when querying date ranges', async function (assert) {
assert.expect(27);
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard');
// query for single, historical month with no new counts
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
if (parseInt(find('[data-test-display-year]').innerText) > LICENSE_START.getFullYear()) {
await click('[data-test-previous-year]');
}
await click(find(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`));
assert.dom('[data-test-usage-stats]').exists('total usage stats show');
assert
.dom(SELECTORS.runningTotalMonthStats)
.doesNotExist('running total single month stat boxes do not show');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.doesNotExist('running total month over month charts do not show');
assert.dom(SELECTORS.monthlyUsageBlock).doesNotExist('does not show monthly usage block');
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
assert
.dom('[data-test-chart-container="new-clients"] [data-test-component="empty-state"]')
.exists('new client attribution has empty state');
assert
.dom('[data-test-empty-state-subtext]')
.hasText('There are no new clients for this namespace during this time period. ');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
// reset to billing period
await click(SELECTORS.rangeDropdown);
await click('[data-test-current-billing-period]');
// change billing start to month/year of first upgrade
await click('[data-test-start-date-editor] button');
await click(SELECTORS.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(`[data-test-dropdown-year="${UPGRADE_DATE.getFullYear()}"]`);
await click('[data-test-date-dropdown-submit]');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(find('[data-test-line-chart="x-axis-labels"] g.tick text'))
.hasText(`8/22`, 'x-axis labels start with updated billing start month');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
6,
`line chart plots 6 points to match query`
);
// query three months ago (Oct 2022)
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
await click('[data-test-previous-year]');
await click(find(`[data-test-calendar-month="October"]`));
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.exists('Shows running totals with monthly breakdown charts');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
3,
`line chart plots 3 points to match query`
);
const xAxisLabels = findAll('[data-test-line-chart="x-axis-labels"] g.tick text');
assert
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText(`10/22`, 'x-axis labels end with queried end month');
// query for single, historical month (upgrade month)
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
assert.dom('[data-test-display-year]').hasText('2022');
await click(find(`[data-test-calendar-month="August"]`));
assert.dom(SELECTORS.runningTotalMonthStats).exists('running total single month stat boxes show');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.doesNotExist('running total month over month charts do not show');
assert.dom(SELECTORS.monthlyUsageBlock).doesNotExist('Does not show monthly usage block');
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
assert.dom('[data-test-chart-container="new-clients"]').exists('new client attribution chart shows');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
// reset to billing period
await click(SELECTORS.rangeDropdown);
await click('[data-test-current-billing-period]');
// query month older than count start date
await click('[data-test-start-date-editor] button');
await click(SELECTORS.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
await click('[data-test-date-dropdown-submit]');
assert
.dom(SELECTORS.upgradeWarning)
.hasTextContaining(
`We only have data from January 2022`,
'warning banner displays that date queried was prior to count start date'
);
});
test('dashboard filters correctly with full data', async function (assert) {
assert.expect(21);
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.exists('Shows running totals with monthly breakdown charts');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
// FILTER BY NAMESPACE
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
const topNamespace = response.byNamespace[0];
const topMount = topNamespace.mounts[0];
assert.ok(true, 'Filter by first namespace');
assert.strictEqual(
find(SELECTORS.selectedNs).innerText.toLowerCase(),
topNamespace.label,
'selects top namespace'
);
assert.dom('[data-test-top-attribution]').includesText('Top auth method');
assert
.dom('[data-test-running-total-entity] p')
.includesText(`${formatNumber([topNamespace.entity_clients])}`, 'total entity clients is accurate');
assert
.dom('[data-test-running-total-nonentity] p')
.includesText(
`${formatNumber([topNamespace.non_entity_clients])}`,
'total non-entity clients is accurate'
);
assert
.dom('[data-test-attribution-clients] p')
.includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate');
// FILTER BY AUTH METHOD
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
assert.ok(true, 'Filter by first auth method');
assert.strictEqual(
find(SELECTORS.selectedAuthMount).innerText.toLowerCase(),
topMount.label,
'selects top mount'
);
assert
.dom('[data-test-running-total-entity] p')
.includesText(`${formatNumber([topMount.entity_clients])}`, 'total entity clients is accurate');
assert
.dom('[data-test-running-total-nonentity] p')
.includesText(`${formatNumber([topMount.non_entity_clients])}`, 'total non-entity clients is accurate');
assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution block');
await click('#namespace-search-select [data-test-selected-list-button="delete"]');
assert.ok(true, 'Remove namespace filter without first removing auth method filter');
assert.dom('[data-test-top-attribution]').includesText('Top namespace');
assert
.dom('[data-test-running-total-entity]')
.hasTextContaining(
`${formatNumber([response.total.entity_clients])}`,
'total entity clients is back to unfiltered value'
);
assert
.dom('[data-test-running-total-nonentity]')
.hasTextContaining(
`${formatNumber([formatNumber([response.total.non_entity_clients])])}`,
'total non-entity clients is back to unfiltered value'
);
assert
.dom('[data-test-attribution-clients]')
.hasTextContaining(
`${formatNumber([topNamespace.clients])}`,
'top attribution clients back to unfiltered value'
);
});
test('shows warning if upgrade happened within license period', async function (assert) {
assert.expect(4);
this.server.get('sys/version-history', function () {
return {
data: {
keys: ['1.9.0', '1.9.1', '1.9.2', '1.10.1'],
key_info: {
'1.9.0': {
previous_version: null,
timestamp_installed: formatRFC3339(subMonths(UPGRADE_DATE, 4)),
},
'1.9.1': {
previous_version: '1.9.0',
timestamp_installed: formatRFC3339(subMonths(UPGRADE_DATE, 3)),
},
'1.9.2': {
previous_version: '1.9.1',
timestamp_installed: formatRFC3339(subMonths(UPGRADE_DATE, 2)),
},
'1.10.1': {
previous_version: '1.9.2',
timestamp_installed: formatRFC3339(UPGRADE_DATE),
},
},
},
};
});
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
assert
.dom(SELECTORS.upgradeWarning)
.hasTextContaining(
`Warning Vault was upgraded to 1.10.1 on Aug 1, 2022. We added monthly breakdowns and mount level attribution starting in 1.10, so keep that in mind when looking at the data. Learn more here.`
);
await click('[data-test-start-date-editor] button');
await click(SELECTORS.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
await click('[data-test-date-dropdown-submit]');
assert.dom(`${SELECTORS.upgradeWarning} ul`).hasClass('bullet', 'renders bullets when multiple warnings');
});
test('Shows empty if license start date is current month', async function (assert) {
// TODO cmb update to reflect new behavior
const licenseStart = STATIC_NOW;
const licenseEnd = addMonths(licenseStart, 12);
this.server.get('sys/license/status', function () {
return {
request_id: 'my-license-request-id',
data: {
autoloaded: {
license_id: 'my-license-id',
start_time: formatRFC3339(licenseStart),
expiration_time: formatRFC3339(licenseEnd),
},
},
};
});
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.emptyStateTitle).doesNotExist('No data for this billing period');
});
test('shows correct interface if no permissions on license', async function (assert) {
this.server.get('/sys/license/status', () => overrideResponse(403));
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert.dom(SELECTORS.dashboardActiveTab).hasText('Dashboard', 'dashboard tab is active');
// Message changes depending on ent or OSS
assert.dom(SELECTORS.emptyStateTitle).exists('Empty state exists');
assert.dom(SELECTORS.monthDropdown).exists('Dropdown exists to select month');
assert.dom(SELECTORS.yearDropdown).exists('Dropdown exists to select year');
});
test('shows error template if permissions denied querying activity response with no data', async function (assert) {
this.server.get('sys/license/status', () => overrideResponse(403));
this.server.get('sys/version-history', () => overrideResponse(403));
this.server.get('sys/internal/counters/config', () => overrideResponse(403));
this.server.get('sys/internal/counters/activity', () => overrideResponse(403));
await visit('/vault/clients/dashboard');
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'clients/dashboard URL is correct');
assert
.dom(SELECTORS.emptyStateTitle)
.includesText('start date found', 'Empty state shows no billing start date');
await click(SELECTORS.monthDropdown);
await click(this.element.querySelector('[data-test-dropdown-month]:not([disabled])'));
await click(SELECTORS.yearDropdown);
await click(this.element.querySelector('[data-test-dropdown-year]:not([disabled])'));
await click(SELECTORS.dateDropdownSubmit);
assert
.dom(SELECTORS.emptyStateTitle)
.hasText('You are not authorized', 'Empty state displays not authorized message');
});
});

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import clientsHandler from 'vault/mirage/handlers/clients';
import sinon from 'sinon';
import { visit, click, currentURL } from '@ember/test-helpers';
import authPage from 'vault/tests/pages/auth';
import { SELECTORS as ts, STATIC_NOW } from 'vault/tests/helpers/clients';
import timestamp from 'core/utils/timestamp';
module('Acceptance | clients | counts', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
});
hooks.beforeEach(async function () {
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
return authPage.login();
});
hooks.after(function () {
timestamp.now.restore();
});
test('it should redirect to counts overview route for transitions to parent', async function (assert) {
await visit('/vault/clients');
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Redirects to counts overview route');
});
test('it should persist filter query params between child routes', async function (assert) {
await visit('/vault/clients/counts/overview');
await click(ts.rangeDropdown);
await click(ts.currentBillingPeriod);
const timeQueryRegex = /end_time=\d+&start_time=\d+/g;
assert.ok(currentURL().match(timeQueryRegex).length, 'Start and end times added as query params');
await click(ts.tab('token'));
assert.ok(
currentURL().match(timeQueryRegex).length,
'Start and end times persist through child route change'
);
await click(ts.navLink('Dashboard'));
await click(ts.navLink('Client Count'));
assert.strictEqual(
currentURL(),
'/vault/clients/counts/overview',
'Query params are reset when exiting route'
);
});
});

View File

@@ -0,0 +1,240 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import clientsHandler from 'vault/mirage/handlers/clients';
import sinon from 'sinon';
import { visit, click, findAll, find, settled } from '@ember/test-helpers';
import authPage from 'vault/tests/pages/auth';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
import { SELECTORS, STATIC_NOW, LICENSE_START, UPGRADE_DATE } from 'vault/tests/helpers/clients';
import { create } from 'ember-cli-page-object';
import { clickTrigger } from 'ember-power-select/test-support/helpers';
import { formatNumber } from 'core/helpers/format-number';
import timestamp from 'core/utils/timestamp';
import ss from 'vault/tests/pages/components/search-select';
const searchSelect = create(ss);
module('Acceptance | clients | overview', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
});
hooks.beforeEach(async function () {
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
await authPage.login();
return visit('/vault/clients/counts/overview');
});
hooks.after(function () {
timestamp.now.restore();
});
test('it should render charts', async function (assert) {
assert
.dom(SELECTORS.counts.startMonth)
.hasText('July 2022', 'billing start month is correctly parsed from license');
assert
.dom(SELECTORS.rangeDropdown)
.hasText(`Jul 2022 - Jan 2023`, 'Date range shows dates correctly parsed activity response');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert
.dom(SELECTORS.charts.chart('running total'))
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(SELECTORS.charts.line.xAxisLabel)
.hasText(`7/22`, 'x-axis labels start with billing start date');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
6,
`line chart plots 6 points to match query`
);
});
test('it should update charts when querying date ranges', async function (assert) {
// query for single, historical month with no new counts
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
if (parseInt(find('[data-test-display-year]').innerText) > LICENSE_START.getFullYear()) {
await click('[data-test-previous-year]');
}
await click(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`);
assert
.dom(SELECTORS.runningTotalMonthStats)
.doesNotExist('running total single month stat boxes do not show');
assert
.dom(SELECTORS.charts.chart('running total'))
.doesNotExist('running total month over month charts do not show');
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
assert
.dom('[data-test-chart-container="new-clients"] [data-test-component="empty-state"]')
.exists('new client attribution has empty state');
assert
.dom('[data-test-empty-state-subtext]')
.hasText('There are no new clients for this namespace during this time period. ');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
// reset to billing period
await click(SELECTORS.rangeDropdown);
await click('[data-test-current-billing-period]');
// change billing start to month/year of first upgrade
await click(SELECTORS.counts.startEdit);
await click(SELECTORS.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(`[data-test-dropdown-year="${UPGRADE_DATE.getFullYear()}"]`);
await click('[data-test-date-dropdown-submit]');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert
.dom(SELECTORS.charts.chart('running total'))
.exists('Shows running totals with monthly breakdown charts');
assert
.dom(SELECTORS.charts.line.xAxisLabel)
.hasText(`8/22`, 'x-axis labels start with updated billing start month');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
6,
`line chart plots 6 points to match query`
);
// query three months ago (Oct 2022)
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
await click('[data-test-previous-year]');
await click('[data-test-calendar-month="October"]');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert
.dom(SELECTORS.charts.chart('running total'))
.exists('Shows running totals with monthly breakdown charts');
assert.strictEqual(
findAll('[data-test-line-chart="plot-point"]').length,
3,
`line chart plots 3 points to match query`
);
const xAxisLabels = findAll(SELECTORS.charts.line.xAxisLabel);
assert
.dom(xAxisLabels[xAxisLabels.length - 1])
.hasText(`10/22`, 'x-axis labels end with queried end month');
// query for single, historical month (upgrade month)
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
assert.dom('[data-test-display-year]').hasText('2022');
await click('[data-test-calendar-month="August"]');
assert.dom(SELECTORS.runningTotalMonthStats).exists('running total single month stat boxes show');
assert
.dom(SELECTORS.charts.chart('running total'))
.doesNotExist('running total month over month charts do not show');
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
assert.dom('[data-test-chart-container="new-clients"]').exists('new client attribution chart shows');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
// reset to billing period
await click(SELECTORS.rangeDropdown);
await click('[data-test-current-billing-period]');
// query month older than count start date
await click(SELECTORS.counts.startEdit);
await click(SELECTORS.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
await click(SELECTORS.yearDropdown);
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
await click('[data-test-date-dropdown-submit]');
assert
.dom(SELECTORS.counts.startDiscrepancy)
.hasTextContaining(
`We only have data from January 2022`,
'warning banner displays that date queried was prior to count start date'
);
});
test('totals filter correctly with full data', async function (assert) {
assert
.dom(SELECTORS.charts.chart('running total'))
.exists('Shows running totals with monthly breakdown charts');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
const response = await this.store.peekRecord('clients/activity', 'some-activity-id');
// FILTER BY NAMESPACE
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
const topNamespace = response.byNamespace[0];
const topMount = topNamespace.mounts[0];
assert.dom(SELECTORS.selectedNs).hasText(topNamespace.label, 'selects top namespace');
assert.dom('[data-test-top-attribution]').includesText('Top auth method');
assert
.dom(SELECTORS.charts.statTextValue('Entity clients'))
.includesText(`${formatNumber([topNamespace.entity_clients])}`, 'total entity clients is accurate');
assert
.dom(SELECTORS.charts.statTextValue('Non-entity clients'))
.includesText(
`${formatNumber([topNamespace.non_entity_clients])}`,
'total non-entity clients is accurate'
);
assert
.dom(SELECTORS.charts.statTextValue('Secrets sync clients'))
.includesText(`${formatNumber([topNamespace.secret_syncs])}`, 'total sync clients is accurate');
assert
.dom('[data-test-attribution-clients] p')
.includesText(`${formatNumber([topMount.clients])}`, 'top attribution clients accurate');
// FILTER BY AUTH METHOD
await clickTrigger();
await searchSelect.options.objectAt(0).click();
await settled();
assert.ok(true, 'Filter by first auth method');
assert.dom(SELECTORS.selectedAuthMount).hasText(topMount.label, 'selects top mount');
assert
.dom(SELECTORS.charts.statTextValue('Entity clients'))
.includesText(`${formatNumber([topMount.entity_clients])}`, 'total entity clients is accurate');
assert
.dom(SELECTORS.charts.statTextValue('Non-entity clients'))
.includesText(`${formatNumber([topMount.non_entity_clients])}`, 'total non-entity clients is accurate');
assert
.dom(SELECTORS.charts.statTextValue('Secrets sync clients'))
.includesText(`${formatNumber([topMount.secret_syncs])}`, 'total sync clients is accurate');
assert.dom(SELECTORS.attributionBlock).doesNotExist('Does not show attribution block');
await click('#namespace-search-select [data-test-selected-list-button="delete"]');
assert.ok(true, 'Remove namespace filter without first removing auth method filter');
assert.dom('[data-test-top-attribution]').includesText('Top namespace');
assert
.dom(SELECTORS.charts.statTextValue('Entity clients'))
.hasTextContaining(
`${formatNumber([response.total.entity_clients])}`,
'total entity clients is back to unfiltered value'
);
assert
.dom(SELECTORS.charts.statTextValue('Non-entity clients'))
.hasTextContaining(
`${formatNumber([formatNumber([response.total.non_entity_clients])])}`,
'total non-entity clients is back to unfiltered value'
);
assert
.dom(SELECTORS.charts.statTextValue('Secrets sync clients'))
.hasTextContaining(
`${formatNumber([formatNumber([response.total.secret_syncs])])}`,
'total sync clients is back to unfiltered value'
);
assert
.dom('[data-test-attribution-clients]')
.hasTextContaining(
`${formatNumber([topNamespace.clients])}`,
'top attribution clients back to unfiltered value'
);
});
});

View File

@@ -45,7 +45,7 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) {
assert.strictEqual(currentURL(), '/vault/replication/dr', 'Replication dr route renders');
await click(link('Client Count'));
assert.strictEqual(currentURL(), '/vault/clients/dashboard', 'Client counts route renders');
assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Client counts route renders');
await click(link('License'));
assert.strictEqual(currentURL(), '/vault/license', 'License route renders');

View File

@@ -4,6 +4,9 @@
*/
import { Response } from 'miragejs';
import { SELECTORS as GENERAL } from 'vault/tests/helpers/general-selectors';
import { click } from '@ember/test-helpers';
import { addMonths, startOfMonth, subMonths } from 'date-fns';
/** Scenarios
Config off, no data
@@ -22,7 +25,48 @@ import { Response } from 'miragejs';
License start date this month
*/
export const SELECTORS = {
dashboardActiveTab: '.active[data-test-dashboard]',
...GENERAL,
counts: {
startLabel: '[data-test-counts-start-label]',
description: '[data-test-counts-description]',
startMonth: '[data-test-counts-start-month]',
startEdit: '[data-test-counts-start-edit]',
startDropdown: '[data-test-counts-start-dropdown]',
configDisabled: '[data-test-counts-disabled]',
namespaces: '[data-test-counts-namespaces]',
mountPaths: '[data-test-counts-auth-mounts]',
startDiscrepancy: '[data-test-counts-start-discrepancy]',
},
tokenTab: {
entity: '[data-test-monthly-new-entity]',
nonentity: '[data-test-monthly-new-nonentity]',
legend: '[data-test-monthly-new-legend]',
},
syncTab: {
total: '[data-test-total-sync-clients]',
average: '[data-test-average-sync-clients]',
},
charts: {
chart: (title) => `[data-test-chart="${title}"]`, // newer lineal charts
statTextValue: (label) =>
label ? `[data-test-stat-text-container="${label}"] .stat-value` : '[data-test-stat-text-container]',
legend: '[data-test-chart-container-legend]',
legendLabel: (nth) => `.legend-label:nth-child(${nth * 2})`, // nth * 2 accounts for dots in legend
timestamp: '[data-test-chart-container-timestamp]',
dataBar: '[data-test-vertical-bar]',
xAxisLabel: '[data-test-x-axis] text',
// selectors for old d3 charts
verticalBar: '[data-test-vertical-bar-chart]',
lineChart: '[data-test-line-chart]',
bar: {
xAxisLabel: '[data-test-vertical-chart="x-axis-labels"] text',
dataBar: '[data-test-vertical-chart="data-bar"]',
},
line: {
xAxisLabel: '[data-test-line-chart] [data-test-x-axis] text',
plotPoint: '[data-test-line-chart="plot-point"]',
},
},
emptyStateTitle: '[data-test-empty-state-title]',
usageStats: '[data-test-usage-stats]',
dateDisplay: '[data-test-date-display]',
@@ -31,10 +75,10 @@ export const SELECTORS = {
rangeDropdown: '[data-test-calendar-widget-trigger]',
monthDropdown: '[data-test-toggle-month]',
yearDropdown: '[data-test-toggle-year]',
currentBillingPeriod: '[data-test-current-billing-period]',
dateDropdownSubmit: '[data-test-date-dropdown-submit]',
runningTotalMonthStats: '[data-test-running-total="single-month-stats"]',
runningTotalMonthlyCharts: '[data-test-running-total="monthly-charts"]',
monthlyUsageBlock: '[data-test-monthly-usage]',
selectedAuthMount: 'div#auth-method-search-select [data-test-selected-option] div',
selectedNs: 'div#namespace-search-select [data-test-selected-option] div',
upgradeWarning: '[data-test-clients-upgrade-warning]',
@@ -80,3 +124,19 @@ export function overrideResponse(httpStatus, data) {
}
return new Response(200, { 'Content-Type': 'application/json' }, JSON.stringify(data));
}
export async function dateDropdownSelect(month, year) {
const { dateDropdown, counts } = SELECTORS;
await click(counts.startEdit);
await click(dateDropdown.toggleMonth);
await click(dateDropdown.selectMonth(month));
await click(dateDropdown.toggleYear);
await click(dateDropdown.selectYear(year));
await click(dateDropdown.submit);
}
export const STATIC_NOW = new Date('2023-01-13T14:15:00');
// for testing, we're in the middle of a license/billing period
export const LICENSE_START = startOfMonth(subMonths(STATIC_NOW, 6)); // 2022-07-01
// upgrade happened 1 month after license start
export const UPGRADE_DATE = addMonths(LICENSE_START, 1); // 2022-08-01

View File

@@ -22,6 +22,22 @@ export const SELECTORS = {
emptyStateActions: '[data-test-empty-state-actions]',
menuTrigger: '[data-test-popup-menu-trigger]',
listItem: '[data-test-list-item-link]',
calendarWidget: {
trigger: '[data-test-calendar-widget-trigger]',
currentMonth: '[data-test-current-month]',
currentBillingPeriod: '[data-test-current-billing-period]',
customEndMonth: '[data-test-show-calendar]',
previousYear: '[data-test-previous-year]',
nextYear: '[data-test-next-year]',
calendarMonth: (month) => `[data-test-calendar-month="${month}"]`,
},
dateDropdown: {
toggleMonth: '[data-test-toggle-month]',
toggleYear: '[data-test-toggle-year]',
selectMonth: (month) => `[data-test-dropdown-month="${month}"]`,
selectYear: (year) => `[data-test-dropdown-year="${year}"]`,
submit: '[data-test-date-dropdown-submit]',
},
// FORMS
infoRowValue: (label) => `[data-test-value-div="${label}"]`,
inputByAttr: (attr) => `[data-test-input="${attr}"]`,
@@ -54,4 +70,5 @@ export const SELECTORS = {
input: '[data-test-kv-suggestion-input]',
select: '[data-test-kv-suggestion-select]',
},
navLink: (label) => `[data-test-sidebar-nav-link="${label}"]`,
};

View File

@@ -0,0 +1,119 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
const EXAMPLE = [
{
month: '7/22',
timestamp: '2022-07-01T00:00:00-07:00',
clients: null,
entity_clients: null,
non_entity_clients: null,
secret_syncs: null,
},
{
month: '8/22',
timestamp: '2022-08-01T00:00:00-07:00',
clients: 6440,
entity_clients: 1471,
non_entity_clients: 4389,
secret_syncs: 4207,
},
{
month: '9/22',
timestamp: '2022-09-01T00:00:00-07:00',
clients: 9583,
entity_clients: 149,
non_entity_clients: 20,
secret_syncs: 5802,
},
];
module('Integration | Component | clients/charts/vertical-bar-basic', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.data = EXAMPLE;
});
test('it renders when some months have no data', async function (assert) {
assert.expect(10);
await render(hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" />`);
assert.dom('[data-test-sync-bar-chart]').exists('renders chart container');
assert.dom('[data-test-vertical-bar]').exists({ count: 3 }, 'renders 3 vertical bars');
// Tooltips
await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseover');
assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover');
assert.dom('[data-test-tooltip-count]').hasText('5,802 secret syncs', 'tooltip has exact count');
assert.dom('[data-test-tooltip-month]').hasText('September 2022', 'tooltip has humanized month and year');
await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseout');
assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout');
await triggerEvent('[data-test-interactive-area="7/22"]', 'mouseover');
assert
.dom('[data-test-tooltip-count]')
.hasText('No data', 'renders tooltip with no data message when no data is available');
// Axis
assert.dom('[data-test-x-axis]').hasText('7/22 8/22 9/22', 'renders x-axis labels');
assert.dom('[data-test-y-axis]').hasText('0 2k 4k', 'renders y-axis labels');
// Table
assert.dom('[data-test-underlying-data]').doesNotExist('does not render underlying data by default');
});
// 0 is different than null (no data)
test('it renders when all months have 0 clients', async function (assert) {
assert.expect(9);
this.data = [
{
month: '6/22',
timestamp: '2022-06-01T00:00:00-07:00',
clients: 0,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
{
month: '7/22',
timestamp: '2022-07-01T00:00:00-07:00',
clients: 0,
entity_clients: 0,
non_entity_clients: 0,
secret_syncs: 0,
},
];
await render(hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" />`);
assert.dom('[data-test-sync-bar-chart]').exists('renders chart container');
assert.dom('[data-test-vertical-bar]').exists({ count: 2 }, 'renders 2 vertical bars');
assert.dom('[data-test-vertical-bar]').hasAttribute('height', '0', 'rectangles have 0 height');
// Tooltips
await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseover');
assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover');
assert.dom('[data-test-tooltip-count]').hasText('0 secret syncs', 'tooltip has exact count');
assert.dom('[data-test-tooltip-month]').hasText('June 2022', 'tooltip has humanized month and year');
await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseout');
assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout');
// Axis
assert.dom('[data-test-x-axis]').hasText('6/22 7/22', 'renders x-axis labels');
assert.dom('[data-test-y-axis]').hasText('0 1 2 3 4', 'renders y-axis labels');
});
test('it renders underlying data', async function (assert) {
assert.expect(3);
await render(
hbs`<Clients::Charts::VerticalBarBasic @data={{this.data}} @dataKey="secret_syncs" @showTable={{true}} />`
);
assert.dom('[data-test-sync-bar-chart]').exists('renders chart container');
assert.dom('[data-test-underlying-data]').exists('renders underlying data when showTable=true');
assert
.dom('[data-test-underlying-data] thead')
.hasText('Month Count of secret syncs', 'renders correct table headers');
});
});

View File

@@ -46,7 +46,7 @@ module('Integration | Component | clients/attribution', function (hooks) {
test('it renders empty state with no data', async function (assert) {
await render(hbs`
<Clients::Attribution @chartLegend={{this.chartLegend}} />
<Clients::Attribution />
`);
assert.dom('[data-test-component="empty-state"]').exists();
@@ -59,7 +59,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
test('it renders with data for namespaces', async function (assert) {
await render(hbs`
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@@ -92,7 +91,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
this.end = formatRFC3339(subMonths(endOfMonth(this.mockNow), 1));
await render(hbs`
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@@ -145,7 +143,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
test('it renders single chart for current month', async function (assert) {
await render(hbs`
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@@ -166,7 +163,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
test('it renders single chart and correct text for for date range', async function (assert) {
await render(hbs`
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.totalClientAttribution}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@@ -189,7 +185,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
this.set('selectedNamespace', 'second');
await render(hbs`
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.namespaceMountsData}}
@totalUsageCounts={{this.totalUsageCounts}}
@responseTimestamp={{this.timestamp}}
@@ -220,7 +215,6 @@ module('Integration | Component | clients/attribution', function (hooks) {
test('it renders modal', async function (assert) {
await render(hbs`
<Clients::Attribution
@chartLegend={{this.chartLegend}}
@totalClientAttribution={{this.namespaceMountsData}}
@responseTimestamp={{this.timestamp}}
@startTimestamp="2022-06-01T23:00:11.050Z"

View File

@@ -9,7 +9,6 @@ import { setupRenderingTest } from 'ember-qunit';
import { find, render, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { format, formatRFC3339, subMonths } from 'date-fns';
// import { formatChartDate } from 'core/utils/date-formatters';
import timestamp from 'core/utils/timestamp';
module('Integration | Component | clients/line-chart', function (hooks) {
@@ -20,6 +19,181 @@ module('Integration | Component | clients/line-chart', function (hooks) {
hooks.beforeEach(function () {
this.set('xKey', 'foo');
this.set('yKey', 'bar');
this.set('dataset', [
{
foo: '2018-04-03T14:15:30',
bar: 4,
expectedLabel: '4/18',
},
{
foo: '2018-05-03T14:15:30',
bar: 8,
expectedLabel: '5/18',
},
{
foo: '2018-06-03T14:15:30',
bar: 14,
expectedLabel: '6/18',
},
{
foo: '2018-07-03T14:15:30',
bar: 10,
expectedLabel: '7/18',
},
]);
});
hooks.after(function () {
timestamp.now.restore();
});
test('it renders', async function (assert) {
await render(hbs`
<div class="chart-container-wide">
<Clients::Charts::Line @dataset={{this.dataset}} @xKey={{this.xKey}} @yKey={{this.yKey}} />
</div>
`);
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
findAll('[data-test-x-axis] text').forEach((e, i) => {
// For some reason the first axis label is not rendered
assert
.dom(e)
.hasText(
`${this.dataset[i].expectedLabel}`,
`renders x-axis label: ${this.dataset[i].expectedLabel}`
);
});
assert.dom('[data-test-y-axis] text').hasText('0', `y-axis starts at 0`);
});
test('it renders upgrade data', async function (assert) {
const now = timestamp.now();
this.set('dataset', [
{
foo: formatRFC3339(subMonths(now, 4)),
bar: 4,
month: format(subMonths(now, 4), 'M/yy'),
},
{
foo: formatRFC3339(subMonths(now, 3)),
bar: 8,
month: format(subMonths(now, 3), 'M/yy'),
},
{
foo: formatRFC3339(subMonths(now, 2)),
bar: 14,
month: format(subMonths(now, 2), 'M/yy'),
},
{
foo: formatRFC3339(subMonths(now, 1)),
bar: 10,
month: format(subMonths(now, 1), 'M/yy'),
},
]);
this.set('upgradeData', [
{
id: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: formatRFC3339(subMonths(now, 2)),
},
]);
await render(hbs`
<div class="chart-container-wide">
<Clients::Charts::Line
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
@xKey={{this.xKey}}
@yKey={{this.yKey}}
/>
</div>
`);
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
assert
.dom(find(`[data-test-line-chart="upgrade-${this.dataset[2].month}"]`))
.hasStyle(
{ fill: 'rgb(253, 238, 186)' },
`upgrade data point ${this.dataset[2].month} has yellow highlight`
);
});
test('it renders tooltip', async function (assert) {
assert.expect(1);
const now = timestamp.now();
const tooltipData = [
{
month: format(subMonths(now, 4), 'M/yy'),
timestamp: formatRFC3339(subMonths(now, 4)),
clients: 4,
new_clients: {
clients: 0,
},
},
{
month: format(subMonths(now, 3), 'M/yy'),
timestamp: formatRFC3339(subMonths(now, 3)),
clients: 8,
new_clients: {
clients: 4,
},
},
{
month: format(subMonths(now, 2), 'M/yy'),
timestamp: formatRFC3339(subMonths(now, 2)),
clients: 14,
new_clients: {
clients: 6,
},
},
{
month: format(subMonths(now, 1), 'M/yy'),
timestamp: formatRFC3339(subMonths(now, 1)),
clients: 20,
new_clients: {
clients: 4,
},
},
];
this.set('dataset', tooltipData);
this.set('upgradeData', [
{
id: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: formatRFC3339(subMonths(now, 2)),
},
]);
await render(hbs`
<div class="chart-container-wide">
<Clients::Charts::Line
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
/>
</div>
`);
const tooltipHoverCircles = findAll('[data-test-hover-circle]');
assert.strictEqual(tooltipHoverCircles.length, tooltipData.length, 'all data circles are rendered');
// FLAKY after adding a11y testing, skip for now
// for (const [i, bar] of tooltipHoverCircles.entries()) {
// await triggerEvent(bar, 'mouseover');
// const tooltip = document.querySelector('.ember-modal-dialog');
// const { month, clients, new_clients } = tooltipData[i];
// assert
// .dom(tooltip)
// .includesText(
// `${formatChartDate(month)} ${clients} total clients ${new_clients.clients} new clients`,
// `tooltip text is correct for ${month}`
// );
// }
});
test('it fails gracefully when data is not formatted correctly', async function (assert) {
this.set('dataset', [
{
foo: 1,
@@ -38,149 +212,27 @@ module('Integration | Component | clients/line-chart', function (hooks) {
bar: 10,
},
]);
});
hooks.after(function () {
timestamp.now.restore();
});
test('it renders', async function (assert) {
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart @dataset={{this.dataset}} @xKey={{this.xKey}} @yKey={{this.yKey}} />
</div>
`);
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
findAll('[data-test-line-chart="x-axis-labels"] text').forEach((e, i) => {
assert
.dom(e)
.hasText(`${this.dataset[i][this.xKey]}`, `renders x-axis label: ${this.dataset[i][this.xKey]}`);
});
assert.dom(find('[data-test-line-chart="y-axis-labels"] text')).hasText('0', `y-axis starts at 0`);
});
test('it renders upgrade data', async function (assert) {
const now = timestamp.now();
this.set('dataset', [
{
foo: format(subMonths(now, 4), 'M/yy'),
bar: 4,
},
{
foo: format(subMonths(now, 3), 'M/yy'),
bar: 8,
},
{
foo: format(subMonths(now, 2), 'M/yy'),
bar: 14,
},
{
foo: format(subMonths(now, 1), 'M/yy'),
bar: 10,
},
]);
this.set('upgradeData', [
{
id: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: formatRFC3339(subMonths(now, 2)),
},
]);
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
<Clients::Charts::Line
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
@xKey={{this.xKey}}
@yKey={{this.yKey}}
/>
</div>
`);
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
assert.dom('[data-test-line-chart]').doesNotExist('Chart is not rendered');
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
assert
.dom(find(`[data-test-line-chart="upgrade-${this.dataset[2][this.xKey]}"]`))
.hasStyle({ opacity: '1' }, `upgrade data point ${this.dataset[2][this.xKey]} has yellow highlight`);
});
test('it renders tooltip', async function (assert) {
assert.expect(1);
const now = timestamp.now();
const tooltipData = [
{
month: format(subMonths(now, 4), 'M/yy'),
clients: 4,
new_clients: {
clients: 0,
},
},
{
month: format(subMonths(now, 3), 'M/yy'),
clients: 8,
new_clients: {
clients: 4,
},
},
{
month: format(subMonths(now, 2), 'M/yy'),
clients: 14,
new_clients: {
clients: 6,
},
},
{
month: format(subMonths(now, 1), 'M/yy'),
clients: 20,
new_clients: {
clients: 4,
},
},
];
this.set('dataset', tooltipData);
this.set('upgradeData', [
{
id: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: formatRFC3339(subMonths(now, 2)),
},
]);
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
/>
</div>
`);
const tooltipHoverCircles = findAll('[data-test-line-chart] circle.hover-circle');
assert.strictEqual(tooltipHoverCircles.length, tooltipData.length, 'all data circles are rendered');
// FLAKY after adding a11y testing, skip for now
// for (const [i, bar] of tooltipHoverCircles.entries()) {
// await triggerEvent(bar, 'mouseover');
// const tooltip = document.querySelector('.ember-modal-dialog');
// const { month, clients, new_clients } = tooltipData[i];
// assert
// .dom(tooltip)
// .includesText(
// `${formatChartDate(month)} ${clients} total clients ${new_clients.clients} new clients`,
// `tooltip text is correct for ${month}`
// );
// }
.dom('[data-test-component="empty-state"]')
.hasText('No data to display', 'Shows empty state when time date is not formatted correctly');
});
test('it fails gracefully when upgradeData is an object', async function (assert) {
this.set('upgradeData', { some: 'object' });
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
<Clients::Charts::Line
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
@xKey={{this.xKey}}
@@ -198,7 +250,7 @@ module('Integration | Component | clients/line-chart', function (hooks) {
this.set('upgradeData', [{ incorrect: 'key names' }]);
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
<Clients::Charts::Line
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
@xKey={{this.xKey}}
@@ -215,7 +267,7 @@ module('Integration | Component | clients/line-chart', function (hooks) {
test('it renders empty state when no dataset', async function (assert) {
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart @noDataMessage="this is a custom message to explain why you're not seeing a line chart"/>
<Clients::Charts::Line @noDataMessage="this is a custom message to explain why you're not seeing a line chart"/>
</div>
`);
@@ -227,4 +279,121 @@ module('Integration | Component | clients/line-chart', function (hooks) {
'custom message renders'
);
});
test('it updates axis when dataset updates', async function (assert) {
const datasets = {
small: [
{
foo: '2020-04-01',
bar: 4,
month: '4/20',
},
{
foo: '2020-05-01',
bar: 8,
month: '5/20',
},
{
foo: '2020-06-01',
bar: 1,
},
{
foo: '2020-07-01',
bar: 10,
},
],
large: [
{
foo: '2020-08-01',
bar: 4586,
month: '8/20',
},
{
foo: '2020-09-01',
bar: 8928,
month: '9/20',
},
{
foo: '2020-10-01',
bar: 11948,
month: '10/20',
},
{
foo: '2020-11-01',
bar: 16943,
month: '11/20',
},
],
broken: [
{
foo: '2020-01-01',
bar: null,
month: '1/20',
},
{
foo: '2020-02-01',
bar: 0,
month: '2/20',
},
{
foo: '2020-03-01',
bar: 22,
month: '3/20',
},
{
foo: '2020-04-01',
bar: null,
month: '4/20',
},
{
foo: '2020-05-01',
bar: 70,
month: '5/20',
},
{
foo: '2020-06-01',
bar: 50,
month: '6/20',
},
],
};
this.set('dataset', datasets.small);
await render(hbs`
<div class="chart-container-wide">
<Clients::Charts::Line
@dataset={{this.dataset}}
@upgradeData={{this.upgradeData}}
@xKey={{this.xKey}}
@yKey={{this.yKey}}
/>
</div>
`);
assert.dom('[data-test-y-axis]').hasText('0 2 4 6 8 10', 'y-axis renders correctly for small values');
assert
.dom('[data-test-x-axis]')
.hasText('4/20 5/20 6/20 7/20', 'x-axis renders correctly for small values');
// Update to large dataset
this.set('dataset', datasets.large);
assert.dom('[data-test-y-axis]').hasText('0 5k 10k 15k', 'y-axis renders correctly for new large values');
assert
.dom('[data-test-x-axis]')
.hasText('8/20 9/20 10/20 11/20', 'x-axis renders correctly for small values');
// Update to broken dataset
this.set('dataset', datasets.broken);
assert.dom('[data-test-y-axis]').hasText('0 20 40 60', 'y-axis renders correctly for new broken values');
assert
.dom('[data-test-x-axis]')
.hasText('1/20 2/20 3/20 4/20 5/20 6/20', 'x-axis renders correctly for small values');
assert.dom('[data-test-hover-circle]').exists({ count: 4 }, 'only render circles for non-null values');
assert
.dom('[data-test-hover-circle="1/20"]')
.doesNotExist('first month dot does not exist because value is null');
assert
.dom('[data-test-hover-circle="4/20"]')
.doesNotExist('other null count month dot also does not render');
// Note: the line should also show a gap, but this is difficult to test for
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, click, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler from 'vault/mirage/handlers/clients';
import { getUnixTime } from 'date-fns';
import { SELECTORS as ts, dateDropdownSelect } from 'vault/tests/helpers/clients';
import { selectChoose } from 'ember-power-select/test-support/helpers';
import timestamp from 'core/utils/timestamp';
import sinon from 'sinon';
const STATIC_NOW = new Date('2024-01-25T23:59:59Z');
const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z'));
const END_TIME = getUnixTime(STATIC_NOW);
module('Integration | Component | clients | Page::Counts', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.before(function () {
sinon.stub(timestamp, 'now').callsFake(() => STATIC_NOW);
});
hooks.beforeEach(async function () {
clientsHandler(this.server);
const store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
this.activity = await store.queryRecord('clients/activity', activityQuery);
this.config = await store.queryRecord('clients/config', {});
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Counts
@activity={{this.activity}}
@activityError={{this.activityError}}
@config={{this.config}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.namespace}}
@mountPath={{this.mountPath}}
@onFilterChange={{this.onFilterChange}}
>
<div data-test-yield>Yield block</div>
</Clients::Page::Counts>
`);
});
hooks.after(function () {
timestamp.now.restore();
});
test('it should render start date label and description based on version', async function (assert) {
const versionService = this.owner.lookup('service:version');
await this.renderComponent();
assert.dom(ts.counts.startLabel).hasText('Client counting start date', 'Label renders for OSS');
assert
.dom(ts.counts.description)
.hasText(
'This date is when client counting starts. Without this starting point, the data shown is not reliable.',
'Description renders for OSS'
);
versionService.set('type', 'enterprise');
await settled();
assert.dom(ts.counts.startLabel).hasText('Billing start month', 'Label renders for Enterprise');
assert
.dom(ts.counts.description)
.hasText(
'This date comes from your license, and defines when client counting starts. Without this starting point, the data shown is not reliable.',
'Description renders for Enterprise'
);
});
test('it should populate start and end month displays', async function (assert) {
await this.renderComponent();
assert.dom(ts.counts.startMonth).hasText('October 2023', 'Start month renders');
assert
.dom(ts.calendarWidget.trigger)
.hasText('Oct 2023 - Jan 2024', 'Start and end months render in filter bar');
});
test('it should render no data empty state', async function (assert) {
this.activity = { id: 'no-data' };
await this.renderComponent();
assert
.dom(ts.emptyStateTitle)
.hasText('No data received from October 2023 to January 2024', 'No data empty state renders');
});
test('it should render activity error', async function (assert) {
this.activity = null;
this.activityError = { httpStatus: 403 };
await this.renderComponent();
assert.dom(ts.emptyStateTitle).hasText('You are not authorized', 'Activity error empty state renders');
});
test('it should render config disabled alert', async function (assert) {
this.config.enabled = 'Off';
await this.renderComponent();
assert.dom(ts.counts.configDisabled).hasText('Tracking is disabled', 'Config disabled alert renders');
});
test('it should send correct values on start and end date change', async function (assert) {
assert.expect(4);
let expected = { start_time: getUnixTime(new Date('2023-01-01T00:00:00Z')), end_time: END_TIME };
this.onFilterChange = (params) => {
assert.deepEqual(params, expected, 'Correct values sent on filter change');
this.startTimestamp = params.start_time || START_TIME;
this.endTimestamp = params.end_time || END_TIME;
};
await this.renderComponent();
await dateDropdownSelect('January', '2023');
expected.start_time = END_TIME;
await click(ts.calendarWidget.trigger);
await click(ts.calendarWidget.currentMonth);
expected.start_time = getUnixTime(this.config.billingStartTimestamp);
await click(ts.calendarWidget.trigger);
await click(ts.calendarWidget.currentBillingPeriod);
expected = { end_time: getUnixTime(new Date('2023-12-31T00:00:00Z')) };
await click(ts.calendarWidget.trigger);
await click(ts.calendarWidget.customEndMonth);
await click(ts.calendarWidget.previousYear);
await click(ts.calendarWidget.calendarMonth('December'));
});
test('it should render namespace and auth mount filters', async function (assert) {
assert.expect(5);
this.namespace = 'root';
this.mountPath = 'auth/authid0';
let assertion = (params) =>
assert.deepEqual(params, { ns: undefined, mountPath: undefined }, 'Auth mount cleared with namespace');
this.onFilterChange = (params) => {
if (assertion) {
assertion(params);
}
const keys = Object.keys(params);
this.namespace = keys.includes('ns') ? params.ns : this.namespace;
this.mountPath = keys.includes('mountPath') ? params.mountPath : this.mountPath;
};
await this.renderComponent();
assert.dom(ts.counts.namespaces).includesText(this.namespace, 'Selected namespace renders');
assert.dom(ts.counts.mountPaths).includesText(this.mountPath, 'Selected auth mount renders');
await click(`${ts.counts.namespaces} button`);
// this is only necessary in tests since SearchSelect does not respond to initialValue changes
// in the app the component is rerender on query param change
assertion = null;
await click(`${ts.counts.mountPaths} button`);
assertion = (params) => assert.true(params.ns.includes('ns/'), 'Namespace value sent on change');
await selectChoose(ts.counts.namespaces, '.ember-power-select-option', 0);
assertion = (params) =>
assert.true(params.mountPath.includes('auth/'), 'Auth mount value sent on change');
await selectChoose(ts.counts.mountPaths, '.ember-power-select-option', 0);
});
test('it should render start time discrepancy alert', async function (assert) {
this.startTimestamp = getUnixTime(new Date('2022-06-01T00:00:00Z'));
await this.renderComponent();
assert
.dom(ts.counts.startDiscrepancy)
.hasText(
'Warning You requested data from June 2022. We only have data from October 2023, and that is what is being shown here.',
'Start discrepancy alert renders'
);
});
test('it should render empty state for no start or license start time', async function (assert) {
this.startTimestamp = null;
this.config.billingStartTimestamp = null;
this.activity = {};
await this.renderComponent();
assert.dom(ts.emptyStateTitle).hasText('No start date found', 'Empty state renders');
assert.dom(ts.counts.startDropdown).exists('Date dropdown renders when start time is not provided');
});
test('it should render catch all empty state', async function (assert) {
this.activity.total = null;
await this.renderComponent();
assert
.dom(ts.emptyStateTitle)
.hasText('No data received from October 2023 to January 2024', 'Empty state renders');
});
});

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, findAll } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler from 'vault/mirage/handlers/clients';
import { getUnixTime } from 'date-fns';
import { SELECTORS } from 'vault/tests/helpers/clients';
import { formatNumber } from 'core/helpers/format-number';
import { calculateAverage } from 'vault/utils/chart-helpers';
import { dateFormat } from 'core/helpers/date-format';
const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z'));
const END_TIME = getUnixTime(new Date('2024-01-31T23:59:59Z'));
const { syncTab, charts, usageStats } = SELECTORS;
module('Integration | Component | clients | Clients::Page::Sync', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Sync
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.countsController.ns}}
@mountPath={{this.countsController.mountPath}}
/>
`);
});
test('it should render with full month activity data', async function (assert) {
assert.expect(4 + this.activity.byMonth.length);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'secret_syncs')]);
await this.renderComponent();
assert
.dom(syncTab.total)
.hasText(
`Total sync clients The total number of secrets synced from Vault to other destinations during this date range. ${expectedTotal}`,
`renders correct total sync stat ${expectedTotal}`
);
assert
.dom(syncTab.average)
.hasText(
`Average sync clients per month ${expectedAvg}`,
`renders correct average sync stat ${expectedAvg}`
);
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert.dom(charts.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders response timestamp');
// assert bar chart is correct
findAll(`${charts.chart('Secrets sync usage')} ${charts.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for bar chart: ${this.activity.byMonth[i].month}`
);
});
assert
.dom(charts.dataBar)
.exists(
{ count: this.activity.byMonth.filter((m) => m.counts !== null).length },
'renders correct number of data bars'
);
});
test('it should render empty state for no monthly data', async function (assert) {
assert.expect(5);
this.activity.set('byMonth', []);
await this.renderComponent();
assert.dom(charts.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
assert.dom(SELECTORS.emptyStateTitle).hasText('No monthly secrets sync clients');
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert.dom(charts.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
assert.dom(syncTab.total).doesNotExist('total sync counts does not exist');
assert.dom(syncTab.average).doesNotExist('average sync client counts does not exist');
});
test('it should render stats without chart for a single month', async function (assert) {
assert.expect(4);
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
const total = formatNumber([this.activity.total.secret_syncs]);
await this.renderComponent();
assert.dom(charts.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
assert
.dom(usageStats)
.hasText(
`Secrets sync usage This data can be used to understand how many secrets sync clients have been used for this date range. A secret with a configured sync destination would qualify as a unique and active client. Total sync clients ${total}`,
'renders sync stats instead of chart'
);
assert.dom(syncTab.total).doesNotExist('total sync counts does not exist');
assert.dom(syncTab.average).doesNotExist('average sync client counts does not exist');
});
});

View File

@@ -0,0 +1,188 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, findAll } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler from 'vault/mirage/handlers/clients';
import { getUnixTime } from 'date-fns';
import { calculateAverage } from 'vault/utils/chart-helpers';
import { formatNumber } from 'core/helpers/format-number';
import { dateFormat } from 'core/helpers/date-format';
import { SELECTORS as ts } from 'vault/tests/helpers/clients';
const START_TIME = getUnixTime(new Date('2023-10-01T00:00:00Z'));
const END_TIME = getUnixTime(new Date('2024-01-31T23:59:59Z'));
module('Integration | Component | clients | Page::Token', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
const store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
this.activity = await store.queryRecord('clients/activity', activityQuery);
this.newActivity = this.activity.byMonth.map((d) => d.new_clients);
this.versionHistory = await store
.findAll('clients/version-history')
.then((response) => {
return response.map(({ version, previousVersion, timestampInstalled }) => {
return {
version,
previousVersion,
timestampInstalled,
};
});
})
.catch(() => []);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Token
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@namespace={{this.ns}}
@mountPath={{this.mountPath}}
/>
`);
});
test('it should render monthly total chart', async function (assert) {
const getAverage = (data) => {
const average = ['entity_clients', 'non_entity_clients'].reduce((count, key) => {
return (count += calculateAverage(data, key) || 0);
}, 0);
return formatNumber([average]);
};
const expectedTotal = getAverage(this.activity.byMonth);
const expectedNew = getAverage(this.newActivity);
const chart = ts.charts.chart('monthly total');
await this.renderComponent();
assert
.dom(ts.charts.statTextValue('Average total clients per month'))
.hasText(expectedTotal, 'renders correct total clients');
assert
.dom(ts.charts.statTextValue('Average new clients per month'))
.hasText(expectedNew, 'renders correct new clients');
// assert bar chart is correct
findAll(`${chart} ${ts.charts.bar.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for bar chart: ${this.activity.byMonth[i].month}`
);
});
assert
.dom(`${chart} ${ts.charts.bar.dataBar}`)
.exists(
{ count: this.activity.byMonth.filter((m) => m.counts !== null).length * 2 },
'renders correct number of data bars'
);
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert
.dom(`${chart} ${ts.charts.timestamp}`)
.hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
assert.dom(`${chart} ${ts.charts.legendLabel(1)}`).hasText('Entity clients', 'Legend label renders');
assert.dom(`${chart} ${ts.charts.legendLabel(2)}`).hasText('Non-entity clients', 'Legend label renders');
});
test('it should render monthly new chart', async function (assert) {
const expectedNewEntity = formatNumber([calculateAverage(this.newActivity, 'entity_clients')]);
const expectedNewNonEntity = formatNumber([calculateAverage(this.newActivity, 'non_entity_clients')]);
const chart = ts.charts.chart('monthly new');
await this.renderComponent();
assert
.dom(ts.charts.statTextValue('Average new entity clients per month'))
.hasText(expectedNewEntity, 'renders correct new entity clients');
assert
.dom(ts.charts.statTextValue('Average new non-entity clients per month'))
.hasText(expectedNewNonEntity, 'renders correct new nonentity clients');
// assert bar chart is correct
findAll(`${chart} ${ts.charts.bar.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for bar chart: ${this.activity.byMonth[i].month}`
);
});
assert
.dom(`${chart} ${ts.charts.bar.dataBar}`)
.exists(
{ count: this.activity.byMonth.filter((m) => m.counts !== null).length * 2 },
'renders correct number of data bars'
);
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert
.dom(`${chart} ${ts.charts.timestamp}`)
.hasText(`Updated ${formattedTimestamp}`, 'renders timestamp');
assert.dom(`${chart} ${ts.charts.legendLabel(1)}`).hasText('Entity clients', 'Legend label renders');
assert.dom(`${chart} ${ts.charts.legendLabel(2)}`).hasText('Non-entity clients', 'Legend label renders');
});
test('it should render empty state for no new monthly data', async function (assert) {
this.activity.byMonth = this.activity.byMonth.map((d) => ({
...d,
new_clients: { month: d.month },
}));
const chart = ts.charts.chart('monthly-new');
await this.renderComponent();
assert.dom(`${chart} ${ts.charts.verticalBar}`).doesNotExist('Chart does not render');
assert.dom(`${chart} ${ts.charts.legend}`).doesNotExist('Legend does not render');
assert.dom(ts.emptyStateTitle).hasText('No new clients');
assert.dom(ts.tokenTab.entity).doesNotExist('New client counts does not exist');
assert.dom(ts.tokenTab.nonentity).doesNotExist('Average new client counts does not exist');
});
test('it should render usage stats', async function (assert) {
assert.expect(6);
this.activity.endTime = this.activity.startTime;
const {
total: { entity_clients, non_entity_clients },
} = this.activity;
const checkUsage = () => {
assert
.dom(ts.charts.statTextValue('Total clients'))
.hasText(formatNumber([entity_clients + non_entity_clients]), 'Total clients value renders');
assert
.dom(ts.charts.statTextValue('Entity clients'))
.hasText(formatNumber([entity_clients]), 'Entity clients value renders');
assert
.dom(ts.charts.statTextValue('Non-entity clients'))
.hasText(formatNumber([non_entity_clients]), 'Non-entity clients value renders');
};
// total usage should display for single month query
await this.renderComponent();
checkUsage();
// total usage should display when there is no monthly data
this.activity.byMonth = null;
await this.renderComponent();
checkUsage();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
.hasAttribute('href', 'https://developer.hashicorp.com/vault/tutorials/monitoring/usage-metrics');
});
test('it renders with data', async function (assert) {
test('it renders with token data', async function (assert) {
this.set('counts', {
clients: 17,
entity_clients: 7,
@@ -38,18 +38,40 @@ module('Integration | Component | clients/usage-stats', function (hooks) {
});
await render(hbs`<Clients::UsageStats @totalUsageCounts={{this.counts}} />`);
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts even with no data passed');
assert.dom('[data-test-stat-text="total-clients"]').exists('Total clients exists');
assert.dom('[data-test-stat-text]').exists({ count: 3 }, 'Renders 3 Stat texts');
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText('17', 'Total clients shows passed value');
assert.dom('[data-test-stat-text="entity-clients"]').exists('Entity clients exists');
assert
.dom('[data-test-stat-text="entity-clients"] .stat-value')
.hasText('7', 'entity clients shows passed value');
assert.dom('[data-test-stat-text="non-entity-clients"]').exists('Non entity clients exists');
assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText('10', 'non entity clients shows passed value');
});
test('it renders with full totals data', async function (assert) {
this.set('counts', {
clients: 22,
entity_clients: 7,
non_entity_clients: 10,
secret_syncs: 5,
});
await render(hbs`<Clients::UsageStats @totalUsageCounts={{this.counts}} />`);
assert.dom('[data-test-stat-text]').exists({ count: 4 }, 'Renders 4 Stat texts');
assert
.dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText('22', 'Total clients shows passed value');
assert
.dom('[data-test-stat-text="entity-clients"] .stat-value')
.hasText('7', 'entity clients shows passed value');
assert
.dom('[data-test-stat-text="non-entity-clients"] .stat-value')
.hasText('10', 'non entity clients shows passed value');
assert
.dom('[data-test-stat-text="secret-syncs"] .stat-value')
.hasText('5', 'secrets sync clients shows passed value');
});
});

View File

@@ -882,7 +882,8 @@ module('Integration | Util | client count utils', function (hooks) {
const byNamespaceKeyObject = namespaceArrayToObject(
totalClientsByNamespace,
newClientsByNamespace,
'10/21'
'10/21',
'2021-10-01T00:00:00Z'
);
assert.propEqual(
@@ -923,7 +924,7 @@ module('Integration | Util | client count utils', function (hooks) {
assert.propEqual(
{},
namespaceArrayToObject(null, null, '10/21'),
namespaceArrayToObject(null, null, '10/21', 'timestamp-here'),
'returns an empty object when totalClientsByNamespace = null'
);
});

View File

@@ -3,12 +3,13 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { formatNumbers, formatTooltipNumber, calculateAverage } from 'vault/utils/chart-helpers';
import { formatNumbers, calculateAverage, calculateSum } from 'vault/utils/chart-helpers';
import { module, test } from 'qunit';
const SMALL_NUMBERS = [0, 7, 27, 103, 999];
const LARGE_NUMBERS = {
1001: '1k',
1245: '1.2k',
33777: '34k',
532543: '530k',
2100100: '2.1M',
@@ -17,7 +18,7 @@ const LARGE_NUMBERS = {
module('Unit | Utility | chart-helpers', function () {
test('formatNumbers renders number correctly', function (assert) {
assert.expect(11);
assert.expect(12);
const method = formatNumbers();
assert.ok(method);
SMALL_NUMBERS.forEach(function (num) {
@@ -29,11 +30,6 @@ module('Unit | Utility | chart-helpers', function () {
});
});
test('formatTooltipNumber renders number correctly', function (assert) {
const formatted = formatTooltipNumber(120300200100);
assert.strictEqual(formatted.length, 15, 'adds punctuation at proper place for large numbers');
});
test('calculateAverage is accurate', function (assert) {
const testArray1 = [
{ label: 'foo', value: 10 },
@@ -63,4 +59,10 @@ module('Unit | Utility | chart-helpers', function () {
'returns null when object key does not exist at all'
);
});
test('calculateSum adds array of numbers', function (assert) {
assert.strictEqual(calculateSum([2, 3]), 5, 'it sums array');
assert.strictEqual(calculateSum(['one', 2]), null, 'returns null if array contains non-integers');
assert.strictEqual(calculateSum('not an array'), null, 'returns null if an array is not passed');
});
});

View File

@@ -8,6 +8,9 @@ import KvSecretDataModel from 'vault/models/kv/data';
import KvSecretMetadataModel from 'vault/models/kv/metadata';
import PkiActionModel from 'vault/models/pki/action';
import PkiCertificateGenerateModel from 'vault/models/pki/certificate/generate';
import ClientsActivityModel from 'vault/models/clients/activity';
import ClientsConfigModel from 'vault/models/clients/config';
import ClientsVersionHistoryModel from 'vault/models/clients/version-history';
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
@@ -15,6 +18,9 @@ declare module 'ember-data/types/registries/model' {
'pki/certificate/generate': PkiCertificateGenerateModel;
'kv/data': KvSecretDataModel;
'kv/metadata': KvSecretMetadataModel;
'clients/activity': ClientsActivityModel;
'clients/config': ClientsConfigModel;
'clients/version-history': ClientsVersionHistoryModel;
// Catchall for any other models
[key: string]: any;
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
// Count and EmptyCount are mutually exclusive
// but that's hard to represent in an interface
// so for now we just have both
interface Count {
clients?: number;
entity_clients?: number;
non_entity_clients?: number;
secret_syncs?: number;
}
interface EmptyCount {
count?: null;
}
interface Timestamp {
month: string; // eg. 12/22
timestamp: string; // ISO 8601
}
export interface MonthlyChartData extends Count, EmptyCount, Timestamp {
new_clients?: Count;
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type { Model } from 'vault/app-types';
interface ClientActivityTotals {
clients: number;
entity_clients: number;
non_entity_clients: number;
secret_syncs: number;
}
interface ClientActivityNestedCount extends ClientActivityTotals {
label: string;
}
interface ClientActivityNewClients extends ClientActivityTotals {
month: string;
mounts?: ClientActivityNestedCount[];
namespaces?: ClientActivityNestedCount[];
}
interface ClientActivityNamespace extends ClientActivityNestedCount {
mounts: ClientActivityNestedCount[];
}
interface ClientActivityResourceByKey extends ClientActivityTotals {
month: 'string';
mounts_by_key: { [key: string]: ClientActivityResourceByKey };
new_clients: ClientActivityNewClients;
}
interface ClientActivityMonthly extends ClientActivityTotals {
month: string;
timestamp: string;
namespaces: ClientActivityNamespace[];
namespaces_by_key: { [key: string]: ClientActivityResourceByKey };
new_clients: ClientActivityNewClients;
}
export default interface ClientsActivityModel extends Model {
byMonth: ClientActivityMonthly[];
byNamespace: ClientActivityNamespace[];
total: ClientActivityTotals;
startTime: string;
endTime: string;
responseTimestamp: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { WithFormFieldsAndValidationsModel } from 'vault/vault/app-types';
export default interface ClientsConfigModel extends WithFormFieldsAndValidationsModel {
queriesAvailable: boolean;
retentionMonths: number;
minimumRetentionMonths: number;
enabled: string;
reportingEnabled: boolean;
billingStartTimestamp: Date;
}

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import type { Model } from 'vault/app-types';
export default interface ClientsVersionHistoryModel extends Model {
version: string;
previousVersion: string;
timestampInstalled: string;
}

View File

@@ -6429,6 +6429,21 @@ __metadata:
languageName: node
linkType: hard
"@lineal-viz/lineal@npm:^0.5.1":
version: 0.5.1
resolution: "@lineal-viz/lineal@npm:0.5.1"
dependencies:
"@embroider/addon-shim": ^1.0.0
d3-array: ^3.2.0
d3-scale: ^4.0.2
d3-shape: ^3.1.0
ember-cached-decorator-polyfill: ^1.0.1
ember-modifier: ^3.2.7
ember-resize-modifier: ^0.4.1
checksum: be47dbac62d65f01121d78b8ed393c7c5aa7cb520755a504d912b44054a478a16b9834208f031cb3cd1710c3f04008a91aa41b36ed544d0a738a09607d3c29ac
languageName: node
linkType: hard
"@lint-todo/utils@npm:^13.0.3":
version: 13.0.3
resolution: "@lint-todo/utils@npm:13.0.3"
@@ -12736,6 +12751,13 @@ __metadata:
languageName: node
linkType: hard
"csstype@npm:^3.1.3":
version: 3.1.3
resolution: "csstype@npm:3.1.3"
checksum: 8db785cc92d259102725b3c694ec0c823f5619a84741b5c7991b8ad135dfaa66093038a1cc63e03361a6cd28d122be48f2106ae72334e067dd619a51f49eddf7
languageName: node
linkType: hard
"cyclist@npm:^1.0.1":
version: 1.0.1
resolution: "cyclist@npm:1.0.1"
@@ -12750,6 +12772,15 @@ __metadata:
languageName: node
linkType: hard
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.2.0":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
internmap: 1 - 2
checksum: a5976a6d6205f69208478bb44920dd7ce3e788c9dceb86b304dbe401a4bfb42ecc8b04c20facde486e9adcb488b5d1800d49393a3f81a23902b68158e12cddd0
languageName: node
linkType: hard
"d3-axis@npm:1, d3-axis@npm:^1.0.8":
version: 1.0.12
resolution: "d3-axis@npm:1.0.12"
@@ -12794,6 +12825,13 @@ __metadata:
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
checksum: 4931fbfda5d7c4b5cfa283a13c91a954f86e3b69d75ce588d06cde6c3628cebfc3af2069ccf225e982e8987c612aa7948b3932163ce15eb3c11cd7c003f3ee3b
languageName: node
linkType: hard
"d3-contour@npm:1":
version: 1.3.2
resolution: "d3-contour@npm:1.3.2"
@@ -12876,6 +12914,13 @@ __metadata:
languageName: node
linkType: hard
"d3-format@npm:1 - 3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
checksum: f345ec3b8ad3cab19bff5dead395bd9f5590628eb97a389b1dd89f0b204c7c4fc1d9520f13231c2c7cf14b7c9a8cf10f8ef15bde2befbab41454a569bd706ca2
languageName: node
linkType: hard
"d3-geo@npm:1":
version: 1.12.1
resolution: "d3-geo@npm:1.12.1"
@@ -12901,6 +12946,15 @@ __metadata:
languageName: node
linkType: hard
"d3-interpolate@npm:1.2.0 - 3":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
d3-color: 1 - 3
checksum: a42ba314e295e95e5365eff0f604834e67e4a3b3c7102458781c477bd67e9b24b6bb9d8e41ff5521050a3f2c7c0c4bbbb6e187fd586daa3980943095b267e78b
languageName: node
linkType: hard
"d3-path@npm:1":
version: 1.0.9
resolution: "d3-path@npm:1.0.9"
@@ -12908,6 +12962,13 @@ __metadata:
languageName: node
linkType: hard
"d3-path@npm:^3.1.0":
version: 3.1.0
resolution: "d3-path@npm:3.1.0"
checksum: 2306f1bd9191e1eac895ec13e3064f732a85f243d6e627d242a313f9777756838a2215ea11562f0c7630c7c3b16a19ec1fe0948b1c82f3317fac55882f6ee5d8
languageName: node
linkType: hard
"d3-polygon@npm:1":
version: 1.0.6
resolution: "d3-polygon@npm:1.0.6"
@@ -12968,6 +13029,19 @@ __metadata:
languageName: node
linkType: hard
"d3-scale@npm:^4.0.2":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
d3-array: 2.10.0 - 3
d3-format: 1 - 3
d3-interpolate: 1.2.0 - 3
d3-time: 2.1.1 - 3
d3-time-format: 2 - 4
checksum: a9c770d283162c3bd11477c3d9d485d07f8db2071665f1a4ad23eec3e515e2cefbd369059ec677c9ac849877d1a765494e90e92051d4f21111aa56791c98729e
languageName: node
linkType: hard
"d3-selection-multi@npm:^1.0.1":
version: 1.0.1
resolution: "d3-selection-multi@npm:1.0.1"
@@ -12994,6 +13068,24 @@ __metadata:
languageName: node
linkType: hard
"d3-shape@npm:^3.1.0":
version: 3.2.0
resolution: "d3-shape@npm:3.2.0"
dependencies:
d3-path: ^3.1.0
checksum: de2af5fc9a93036a7b68581ca0bfc4aca2d5a328aa7ba7064c11aedd44d24f310c20c40157cb654359d4c15c3ef369f95ee53d71221017276e34172c7b719cfa
languageName: node
linkType: hard
"d3-time-format@npm:2 - 4":
version: 4.1.0
resolution: "d3-time-format@npm:4.1.0"
dependencies:
d3-time: 1 - 3
checksum: 7342bce28355378152bbd4db4e275405439cabba082d9cd01946d40581140481c8328456d91740b0fe513c51ec4a467f4471ffa390c7e0e30ea30e9ec98fcdf4
languageName: node
linkType: hard
"d3-time-format@npm:2, d3-time-format@npm:^2.1.1":
version: 2.3.0
resolution: "d3-time-format@npm:2.3.0"
@@ -13010,6 +13102,15 @@ __metadata:
languageName: node
linkType: hard
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
d3-array: 2 - 3
checksum: 613b435352a78d9f31b7f68540788186d8c331b63feca60ad21c88e9db1989fe888f97f242322ebd6365e45ec3fb206a4324cd4ca0dfffa1d9b5feb856ba00a7
languageName: node
linkType: hard
"d3-timer@npm:1":
version: 1.0.10
resolution: "d3-timer@npm:1.0.10"
@@ -15340,7 +15441,7 @@ __metadata:
languageName: node
linkType: hard
"ember-modifier@npm:^3.2.7":
"ember-modifier@npm:^3.2.0, ember-modifier@npm:^3.2.7":
version: 3.2.7
resolution: "ember-modifier@npm:3.2.7"
dependencies:
@@ -15422,6 +15523,17 @@ __metadata:
languageName: node
linkType: hard
"ember-resize-modifier@npm:^0.4.1":
version: 0.4.1
resolution: "ember-resize-modifier@npm:0.4.1"
dependencies:
ember-cli-babel: ^7.26.11
ember-cli-htmlbars: ^6.0.1
ember-modifier: ^3.2.0
checksum: 6363c9bc240678ecbe538f655d91a1fd79b7ac2e72f2fe7db70141f45e846bb685d4a10640ed1e59e2f6fae39ad3ca09fb2d60f02493f2db43dd5c38c7823c90
languageName: node
linkType: hard
"ember-resolver@npm:^10.0.0":
version: 10.1.1
resolution: "ember-resolver@npm:10.1.1"
@@ -15609,6 +15721,22 @@ __metadata:
languageName: node
linkType: hard
"ember-style-modifier@npm:^4.1.0":
version: 4.1.0
resolution: "ember-style-modifier@npm:4.1.0"
dependencies:
"@babel/core": ^7.23.6
csstype: ^3.1.3
ember-auto-import: ^2.7.0
ember-cli-babel: ^8.2.0
ember-modifier: ^3.2.7 || ^4.0.0
peerDependencies:
"@ember/string": ^3.0.1
ember-source: ">= 4.12.0"
checksum: a83a0328210d7ec6e12995ded1b8ace876245fcb025ba380cb034770bc717628d6ba142c947087c6500b9bf6993225b32d57f1e1371e009e4396a5fd3c65b190
languageName: node
linkType: hard
"ember-svg-jar@npm:2.4.0":
version: 2.4.0
resolution: "ember-svg-jar@npm:2.4.0"
@@ -19150,6 +19278,13 @@ __metadata:
languageName: node
linkType: hard
"internmap@npm:1 - 2":
version: 2.0.3
resolution: "internmap@npm:2.0.3"
checksum: 7ca41ec6aba8f0072fc32fa8a023450a9f44503e2d8e403583c55714b25efd6390c38a87161ec456bf42d7bc83aab62eb28f5aef34876b1ac4e60693d5e1d241
languageName: node
linkType: hard
"invariant@npm:^2.2.2":
version: 2.2.4
resolution: "invariant@npm:2.2.4"
@@ -27845,6 +27980,7 @@ __metadata:
"@hashicorp/design-system-components": ^3.4.0
"@hashicorp/ember-flight-icons": ^4.0.5
"@icholy/duration": ^5.1.0
"@lineal-viz/lineal": ^0.5.1
"@tsconfig/ember": ^1.0.1
"@types/ember": ^4.0.2
"@types/ember-data": ^4.4.6
@@ -27939,6 +28075,7 @@ __metadata:
ember-service-worker: "meirish/ember-service-worker#configurable-scope"
ember-sinon: ^4.0.0
ember-source: ~4.12.0
ember-style-modifier: ^4.1.0
ember-svg-jar: 2.4.0
ember-template-lint: 5.7.2
ember-template-lint-plugin-prettier: 4.0.0