Icu 12587 add filtering pagination support for sessions (#2194)

* feat: 🎸 add pagination and filtering to sessions page

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

* refactor: 💡 fix handling of null values in indexed-db-query

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

* refactor: 💡 add method signatures where missing

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

* refactor: 💡 address PR feedback

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

* fix: 🐛 add override for table header color when sortable

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

* refactor: 💡 remove poller and add manual refresh action

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

* refactor: 💡 remove force_refresh from sessions route queries

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

* refactor: 💡 add user getter to session model

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

* refactor: 💡 remove logicalOperator value from queries

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

* refactor: 💡 move action to controller from router

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

* refactor: 💡 remove cleanValues from indexed-db-query

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

* refactor: 💡 remove potential null values for user/target ids

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

* refactor: 💡 add async/await back to cancelSession

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

* refactor: 💡 update sessions route

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

* refactor: 💡 use translation string for Sessions

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

* test: 💍 fix controller test

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

* refactor: 💡 add scope_id to filters for query

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

* refactor: 💡 remove scope_id and recursive in sessions query

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

* refactor: 💡 how user query to use global and org scope

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-12587
This commit is contained in:
Cameron Perera authored and Zhihe Li committed 2024-04-12 14:55:33 -04:00
1 parent be96daa499
commit 6e75439652
9 files changed
+749 -250

No files matched your search

+11
View File
@@ -151,6 +151,17 @@ export default class SessionModel extends GeneratedSessionModel {
return this.store.peekRecord('target', this.target_id);
}
/**
* The user associated with this session (if loaded).
* @type {UserModel}
*/
get user() {
if (!this.user_id) {
return null;
}
return this.store.peekRecord('user', this.user_id);
}
// =methods
/**
@@ -72,4 +72,88 @@ module('Unit | Model | session', function (hooks) {
assert.false(modelA.isUnknownStatus);
assert.true(modelB.isUnknownStatus);
});
test('it has target property and returns the null with no target_id set', function (assert) {
const store = this.owner.lookup('service:store');
store.push({
data: {
id: 's_123',
type: 'session',
attributes: { target_id: null },
},
});
const session = store.peekRecord('session', 's_123');
assert.strictEqual(session.target, null);
});
test('it has target property and returns the correct target', function (assert) {
const store = this.owner.lookup('service:store');
const targetId = 'ttcp_123';
store.push({
data: {
id: targetId,
type: 'target',
attributes: {
name: 'admin',
scope: {
scope_id: 'o_1',
type: 'scope',
},
},
},
});
store.push({
data: {
id: 's_123',
type: 'session',
attributes: { target_id: targetId },
},
});
const session = store.peekRecord('session', 's_123');
assert.strictEqual(session.target.id, targetId);
});
test('it has user property and returns the null with no user_id set', function (assert) {
const store = this.owner.lookup('service:store');
store.push({
data: {
id: 's_123',
type: 'session',
attributes: { user_id: null },
},
});
const session = store.peekRecord('session', 's_123');
assert.strictEqual(session.user, null);
});
test('it has user property and returns the correct user', function (assert) {
const store = this.owner.lookup('service:store');
const userId = 'u_123';
store.push({
data: {
id: userId,
type: 'user',
attributes: {
name: 'admin',
scope: {
scope_id: 'o_1',
type: 'scope',
},
},
},
});
store.push({
data: {
id: 's_123',
type: 'session',
attributes: { user_id: userId },
},
});
const session = store.peekRecord('session', 's_123');
assert.strictEqual(session.user.id, userId);
});
});
@@ -31,6 +31,13 @@
color: var(--token-form-indicator-optional-color);
}
// Override for sortable HDS table columns that ignore selected theme in app
// and use the system default theme causing color mismatch if selected theme
// is not the system default theme
.hds-table__th-sort button {
color: var(--token-color-foreground-strong);
}
// TODO: This is a temporary hack to fix an issue causing weird whitespace
// and scrollbars from an accessible class in HDS. We have to use !important
// as they already forced it with !important and we can't override it otherwise
@@ -0,0 +1,115 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import {
STATUS_SESSION_ACTIVE,
STATUS_SESSION_PENDING,
STATUS_SESSION_CANCELING,
statusTypes,
} from 'api/models/session';
import { action } from '@ember/object';
import { debounce } from 'core/decorators/debounce';
import { notifySuccess, notifyError } from 'core/decorators/notify';
export default class ScopesScopeSessionsIndexController extends Controller {
// =services
@service store;
@service intl;
// =attributes
queryParams = [
'search',
{ users: { type: 'array' } },
{ targets: { type: 'array' } },
{ status: { type: 'array' } },
'page',
'pageSize',
];
@tracked search = '';
@tracked users = [];
@tracked targets = [];
@tracked status = [
STATUS_SESSION_ACTIVE,
STATUS_SESSION_PENDING,
STATUS_SESSION_CANCELING,
];
@tracked page = 1;
@tracked pageSize = 10;
/**
* Returns object of filters to be used for displaying selected filters
* @returns {object}
*/
get filters() {
return {
allFilters: {
users: this.model.associatedUsers,
targets: this.model.associatedTargets,
status: this.sessionStatusOptions,
},
selectedFilters: {
users: this.users,
targets: this.targets,
status: this.status,
},
};
}
/**
* Returns all status types for sessions
* @returns {[object]}
*/
get sessionStatusOptions() {
return statusTypes.map((status) => ({
id: status,
name: this.intl.t(`resources.session.status.${status}`),
}));
}
// =actions
/**
* Handles input on each keystroke and the search queryParam
* @param {object} event
*/
@action
@debounce(250)
handleSearchInput(event) {
const { value } = event.target;
this.search = value;
this.page = 1;
}
/**
* Sets the selected items for the given paramKey and sets the page to 1
* @param {string} paramKey
* @param {[string]} selectedItems
*/
@action
applyFilter(paramKey, selectedItems) {
this[paramKey] = [...selectedItems];
this.page = 1;
}
/**
* Refreshes the all data for the current page
*/
@action
refresh() {
this.send('refreshAll');
}
/**
* Cancels the specified session and notifies the user of success or error
* @param {SessionModel} session
*/
@action
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.canceled-success')
async cancelSession(session) {
await session.cancelSession();
}
}
@@ -5,137 +5,17 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { all, hash } from 'rsvp';
import runEvery from 'ember-pollster/decorators/route/run-every';
import { notifySuccess, notifyError } from 'core/decorators/notify';
import config from '../../../config/environment';
import { resourceFilter } from 'core/decorators/resource-filter';
import orderBy from 'lodash/orderBy';
const POLL_TIMEOUT_SECONDS = config.sessionPollingTimeoutSeconds;
export default class ScopesScopeSessionsRoute extends Route {
// =services
@service store;
@service intl;
@service session;
@service resourceFilterStore;
@service router;
// =attributes
@resourceFilter({
allowed: ['active', 'pending', 'canceling', 'terminated'],
defaultValue: ['active', 'pending', 'canceling'],
})
status;
@runEvery(POLL_TIMEOUT_SECONDS * 1000)
poller() {
return this.refresh();
}
// =methods
/**
* If arriving here unauthenticated, redirect to index for further processing.
*/
beforeModel() {
if (!this.session.isAuthenticated) this.router.transitionTo('index');
}
/**
* Loads all sessions under the current scope and encapsulates them into
* an array of objects containing their associated users and targets.
* @return {Promise{[{session: SessionModel, user: UserModel, target: TargetModel}]}}
*/
async model() {
const { id: scope_id } = this.modelFor('scopes.scope');
const filters = { status: this.status };
const options = {
scope_id,
include_terminated: filters.status?.includes('terminated'),
};
const sessions = await this.resourceFilterStore.queryBy(
'session',
filters,
options,
);
const sessionAggregates = await all(
sessions.map((session) =>
hash({
session,
user: session.user_id
? this.store.peekRecord('user', session.user_id) ||
this.store.findRecord('user', session.user_id)
: null,
target: session.target_id
? this.store.peekRecord('target', session.target_id) ||
this.store.findRecord('target', session.target_id)
: null,
}),
),
);
// Sort sessions by created time descending (newest on top)
let sortedSessionAggregates = orderBy(
sessionAggregates,
'created_time',
'desc',
);
// Move active sessions to the top...
sortedSessionAggregates = [
...sortedSessionAggregates.filter(
(aggregate) => aggregate.session.status === 'active',
),
...sortedSessionAggregates.filter(
(aggregate) => aggregate.session.status !== 'active',
),
];
return sortedSessionAggregates;
}
// =actions
/**
* Cancels the specified session and notifies user of success or error.
* @param {SessionModel}
*/
@action
@notifyError(({ message }) => message, { catch: true })
@notifySuccess('notifications.canceled-success')
async cancelSession(session) {
await session.cancelSession();
}
/**
* Sets the specified resource filter field to the specified value.
* @param {string} field
* @param value
*/
@action
filterBy(field, value) {
this[field] = value;
}
/**
* Clears and filter selections.
*/
@action
clearAllFilters() {
this.status = [];
}
/**
* refreshes session data.
*/
@action
refreshSessions() {
return super.refresh(...arguments);
}
}
@@ -4,5 +4,173 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class ScopesScopeSessionsIndexRoute extends Route {}
export default class ScopesScopeSessionsIndexRoute extends Route {
// =services
@service store;
// =attributes
queryParams = {
search: {
refreshModel: true,
replace: true,
},
users: {
refreshModel: true,
replace: true,
},
targets: {
refreshModel: true,
replace: true,
},
status: {
refreshModel: true,
replace: true,
},
page: {
refreshModel: true,
},
pageSize: {
refreshModel: true,
},
};
allSessions;
associatedUsers;
associatedTargets;
/**
* Loads all sessions under the current scope and encapsulates them into
* an array of objects containing their associated users and targets.
* @return {Promise{[{sessions: [SessionModel], allSessions: [SessionModel], associatedUsers: [UserModel], associatedTargets: [TargetModel], totalItems: number}]}}
*/
async model({ search, users, targets, status, page, pageSize }) {
const { id: scope_id, scope: parentScope } = this.modelFor('scopes.scope');
const filters = {
scope_id: [{ equals: scope_id }],
status: [],
user_id: [],
target_id: [],
};
users.forEach((user) => {
filters.user_id.push({ equals: user });
});
targets.forEach((target) => {
filters.target_id.push({ equals: target });
});
status.forEach((item) => {
filters.status.push({ equals: item });
});
const queryOptions = {
query: { search, filters },
page,
pageSize,
};
const sessions = await this.store.query('session', queryOptions);
const totalItems = sessions.meta?.totalItems;
// Query all sessions, users, and targets for defining filtering values if entering route for the first time
if (!this.allSessions) {
await this.getAllSessions(scope_id);
}
if (!this.associatedUsers) {
await this.getAssociatedUsers(parentScope.id);
}
if (!this.associatedTargets) {
await this.getAssociatedTargets(scope_id);
}
return {
sessions,
allSessions: this.allSessions,
associatedUsers: this.associatedUsers,
associatedTargets: this.associatedTargets,
totalItems,
};
}
/**
* Get all the sessions but only load them once when entering the route.
* @param scope_id
* @returns {Promise<void>}
*/
async getAllSessions(scope_id) {
const allSessionsQuery = {
query: { filters: { scope_id: [{ equals: scope_id }] } },
};
this.allSessions = await this.store.query('session', allSessionsQuery, {
pushToStore: false,
});
}
/**
* Get all the users but only load them once when entering the route.
* @returns {Promise<void>}
*/
async getAssociatedUsers(parentScopeId) {
const uniqueSessionUserIds = new Set(
this.allSessions
.filter((session) => session.user_id)
.map((session) => session.user_id),
);
// Users can belong to the global scope or the org scope
// the project is under so we need to query for both.
const filters = {
id: { values: [] },
scope_id: [{ equals: 'global' }, { equals: parentScopeId }],
};
uniqueSessionUserIds.forEach((userId) => {
filters.id.values.push({ equals: userId });
});
const associatedUsersQuery = {
query: { filters },
};
this.associatedUsers = await this.store.query('user', associatedUsersQuery);
}
/**
* Get all the targets but only load them once when entering the route.
* @param scope_id
* @returns {Promise<void>}
*/
async getAssociatedTargets(scope_id) {
const uniqueSessionTargetIds = new Set(
this.allSessions
.filter((session) => session.target_id)
.map((session) => session.target_id),
);
const filters = { id: { values: [] }, scope_id: [{ equals: scope_id }] };
uniqueSessionTargetIds.forEach((targetId) => {
filters.id.values.push({ equals: targetId });
});
const associatedTargetsQuery = {
query: { filters },
};
this.associatedTargets = await this.store.query(
'target',
associatedTargetsQuery,
);
}
// =actions
/**
* refreshes all session route data.
*/
@action
async refreshAll() {
const { id: scope_id } = this.modelFor('scopes.scope');
await this.getAllSessions(scope_id);
await this.getAssociatedUsers();
await this.getAssociatedTargets(scope_id);
return super.refresh(...arguments);
}
}
@@ -17,134 +17,143 @@
<p>{{t 'resources.session.description'}}</p>
</page.header>
<page.body>
<Rose::Toolbar as |toolbar|>
<toolbar.primary>
<Rose::Form as |form|>
{{#let
(resource-filter 'scopes.scope.sessions' 'status')
as |filter|
}}
<Rose::Dropdown
@text={{t (concat 'form.' filter.name '.label')}}
@count={{filter.selectedValue.length}}
@ignoreClickInside={{true}}
as |dropdown|
<page.body class='search-filtering'>
{{#if @model.allSessions}}
<div class='search-filtering-toolbar'>
<Hds::SegmentedGroup data-test-sessions-bar as |S|>
<S.TextInput
@value={{this.search}}
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
<S.Generic>
<Dropdown
@name={{t 'resources.user.title'}}
@itemOptions={{@model.associatedUsers}}
@checkedItems={{this.users}}
@applyFilter={{fn this.applyFilter 'users'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
<form.checkboxGroup
@name={{filter.name}}
@items={{filter.allowedValues}}
@selectedItems={{filter.selectedValue}}
@onChange={{route-action 'filterBy' filter.name}}
as |group|
>
<dropdown.item>
<group.checkbox
@label={{t (concat 'resources.session.status.' group.item)}}
value={{group.item}}
/>
</dropdown.item>
</form.checkboxGroup>
</Rose::Dropdown>
{{/let}}
{{#each itemOptions as |itemOption|}}
<FD.Checkbox
@value={{itemOption.id}}
checked={{includes itemOption.id this.users}}
{{on 'change' selectItem}}
>
{{itemOption.name}}
</FD.Checkbox>
{{/each}}
</Dropdown>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'resources.target.title'}}
@itemOptions={{@model.associatedTargets}}
@checkedItems={{this.targets}}
@applyFilter={{fn this.applyFilter 'targets'}}
@isSearchable={{true}}
/>
</S.Generic>
<S.Generic>
<Dropdown
@name={{t 'form.status.label'}}
@itemOptions={{this.sessionStatusOptions}}
@checkedItems={{this.status}}
@applyFilter={{fn this.applyFilter 'status'}}
/>
</S.Generic>
</Hds::SegmentedGroup>
<span>
<ToolbarRefresher @onClick={{this.refresh}} />
</span>
</div>
{{#if
(has-resource-filter-selections 'scopes.scope.sessions' 'status')
<FilterTags @filters={{this.filters}} />
{{#if @model.sessions}}
<Hds::Table
@columns={{array
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.user.title'))
(hash label=(t 'resources.target.title'))
(hash label=(t 'form.started.label'))
(hash key='status' label=(t 'form.status.label') isSortable=true)
(hash label=(t 'titles.actions'))
}}
<Rose::Button
@style='inline-link-action'
@iconLeft='flight-icons/svg/x-16'
{{on 'click' (route-action 'clearAllFilters')}}
>{{t 'actions.clear-all-filters'}}
</Rose::Button>
{{/if}}
</Rose::Form>
</toolbar.primary>
<toolbar.secondary>
<ToolbarRefresher @onClick={{route-action 'refreshSessions'}} />
</toolbar.secondary>
</Rose::Toolbar>
{{#if @model}}
<Hds::Table
@model={{@model}}
class='sessions-list-table'
@columns={{array
(hash label=(t 'form.id.label'))
(hash label=(t 'resources.user.title'))
(hash label=(t 'resources.target.title'))
(hash label=(t 'form.started.label'))
(hash label=(t 'form.status.label'))
(hash label=(t 'titles.actions'))
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
<B.Td>
<Rose::Icon
class={{concat 'session-status-' B.data.session.status}}
@name='flight-icons/svg/entry-point-16'
@size='medium'
/>
<Hds::Copy::Snippet
@textToCopy={{B.data.session.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
<Hds::Text::Code>{{B.data.user.id}}</Hds::Text::Code>
{{#if B.data.user.accountName}}
<br />
<Hds::Badge @text={{B.data.user.accountName}} />
{{/if}}
</B.Td>
<B.Td>{{B.data.target.displayName}}</B.Td>
<B.Td>
<time datetime={{format-date-iso B.data.session.created_time}}>
{{format-date-iso-human B.data.session.created_time}}
</time>
</B.Td>
<B.Td>
<SessionStatus @model={{B.data.session}} />
</B.Td>
<B.Td>
{{#if (can 'cancel session' B.data.session)}}
<Hds::Button
@color='secondary'
@text={{t 'actions.cancel'}}
{{on 'click' (route-action 'cancelSession' B.data.session)}}
/>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
{{else if
(has-resource-filter-selections 'scopes.scope.sessions' 'status')
}}
<Rose::Layout::Centered>
<Rose::Message @title={{t 'titles.no-results'}} as |message|>
<message.description>
{{t 'descriptions.no-results'}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
{{else}}
<Rose::Layout::Centered>
<Rose::Message
@title={{t 'resources.session.messages.none.title'}}
as |message|
@sortBy='status'
@model={{@model.sessions}}
@density='short'
@valign='middle'
>
<message.description>
{{t 'resources.session.messages.none.description'}}
</message.description>
</Rose::Message>
</Rose::Layout::Centered>
<:body as |B|>
<B.Tr>
<B.Td>
<Hds::Copy::Snippet
data-test-session={{B.data.id}}
@textToCopy={{B.data.id}}
@color='secondary'
/>
</B.Td>
<B.Td>
{{B.data.user.displayName}}
</B.Td>
<B.Td>
{{B.data.target.displayName}}
</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>
<SessionStatus @model={{B.data}} />
</B.Td>
<B.Td>
{{#if (can 'cancel session' B.data)}}
<Hds::Button
@color='secondary'
@text={{t 'actions.cancel'}}
{{on 'click' (fn this.cancelSession B.data)}}
/>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
<Rose::Pagination
@totalItems={{@model.totalItems}}
@currentPage={{this.page}}
@currentPageSize={{this.pageSize}}
/>
{{else}}
<Hds::ApplicationState data-test-no-session-results as |A|>
<A.Header @title={{t 'titles.no-results-found'}} />
<A.Body
@text={{t
(if
this.search
'descriptions.no-search-results'
'descriptions.no-filter-results'
)
query=this.search
resource=(t 'resources.session.title_plural')
}}
/>
</Hds::ApplicationState>
{{/if}}
{{else}}
<Hds::ApplicationState data-test-no-sessions as |A|>
<A.Header
@title={{t 'resources.session.messages.none-friendly.title'}}
/>
<A.Body
@text={{t 'resources.session.messages.none-friendly.description'}}
/>
</Hds::ApplicationState>
{{/if}}
</page.body>
</Rose::Layout::Page>
@@ -4,9 +4,17 @@
*/
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import {
visit,
currentURL,
click,
fillIn,
waitUntil,
findAll,
} from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { Response } from 'miragejs';
import {
@@ -15,10 +23,21 @@ import {
//currentSession,
//invalidateSession,
} from 'ember-simple-auth/test-support';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
module('Acceptance | sessions', function (hooks) {
module('Acceptance | sessions | list', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
setupIndexedDb(hooks);
const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]';
const NO_RESULTS_MSG_SELECTOR = '[data-test-no-session-results]';
const SESSION_ID_SELECTOR = (id) => `[data-test-session="${id}"]`;
const FILTER_TOGGLE_SELECTOR = (name) =>
`[data-test-sessions-bar] div[name="${name}"] button`;
const FILTER_APPLY_BUTTON = (name) =>
`[data-test-sessions-bar] div[name="${name}"] div div:last-child button[type="button"]`;
const instances = {
scopes: {
global: null,
@@ -26,6 +45,10 @@ module('Acceptance | sessions', function (hooks) {
project: null,
},
sessions: null,
tcpTarget: null,
sshTarget: null,
admin: null,
dev: null,
};
const urls = {
orgScope: null,
@@ -35,6 +58,14 @@ module('Acceptance | sessions', function (hooks) {
hooks.beforeEach(function () {
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.admin = this.server.create('user', {
scopeId: 'global',
name: 'admin',
});
instances.dev = this.server.create('user', {
scopeId: 'global',
name: 'dev',
});
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
@@ -49,10 +80,14 @@ module('Acceptance | sessions', function (hooks) {
{ scope: instances.scopes.org },
'withMembers',
);
this.server.createList(
instances.tcpTarget = this.server.create(
'target',
1,
{ scope: instances.scopes.project },
{ scope: instances.scopes.project, type: TYPE_TARGET_TCP },
'withAssociations',
);
instances.sshTarget = this.server.create(
'target',
{ scope: instances.scopes.project, type: TYPE_TARGET_SSH },
'withAssociations',
);
instances.sessions = this.server.createList(
@@ -156,4 +191,67 @@ module('Acceptance | sessions', function (hooks) {
.dom(`[href="https://boundaryproject.io/help/admin-ui/sessions"]`)
.exists();
});
test('user can search for a specific session by id', async function (assert) {
await visit(urls.projectScope);
const sessionId = instances.sessions[0].id;
await click(`[href="${urls.sessions}"]`);
await fillIn(SEARCH_INPUT_SELECTOR, sessionId);
await waitUntil(() => findAll(SESSION_ID_SELECTOR(sessionId)).length === 1);
assert.dom(SESSION_ID_SELECTOR(sessionId)).hasText(sessionId);
});
test('user can search for sessions and get no results', async function (assert) {
await visit(urls.projectScope);
const sessionId = 'fake session that does not exist';
await click(`[href="${urls.sessions}"]`);
await fillIn(SEARCH_INPUT_SELECTOR, sessionId);
await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1);
assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found');
});
test('user can filter for sessions by user', async function (assert) {
await visit(urls.projectScope);
instances.sessions[2].update({
userId: instances.dev.id,
});
await click(`[href="${urls.sessions}"]`);
await click(FILTER_TOGGLE_SELECTOR('User'));
await click(`input[value="${instances.dev.id}"]`);
await click(FILTER_APPLY_BUTTON('User'));
assert.dom(SESSION_ID_SELECTOR(instances.sessions[2].id)).exists();
assert.dom('tbody tr').exists({ count: 1 });
});
test('user can filter for sessions by target', async function (assert) {
await visit(urls.projectScope);
instances.sessions[2].update({
targetId: instances.sshTarget.id,
});
await click(`[href="${urls.sessions}"]`);
await click(FILTER_TOGGLE_SELECTOR('Target'));
await click(`input[value="${instances.sshTarget.id}"]`);
await click(FILTER_APPLY_BUTTON('Target'));
assert.dom(SESSION_ID_SELECTOR(instances.sessions[2].id)).exists();
assert.dom('tbody tr').exists({ count: 1 });
});
test('user can filter for sessions by status', async function (assert) {
await visit(urls.projectScope);
await click(`[href="${urls.sessions}"]`);
await click(FILTER_TOGGLE_SELECTOR('Status'));
await click('input[value="active"]');
await click(FILTER_APPLY_BUTTON('Status'));
assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found');
});
});
@@ -0,0 +1,127 @@
import { module, test } from 'qunit';
import { setupTest } from 'admin/tests/helpers';
import { waitUntil } from '@ember/test-helpers';
module('Unit | Controller | scopes/scope/sessions/index', function (hooks) {
setupTest(hooks);
const session = {
id: 's_123',
status: 'active',
created_time: '2024-03-12T20:44:14.808699Z',
};
const user = { id: 'u_123', name: 'admin' };
const target = { id: 't_123', name: 'target' };
const model = {
sessions: [session],
associatedUsers: [user],
associatedTargets: [target],
allSessions: [session],
totalItems: 1,
};
test('it exists', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/sessions/index',
);
assert.ok(controller);
});
test('filters returns expected entries', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/sessions/index',
);
controller.set('model', model);
assert.deepEqual(controller.filters.allFilters, {
status: [
{
id: 'active',
name: 'Active',
},
{
id: 'pending',
name: 'Pending',
},
{
id: 'canceling',
name: 'Canceling',
},
{
id: 'terminated',
name: 'Terminated',
},
],
targets: [
{
id: 't_123',
name: 'target',
},
],
users: [
{
id: 'u_123',
name: 'admin',
},
],
});
assert.deepEqual(controller.filters.selectedFilters, {
users: [],
targets: [],
status: ['active', 'pending', 'canceling'],
});
});
test('sessionStatusOptions returns expected object', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/sessions/index',
);
assert.deepEqual(controller.sessionStatusOptions, [
{ id: 'active', name: 'Active' },
{ id: 'pending', name: 'Pending' },
{ id: 'canceling', name: 'Canceling' },
{ id: 'terminated', name: 'Terminated' },
]);
});
test('handleSearchInput action sets expected values correctly', async function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/sessions/index',
);
const searchValue = 'test';
controller.handleSearchInput({ target: { value: searchValue } });
await waitUntil(() => controller.search === searchValue);
assert.strictEqual(controller.page, 1);
assert.strictEqual(controller.search, searchValue);
});
test('applyFilter action sets expected values correctly', function (assert) {
let controller = this.owner.lookup(
'controller:scopes/scope/sessions/index',
);
const selectedItems = ['admin'];
controller.applyFilter('users', selectedItems);
assert.strictEqual(controller.page, 1);
assert.deepEqual(controller.users, selectedItems);
});
test('refresh action calls refreshAll', async function (assert) {
assert.expect(2);
let controller = this.owner.lookup(
'controller:scopes/scope/sessions/index',
);
controller.set('target', {
send(actionName, ...args) {
assert.strictEqual(actionName, 'refreshAll');
assert.deepEqual(
args,
[],
'refreshAll was called with the correct arguments',
);
},
});
await controller.refresh();
});
});