Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
29 changes: 29 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,32 @@ export default class SyncAssociationSerializer extends ApplicationSerializer {
}
return payload;
}

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

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 (!lastSync || updated > lastSync) {
lastSync = updated;
}
}

return {
icon: findDestination(store_type).icon,
name: store_name,
type: store_type,
associationCount: Object.entries(associated_secrets).length,
status: unsynced.length ? `${unsynced.length} Unsynced` : 'All synced',
Copy link
Copy Markdown
Contributor

@hellobontempo hellobontempo Dec 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also need to account for if there are no secrets at all - this current logic means that a destination with 0 secrets returns an All synced badge

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! I think hiding the badge is probably appropriate for that state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a destination with no associations to the mirage scenario after making the changes so that it's represented.

image

lastSync,
};
}
}
7 changes: 5 additions & 2 deletions ui/app/serializers/sync/destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,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
118 changes: 115 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,119 @@
</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>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>
<Hds::Badge
@text={{data.status}}
@color={{if (eq data.status "All synced") "success"}}
data-test-overview-table-badge={{index}}
/>
</B.Td>
<B.Td @align="right" data-test-overview-table-total={{index}}>{{data.associationCount}}</B.Td>
<B.Td data-test-overview-table-updated={{index}}>{{date-format data.lastSync "MMMM do yyyy, h:mm:ss a"}}</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