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:
1 parent
be96daa499
commit
6e75439652
9 files changed
+749
-250
No files matched your search
@@ -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>
|
||||
+103
-5
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user