Skip to content

Commit

Permalink
Sync Overview (#24340)
Browse files Browse the repository at this point in the history
* updates landing-cta image to png with matching height

* adds ts definitons for sync adapters

* updates sync adapters and serializers to add methods for fetching overview data

* adds sync associations list handler to mirage and seeds more associations in scenario

* adds table and totals cards to sync overview page

* adds sync overview page component tests

* fixes tests

* changes lastSync key to lastUpdated for sync fetchByDestinations response

* adds emdash as placeholder for lastUpdated null value in secrets by destination table

* updates to handle 0 associations state for destination in overview table
  • Loading branch information
zofskeez authored Dec 5, 2023
1 parent 2016eb8 commit e1d8221
Show file tree
Hide file tree
Showing 24 changed files with 657 additions and 33 deletions.
29 changes: 27 additions & 2 deletions ui/app/adapters/sync/association.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@

import ApplicationAdapter from 'vault/adapters/application';
import { assert } from '@ember/debug';
import { all } from 'rsvp';

export default class SyncAssociationAdapter extends ApplicationAdapter {
namespace = 'v1/sys/sync';

buildURL(modelName, id, snapshot, requestType, query) {
buildURL(modelName, id, snapshot, requestType, query = {}) {
const { destinationType, destinationName } = snapshot ? snapshot.attributes() : query;
if (!destinationType || !destinationName) {
return `${super.buildURL()}/associations`;
}
const { action } = snapshot?.adapterOptions || {};
const uri = action ? `/${action}` : '';
return `${super.buildURL()}/destinations/${destinationType}/${destinationName}/associations${uri}`;
Expand All @@ -22,9 +26,30 @@ export default class SyncAssociationAdapter extends ApplicationAdapter {
return this.ajax(url, 'GET');
}

// typically associations are queried for a specific destination which is what the standard query method does
// in specific cases we can query all associations to access total_associations and total_secrets values
queryAll() {
return this.query(this.store, { modelName: 'sync/association' }).then((response) => {
const { total_associations, total_secrets } = response.data;
return { total_associations, total_secrets };
});
}

// fetch associations for many destinations
// returns aggregated association information for each destination
// information includes total associations, total unsynced and most recent updated datetime
async fetchByDestinations(destinations) {
const promises = destinations.map(({ name: destinationName, type: destinationType }) => {
return this.query(this.store, { modelName: 'sync/association' }, { destinationName, destinationType });
});
const queryResponses = await all(promises);
const serializer = this.store.serializerFor('sync/association');
return queryResponses.map((response) => serializer.normalizeFetchByDestinations(response));
}

// array of association data for each destination a secret is synced to
fetchSyncStatus({ mount, secretName }) {
const url = `${super.buildURL()}/associations/${mount}/${secretName}`;
const url = `${this.buildURL()}/${mount}/${secretName}`;
return this.ajax(url, 'GET').then((resp) => {
const { associated_destinations } = resp.data;
const syncData = [];
Expand Down
8 changes: 8 additions & 0 deletions ui/app/adapters/sync/destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@ export default class SyncDestinationAdapter extends ApplicationAdapter {
query(store, { modelName }) {
return this.ajax(this.buildURL(modelName), 'GET', { data: { list: true } });
}

// return normalized query response
// useful for fetching data directly without loading models into store
async normalizedQuery() {
const queryResponse = await this.query(this.store, { modelName: 'sync/destination' });
const serializer = this.store.serializerFor('sync/destination');
return serializer.extractLazyPaginatedData(queryResponse);
}
}
1 change: 1 addition & 0 deletions ui/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,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',
},
},
},
Expand Down
30 changes: 30 additions & 0 deletions ui/app/serializers/sync/association.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import ApplicationSerializer from 'vault/serializers/application';
import { findDestination } from 'core/helpers/sync-destinations';

export default class SyncAssociationSerializer extends ApplicationSerializer {
attrs = {
Expand Down Expand Up @@ -31,4 +32,33 @@ export default class SyncAssociationSerializer extends ApplicationSerializer {
}
return payload;
}

normalizeFetchByDestinations(payload) {
const { store_name, store_type, associated_secrets } = payload.data;
const unsynced = [];
let lastUpdated;

for (const key in associated_secrets) {
const association = associated_secrets[key];
// for display purposes, any status other than SYNCED is considered unsynced
if (association.sync_status !== 'SYNCED') {
unsynced.push(association.sync_status);
}
// use the most recent updated_at value as the last synced date
const updated = new Date(association.updated_at);
if (!lastUpdated || updated > lastUpdated) {
lastUpdated = updated;
}
}

const associationCount = Object.entries(associated_secrets).length;
return {
icon: findDestination(store_type).icon,
name: store_name,
type: store_type,
associationCount,
status: associationCount ? (unsynced.length ? `${unsynced.length} Unsynced` : 'All synced') : null,
lastUpdated,
};
}
}
7 changes: 5 additions & 2 deletions ui/app/serializers/sync/destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ export default class SyncDestinationSerializer extends ApplicationSerializer {
return transformedPayload;
}

// uses name for id and spreads connection_details object into data
_normalizePayload(payload, requestType) {
if (requestType !== 'query' && payload?.data) {
if (payload?.data) {
if (requestType === 'query') {
return this.extractLazyPaginatedData(payload);
}
// uses name for id and spreads connection_details object into data
const { data } = payload;
const connection_details = payload.data.connection_details || {};
data.id = data.name;
Expand Down
1 change: 1 addition & 0 deletions ui/lib/core/addon/components/overview-card.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
@iconPosition="trailing"
@text={{@actionText}}
@route={{@actionTo}}
@isRouteExternal={{@actionExternal}}
@query={{@actionQuery}}
data-test-action-text={{@actionText}}
/>
Expand Down
6 changes: 3 additions & 3 deletions ui/lib/sync/addon/components/secrets/landing-cta.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@
{{/if}}
</div>

<div class="is-flex-row">
<div class="is-flex-row has-gap-l has-bottom-margin-m">
<div>
<img src={{img-path "~/sync-landing-1.png"}} alt="Secrets sync destinations diagram" aria-describedby="sync-step-1" />
<p id="sync-step-1" class="has-top-margin-l">
<p id="sync-step-1" class="has-top-margin-m">
<b>Step 1:</b>
Create a destination, and set up the connection details to allow Vault access.
</p>
</div>
<div class="has-left-margin-l">
<div>
<img src={{img-path "~/sync-landing-2.png"}} alt="Syncing secrets diagram" aria-describedby="sync-step-2" />
<p id="sync-step-2" class="has-top-margin-m">
<b>Step 2:</b>
Expand Down
128 changes: 125 additions & 3 deletions ui/lib/sync/addon/components/secrets/page/overview.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@

<SyncHeader @title="Secrets sync">
<:actions>
{{#unless @shouldRenderOverview}}
{{#unless @destinations}}
<Hds::Button @text="Create first destination" @route="secrets.destinations.create" data-test-cta-button />
{{/unless}}
</:actions>
</SyncHeader>

{{#if @shouldRenderOverview}}
{{#if @destinations}}
<div class="tabs-container box is-bottomless is-marginless is-paddingless">
<nav class="tabs" aria-label="destination tabs">
<ul>
Expand All @@ -20,7 +20,129 @@
</ul>
</nav>
</div>
{{! overview cards here }}
<Toolbar>
<ToolbarActions>
<ToolbarLink @route="secrets.destinations.create" @type="add" data-test-create-destination>
Create new destination
</ToolbarLink>
</ToolbarActions>
</Toolbar>

<OverviewCard @cardTitle="Secrets by destination" class="has-top-margin-l">
{{#if this.fetchAssociationsForDestinations.isRunning}}
<div data-test-sync-overview-loading>
<Icon @name="loading" @size="24" />
Loading destinations...
</div>
{{else if (not this.destinationMetrics)}}
<EmptyState
@title="Error fetching information"
@message="Ensure that the policy has access to read sync associations."
>
<DocLink @path="/vault/api-docs/system/secrets-sync#read-associations">
API reference
</DocLink>
</EmptyState>
{{else}}
<Hds::Table>
<:head as |H|>
<H.Tr>
<H.Th>Sync destination</H.Th>
<H.Th @align="right"># of secrets</H.Th>
<H.Th @align="right">Last updated</H.Th>
<H.Th @align="right">Actions</H.Th>
</H.Tr>
</:head>
<:body as |B|>
{{#each this.destinationMetrics as |data index|}}
<B.Tr data-test-overview-table-row>
<B.Td>
<Icon @name={{data.icon}} data-test-overview-table-icon={{index}} />
<span data-test-overview-table-name={{index}}>{{data.name}}</span>
{{#if data.status}}
<Hds::Badge
@text={{data.status}}
@color={{if (eq data.status "All synced") "success"}}
data-test-overview-table-badge={{index}}
/>
{{/if}}
</B.Td>
<B.Td @align="right" data-test-overview-table-total={{index}}>
{{data.associationCount}}
</B.Td>
<B.Td @align="right" data-test-overview-table-updated={{index}}>
{{#if data.lastUpdated}}
{{date-format data.lastUpdated "MMMM do yyyy, h:mm:ss a"}}
{{else}}
&mdash;
{{/if}}
</B.Td>
<B.Td @align="right">
<Hds::Dropdown @isInline={{true}} as |dd|>
<dd.ToggleIcon
@icon="more-horizontal"
@text="Actions"
@hasChevron={{false}}
@size="small"
data-test-overview-table-action-toggle={{index}}
/>
<dd.Interactive
@route="secrets.destinations.destination.sync"
@model={{data}}
@text="Sync new secrets"
data-test-overview-table-action="sync"
/>
<dd.Interactive
@route="secrets.destinations.destination.secrets"
@model={{data}}
@text="Details"
data-test-overview-table-action="details"
/>
</Hds::Dropdown>
</B.Td>
</B.Tr>
{{/each}}
</:body>
</Hds::Table>

<Hds::Pagination::Numbered
@totalItems={{@destinations.length}}
@currentPage={{this.page}}
@currentPageSize={{this.pageSize}}
@showSizeSelector={{false}}
@onPageChange={{perform this.fetchAssociationsForDestinations}}
/>
{{/if}}
</OverviewCard>

<div class="is-grid grid-2-columns grid-gap-2 has-top-margin-l has-bottom-margin-l">
<OverviewCard
@cardTitle="Total destinations"
@subText="The total number of connected destinations"
@actionText="Create new"
@actionTo="secrets.destinations.create"
class="is-flex-half"
>
<h2 class="title is-2 has-font-weight-normal has-top-margin-m" data-test-overview-card-content="Total destinations">
{{or @destinations.length "None"}}
</h2>
</OverviewCard>
<OverviewCard
@cardTitle="Total sync associations"
@subText="Total sync associations that count towards client count"
@actionText="View billing"
@actionTo="clientCountDashboard"
@actionExternal={{true}}
class="is-flex-half"
>
<h2
class="title is-2 has-font-weight-normal has-top-margin-m"
data-test-overview-card-content="Total sync associations"
>
{{or @totalAssociations "None"}}
</h2>
</OverviewCard>
</div>
{{else}}
<Secrets::LandingCta />
{{/if}}
52 changes: 52 additions & 0 deletions ui/lib/sync/addon/components/secrets/page/overview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import Ember from 'ember';

import type RouterService from '@ember/routing/router-service';
import type StoreService from 'vault/services/store';
import type FlashMessageService from 'vault/services/flash-messages';
import type { SyncDestinationAssociationMetrics } from 'vault/vault/adapters/sync/association';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';

interface Args {
destinations: Array<SyncDestinationModel>;
totalAssociations: number;
}

export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flashMessages: FlashMessageService;

@tracked destinationMetrics: SyncDestinationAssociationMetrics[] = [];
@tracked page = 1;

pageSize = Ember.testing ? 3 : 5; // lower in tests to test pagination without seeding more data

constructor(owner: unknown, args: Args) {
super(owner, args);
if (this.args.destinations.length) {
this.fetchAssociationsForDestinations.perform();
}
}

fetchAssociationsForDestinations = task(this, {}, async (page = 1) => {
try {
const total = page * this.pageSize;
const paginatedDestinations = this.args.destinations.slice(total - this.pageSize, total);
this.destinationMetrics = await this.store
.adapterFor('sync/association')
.fetchByDestinations(paginatedDestinations);
this.page = page;
} catch (error) {
this.destinationMetrics = [];
}
});
}
2 changes: 1 addition & 1 deletion ui/lib/sync/addon/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class SyncEngine extends Engine {
Resolver = Resolver;
dependencies = {
services: ['flash-messages', 'router', 'store', 'version'],
externalRoutes: ['kvSecretDetails'],
externalRoutes: ['kvSecretDetails', 'clientCountDashboard'],
};
}

Expand Down
9 changes: 8 additions & 1 deletion ui/lib/sync/addon/routes/secrets/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@

import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';

import type StoreService from 'vault/services/store';

export default class SyncSecretsOverviewRoute extends Route {
@service declare readonly store: StoreService;

async model() {
return this.store.query('sync/destination', {}).catch(() => []);
return hash({
destinations: this.store.query('sync/destination', {}).catch(() => []),
associations: this.store
.adapterFor('sync/association')
.queryAll()
.catch(() => []),
});
}
}
5 changes: 4 additions & 1 deletion ui/lib/sync/addon/templates/secrets/overview.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}

<Secrets::Page::Overview @shouldRenderOverview={{this.model.length}} />
<Secrets::Page::Overview
@destinations={{this.model.destinations}}
@totalAssociations={{this.model.associations.total_associations}}
/>
Loading

0 comments on commit e1d8221

Please sign in to comment.