Icu 11916 add flyout for session in prgoress field (#2042)

* feat: 🎸 sessions flyout for targets list view

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-11916

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
This commit is contained in:
Lisbet AlvarezandZhihe Li authored and Zhihe Li committed 2024-01-16 10:17:46 -05:00
1 parent 1122735b87
commit c06d18940d
8 files changed
+294 -65

No files matched your search

+2 -2
View File
@@ -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.
@@ -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
@@ -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();
}
}
@@ -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,
+8
View File
@@ -703,3 +703,11 @@ $chevron-down: url("data:image/svg+xml;utf-8,<svg viewBox='0 0 24 24' fill='%231
margin-bottom: 1rem;
}
}
// Flyout with table
.flyout-with-table {
.copyable-content a,
button {
color: var(--token-color-foreground-primary);
}
}
@@ -120,7 +120,7 @@
@columns={{array
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.target.title'))
(hash label=(t 'form.proxy.label'))
(hash label=(t 'form.proxy_url.label'))
(hash label=(t 'form.started.label'))
(hash label=(t 'form.status.label'))
(hash label='')
@@ -80,59 +80,58 @@
<B.Tr>
<B.Td>
<span class='hds-font-weight-semibold'>
{{#if (can 'read model' B.data.target)}}
{{#if (can 'read model' B.data)}}
<LinkTo
data-test-visit-target={{B.data.target.id}}
data-test-visit-target={{B.data.id}}
@route='scopes.scope.projects.targets.target'
@model={{B.data.target.id}}
@model={{B.data.id}}
@query={{hash isConnecting=false}}
>
{{B.data.target.displayName}}
{{B.data.displayName}}
</LinkTo>
{{else}}
{{B.data.target.displayName}}
{{B.data.displayName}}
{{/if}}
{{#if B.data.target.description}}
<p>{{B.data.target.description}}</p>
{{#if B.data.description}}
<p>{{B.data.description}}</p>
{{/if}}
</span>
</B.Td>
<B.Td>
<Copyable
@text={{B.data.target.id}}
@text={{B.data.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<code>{{B.data.target.id}}</code>
<Hds::Text::Code>{{B.data.id}}</Hds::Text::Code>
</Copyable>
</B.Td>
<B.Td>
{{#if B.data.sessions.length}}
{{#if B.data.isActive}}
<Hds::Button
data-test-targets-sessions-flyout-button={{B.data.id}}
@text={{t 'actions.yes'}}
@color='tertiary'
@icon='info'
{{on 'click' this.toggleSessionsFlyout}}
{{on 'click' (fn this.selectTarget B.data)}}
/>
{{/if}}
</B.Td>
<B.Td>
{{#if B.data.target.type}}
{{#if B.data.type}}
<Hds::Badge
@text={{t
(concat 'resources.target.types.' B.data.target.type)
}}
@text={{t (concat 'resources.target.types.' B.data.type)}}
/>
{{/if}}
</B.Td>
<B.Td>
<div>
<Copyable
@text={{B.data.target.project.id}}
@text={{B.data.project.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<code>{{B.data.target.project.id}}</code>
<Hds::Text::Code>{{B.data.project.id}}</Hds::Text::Code>
</Copyable>
</div>
{{#if B.data.project.name}}
@@ -140,22 +139,22 @@
{{/if}}
</B.Td>
<B.Td>
{{#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)}}
<Hds::Button
data-test-targets-connect-button={{B.data.target.id}}
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
@route='scopes.scope.projects.targets.target'
@model={{B.data.target.id}}
@model={{B.data.id}}
@query={{hash isConnecting=true}}
/>
{{else}}
<Hds::Button
data-test-targets-connect-button={{B.data.target.id}}
data-test-targets-connect-button={{B.data.id}}
@text={{t 'resources.session.actions.connect'}}
@color='secondary'
{{on 'click' (fn this.quickConnect B.data.target)}}
{{on 'click' (fn this.quickConnect B.data)}}
/>
{{/if}}
{{/if}}
@@ -190,6 +189,73 @@
<A.Body @text={{t 'resources.target.messages.none.description'}} />
</Hds::ApplicationState>
{{/if}}
{{#if (and this.selectedTarget this.selectedTarget.isActive)}}
<Hds::Flyout
@onClose={{fn this.selectTarget null}}
class='flyout-with-table'
data-test-targets-sessions-flyout
as |M|
>
<M.Header>
{{t
'resources.target.titles.sessions-flyout'
targetDisplayName=this.selectedTarget.displayName
}}
</M.Header>
<M.Body>
<Hds::Table
@model={{this.availableSessions}}
@columns={{array
(hash label=(t 'form.id.label'))
(hash label=(t 'form.started.label'))
(hash label=(t 'form.proxy_url.label'))
(hash label='')
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
<Copyable
@text={{B.data.id}}
@buttonText={{t 'actions.copy-to-clipboard'}}
@acknowledgeText={{t 'states.copied'}}
>
<LinkTo
@route='scopes.scope.projects.sessions.session.index'
@model={{B.data.id}}
data-test-targets-session-detail-link={{B.data.id}}
>
<Hds::Text::Code>{{B.data.id}}</Hds::Text::Code>
</LinkTo>
</Copyable>
</B.Td>
<B.Td>
<time datetime={{format-date-iso B.data.created_time}}>
{{format-date-iso-human B.data.created_time}}
</time>
</B.Td>
<B.Td>
{{B.data.proxy}}
</B.Td>
<B.Td>
{{#if (can 'cancel session' B.data)}}
<Hds::Button
data-test-session-flyout-cancel-button={{B.data.id}}
@text={{t 'resources.session.actions.end'}}
@icon='x'
@isIconOnly={{true}}
@color='secondary'
{{on 'click' (fn this.cancelSession B.data)}}
/>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</M.Body>
</Hds::Flyout>
{{/if}}
</page.body>
</Rose::Layout::Page>
@@ -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);
});
});