From c06d18940d5364e38090dbb6518e3fd79274063c Mon Sep 17 00:00:00 2001 From: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com> Date: Fri, 15 Dec 2023 16:01:35 -0800 Subject: [PATCH] Icu 11916 add flyout for session in prgoress field (#2042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 sessions flyout for targets list view ✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11916 --------- Co-authored-by: Zhihe Li --- addons/core/translations/form/en-us.yaml | 4 +- addons/core/translations/resources/en-us.yaml | 1 + .../scopes/scope/projects/targets/index.js | 52 +++++- .../scopes/scope/projects/targets/index.js | 32 ++-- ui/desktop/app/styles/app.scss | 8 + .../scopes/scope/projects/sessions/index.hbs | 2 +- .../scopes/scope/projects/targets/index.hbs | 112 ++++++++++--- .../acceptance/projects/targets/index-test.js | 148 ++++++++++++++++-- 8 files changed, 294 insertions(+), 65 deletions(-) diff --git a/addons/core/translations/form/en-us.yaml b/addons/core/translations/form/en-us.yaml index ada4964e1..2a236e4c9 100644 --- a/addons/core/translations/form/en-us.yaml +++ b/addons/core/translations/form/en-us.yaml @@ -68,8 +68,8 @@ grant_scope: cluster-url: label: Cluster URL description: The Boundary URL to which to connect (e.g. protocol://host:port) -proxy: - label: Proxy +proxy_url: + label: Proxy URL issuer: label: Issuer help: The issuer URL. Boundary expects only the schema, host, and port and will strip off ".well-known/openid-configuration" if present. This will be used for configuration discovery as well as for validation of the "iss" claim. diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index f17a9fd3d..dd6af173d 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -570,6 +570,7 @@ target: titles: new: New Target active-sessions: Active sessions + sessions-flyout: Active sessions for {targetDisplayName} actions: create: New Target delete: Delete Target diff --git a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js index d7353eeae..27acef06a 100644 --- a/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js +++ b/ui/desktop/app/controllers/scopes/scope/projects/targets/index.js @@ -9,6 +9,8 @@ import { action } from '@ember/object'; import { loading } from 'ember-loading'; import { tracked } from '@glimmer/tracking'; import { debounce } from 'core/decorators/debounce'; +import { notifySuccess, notifyError } from 'core/decorators/notify'; +import orderBy from 'lodash/orderBy'; export default class ScopesScopeProjectsTargetsIndexController extends Controller { // =services @@ -18,6 +20,7 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle @service router; @service session; @service store; + @service can; // =attributes @@ -27,7 +30,7 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle @tracked scopes = []; @tracked page = 1; @tracked pageSize = 10; - @tracked sessionsFlyoutActive = false; + @tracked selectedTarget; // =methods @@ -61,6 +64,17 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle ); } + /** + * Returns active and pending sessions associated with a target. + * @returns {object} + */ + get availableSessions() { + const sessions = this.selectedTarget.sessions.filter( + (session) => session.isAvailable, + ); + return orderBy(sessions, 'created_time', 'desc'); + } + /** * Quick connect method used to call main connect method and handle * connection errors unique to this route @@ -160,11 +174,6 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle this.page = 1; } - @action - toggleSessionsFlyout() { - this.sessionsFlyoutActive = !this.sessionsFlyoutActive; - } - /** * Sets the scopes query param to value of selectedScopes * to trigger a query and closes the dropdown @@ -174,4 +183,35 @@ export default class ScopesScopeProjectsTargetsIndexController extends Controlle applyFilter(selectedItems) { this.scopes = [...selectedItems]; } + + /** + * Toggle the sessions flyout and initialize variable to store selected target + * @param {object} selectedTarget + */ + @action + selectTarget(selectedTarget) { + this.selectedTarget = selectedTarget; + } + + /** + * Cancels the specified session and notifies user of success or error. + * @param {SessionModel} + */ + @action + @loading + @notifyError(({ message }) => message, { catch: true }) + @notifySuccess('notifications.canceled-success') + async cancelSession(session) { + let updatedSession = session; + // fetch session from API to verify we have most up to date record + if (this.can.can('read session', session)) { + updatedSession = await this.store.findRecord('session', session.id, { + reload: true, + }); + } + + await updatedSession.cancelSession(); + await this.ipc.invoke('stop', { session_id: session.id }); + this.router.refresh(); + } } diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets/index.js b/ui/desktop/app/routes/scopes/scope/projects/targets/index.js index fbd236b6c..40405e630 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets/index.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets/index.js @@ -5,7 +5,6 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; -import orderBy from 'lodash/orderBy'; import { STATUS_SESSION_ACTIVE, STATUS_SESSION_PENDING, @@ -81,23 +80,20 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route { this.can.can('connect target', target), ); - // Retrieve all the 'active' and 'pending' sessions associated with each target. - let targetsWithSessions = []; - for (const target of targets) { - let sessions = await this.store.query('session', { - query: { - filters: { - target_id: { equals: target.id }, - status: [ - { equals: STATUS_SESSION_ACTIVE }, - { equals: STATUS_SESSION_PENDING }, - ], - }, + // Retrieve all sessions so that the session and activeSessions getters + // in the target model always retrieve the most up-to-date sessions. + await this.store.query('session', { + query: { + filters: { + user_id: { equals: this.session.data.authenticated.user_id }, + status: [ + { equals: STATUS_SESSION_ACTIVE }, + { equals: STATUS_SESSION_PENDING }, + ], }, - }); - sessions = orderBy(sessions, 'created_time', 'desc'); - targetsWithSessions.push({ target, sessions }); - } + }, + force_refresh: true, + }); // Query all targets for defining filtering values if entering route for first time if (from !== 'scopes.scope.projects.targets.index') { @@ -111,7 +107,7 @@ export default class ScopesScopeProjectsTargetsIndexRoute extends Route { } return { - targets: targetsWithSessions, + targets, projects, allTargets: this.allTargets, totalItems, diff --git a/ui/desktop/app/styles/app.scss b/ui/desktop/app/styles/app.scss index c65465b48..5bf35697a 100644 --- a/ui/desktop/app/styles/app.scss +++ b/ui/desktop/app/styles/app.scss @@ -703,3 +703,11 @@ $chevron-down: url("data:image/svg+xml;utf-8, - {{#if (can 'read model' B.data.target)}} + {{#if (can 'read model' B.data)}} - {{B.data.target.displayName}} + {{B.data.displayName}} {{else}} - {{B.data.target.displayName}} + {{B.data.displayName}} {{/if}} - {{#if B.data.target.description}} -

{{B.data.target.description}}

+ {{#if B.data.description}} +

{{B.data.description}}

{{/if}}
- {{B.data.target.id}} + {{B.data.id}} - {{#if B.data.sessions.length}} + {{#if B.data.isActive}} {{/if}} - {{#if B.data.target.type}} + {{#if B.data.type}} {{/if}}
- {{B.data.target.project.id}} + {{B.data.project.id}}
{{#if B.data.project.name}} @@ -140,22 +139,22 @@ {{/if}}
- {{#if (can 'connect target' B.data.target)}} - {{#if (can 'read target' B.data.target)}} + {{#if (can 'connect target' B.data)}} + {{#if (can 'read target' B.data)}} {{else}} {{/if}} {{/if}} @@ -190,6 +189,73 @@ {{/if}} + {{#if (and this.selectedTarget this.selectedTarget.isActive)}} + + + {{t + 'resources.target.titles.sessions-flyout' + targetDisplayName=this.selectedTarget.displayName + }} + + + + <:body as |B|> + + + + + {{B.data.id}} + + + + + + + + {{B.data.proxy}} + + + {{#if (can 'cancel session' B.data)}} + + {{/if}} + + + + + + + {{/if}} \ No newline at end of file diff --git a/ui/desktop/tests/acceptance/projects/targets/index-test.js b/ui/desktop/tests/acceptance/projects/targets/index-test.js index f699beb4b..2f97f428d 100644 --- a/ui/desktop/tests/acceptance/projects/targets/index-test.js +++ b/ui/desktop/tests/acceptance/projects/targets/index-test.js @@ -37,6 +37,11 @@ module('Acceptance | projects | targets | index', function (hooks) { const ROSE_DIALOG_RETRY_BUTTON = '.rose-dialog footer .rose-button-primary'; const ROSE_DIALOG_CANCEL_BUTTON = '.rose-dialog footer .rose-button-secondary'; + const SESSIONS_FLYOUT = '[data-test-targets-sessions-flyout]'; + const SESSIONS_FLYOUT_TITLE = + '[data-test-targets-sessions-flyout] .hds-flyout__title'; + const SESSIONS_FLYOUT_CLOSE_BUTTON = + '[data-test-targets-sessions-flyout] .hds-flyout__dismiss'; const instances = { scopes: { @@ -65,7 +70,7 @@ module('Acceptance | projects | targets | index', function (hooks) { projects: null, targets: null, target: null, - sessions: null, + session: null, }; const setDefaultClusterUrl = (test) => { @@ -110,6 +115,7 @@ module('Acceptance | projects | targets | index', function (hooks) { { scope: instances.scopes.project, status: 'active', + target_id: instances.target.id, }, 'withAssociations', ); @@ -122,6 +128,7 @@ module('Acceptance | projects | targets | index', function (hooks) { urls.projects = `${urls.scopes.org}/projects`; urls.targets = `${urls.projects}/targets`; urls.target = `${urls.targets}/${instances.target.id}`; + urls.session = `${urls.projects}/sessions/${instances.session.id}`; // Generate resource counter getTargetCount = () => this.server.schema.targets.all().models.length; @@ -131,12 +138,11 @@ module('Acceptance | projects | targets | index', function (hooks) { setDefaultClusterUrl(this); this.ipcStub.withArgs('isClientDaemonRunning').returns(true); - this.stubClientDaemonSearch('targets', 'targets', 'sessions'); + this.stubClientDaemonSearch('targets', 'sessions', 'targets'); }); test('visiting index while unauthenticated redirects to global authenticate method', async function (assert) { invalidateSession(); - assert.expect(2); await visit(urls.targets); await a11yAudit(); @@ -145,7 +151,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('visiting targets index', async function (assert) { - assert.expect(2); const targetsCount = getTargetCount(); await visit(urls.projects); @@ -157,7 +162,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('visiting a target', async function (assert) { - assert.expect(2); await visit(urls.projects); await click(`[href="${urls.targets}"]`); @@ -170,7 +174,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('visiting targets list view with no targets', async function (assert) { - assert.expect(1); this.server.db.targets.remove(); this.stubClientDaemonSearch('targets', 'targets'); @@ -182,7 +185,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('user cannot navigate to a target without proper authorization', async function (assert) { - assert.expect(1); instances.target.authorized_actions = instances.target.authorized_actions.filter((item) => item !== 'read'); this.stubClientDaemonSearch('targets', 'targets'); @@ -197,7 +199,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('user can connect to a target with proper authorization', async function (assert) { - assert.expect(2); await visit(urls.projects); await click(`[href="${urls.targets}"]`); @@ -211,7 +212,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('user cannot connect to a target without proper authorization', async function (assert) { - assert.expect(2); instances.target.authorized_actions = instances.target.authorized_actions.filter( (item) => item !== 'authorize-session', @@ -231,13 +231,13 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('user is redirected to target details page when unable to connect from list view if they have read and authorize-session permissions', async function (assert) { - assert.expect(3); this.ipcStub.withArgs('cliExists').returns(true); this.ipcStub.withArgs('connect').rejects(); const confirmService = this.owner.lookup('service:confirm'); confirmService.enabled = true; + await visit(urls.projects); - await visit(urls.targets); + await click(`[href="${urls.targets}"]`); await click(`[data-test-targets-connect-button="${instances.target.id}"]`); @@ -247,7 +247,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('user can connect without target read permissions', async function (assert) { - assert.expect(2); instances.target.authorized_actions = instances.target.authorized_actions.filter((item) => item !== 'read'); this.stubClientDaemonSearch('targets'); @@ -258,8 +257,9 @@ module('Acceptance | projects | targets | index', function (hooks) { port: 'p_123', protocol: 'tcp', }); + await visit(urls.projects); - await visit(urls.targets); + await click(`[href="${urls.targets}"]`); await click(`[data-test-targets-connect-button="${instances.target.id}"]`); @@ -271,7 +271,6 @@ module('Acceptance | projects | targets | index', function (hooks) { }); test('user can retry connect without target read permissions', async function (assert) { - assert.expect(5); instances.target.authorized_actions = instances.target.authorized_actions.filter((item) => item !== 'read'); this.ipcStub.withArgs('cliExists').returns(true); @@ -279,8 +278,9 @@ module('Acceptance | projects | targets | index', function (hooks) { this.stubClientDaemonSearch('targets', 'targets'); const confirmService = this.owner.lookup('service:confirm'); confirmService.enabled = true; + await visit(urls.projects); - await visit(urls.targets); + await click(`[href="${urls.targets}"]`); await click(`[data-test-targets-connect-button="${instances.target.id}"]`); @@ -290,4 +290,122 @@ module('Acceptance | projects | targets | index', function (hooks) { assert.dom(ROSE_DIALOG_RETRY_BUTTON).hasText('Retry'); assert.dom(ROSE_DIALOG_CANCEL_BUTTON).hasText('Cancel'); }); + + test('user can open sessions flyout when target has active or pending sessions', async function (assert) { + await visit(urls.projects); + + await click(`[href="${urls.targets}"]`); + + assert + .dom( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ) + .exists(); + + await click( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ); + + assert.dom(SESSIONS_FLYOUT).exists(); + assert.dom(SESSIONS_FLYOUT_TITLE).includesText(`Active sessions for`); + + await click(SESSIONS_FLYOUT_CLOSE_BUTTON); + + assert.dom(SESSIONS_FLYOUT).doesNotExist(); + }); + + test('user can cancel a session from inside target sessions flyout', async function (assert) { + this.stubClientDaemonSearch( + 'targets', + 'sessions', + 'targets', + 'targets', + '', + 'targets', + ); + await visit(urls.projects); + + await click(`[href="${urls.targets}"]`); + await click( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ); + + assert.dom(SESSIONS_FLYOUT).exists(); + assert + .dom(`[data-test-session-flyout-cancel-button="${instances.session.id}"]`) + .exists(); + + await click( + `[data-test-session-flyout-cancel-button="${instances.session.id}"]`, + ); + + assert.dom(SESSIONS_FLYOUT).doesNotExist(); + assert + .dom( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ) + .doesNotExist(); + assert.strictEqual(currentURL(), urls.targets); + }); + + test('user cannot cancel a session from inside target sessions flyout without permissions', async function (assert) { + instances.session.authorized_actions = + instances.session.authorized_actions.filter((item) => item !== 'cancel'); + this.stubClientDaemonSearch('targets', 'sessions', 'targets'); + await visit(urls.projects); + + await click(`[href="${urls.targets}"]`); + await click( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ); + + assert.dom(SESSIONS_FLYOUT).exists(); + assert.strictEqual(currentURL(), urls.targets); + assert + .dom(`[data-test-session-flyout-cancel-button="${instances.target.id}"]`) + .doesNotExist(); + assert + .dom(`[data-test-targets-session-detail-link="${instances.session.id}"]`) + .exists(); + }); + + test('user can navigate to session details from sessions table in flyout', async function (assert) { + await visit(urls.projects); + + await click(`[href="${urls.targets}"]`); + await click( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ); + + assert.dom(SESSIONS_FLYOUT).exists(); + + await click( + `[data-test-targets-session-detail-link="${instances.session.id}"]`, + ); + + assert.strictEqual(currentURL(), urls.session); + }); + + test('user can navigate to session details from sessions table in flyout without permissions', async function (assert) { + instances.session.authorized_actions = + instances.session.authorized_actions.filter((item) => item !== 'read'); + this.stubClientDaemonSearch('targets', 'sessions', 'targets'); + await visit(urls.projects); + + await click(`[href="${urls.targets}"]`); + await click( + `[data-test-targets-sessions-flyout-button="${instances.target.id}"]`, + ); + + assert.dom(SESSIONS_FLYOUT).exists(); + assert + .dom(`[data-test-targets-session-detail-link="${instances.session.id}"]`) + .exists(); + + await click( + `[data-test-targets-session-detail-link="${instances.session.id}"]`, + ); + + assert.strictEqual(currentURL(), urls.session); + }); });