Icu 15181 add pagination to session recordings (#2502)

* refactor: 💡 move model hook to session-recordings/index route

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

* test: 💍 add missing tests for session recordings controller

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

* feat: 🎸 paginate session-recordings and search

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

* refactor: 💡 fix failing redirect and tests

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

* test: 💍 add search tests for session recordings

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

* feat: 🎸 add user filtering for session recordings

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

* feat: 🎸 add more attributes to indexed-db session recordings

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

* feat: 🎸 add target filter for session recordings

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

* refactor: 💡 update filterOptions method

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

* feat: 🎸 add scopes filtering to session recording

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

* feat: 🎸 add time filter to session recordings

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

* refactor: 💡 add no filter results text

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

* refactor: 💡 update Exist variables to doExist

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

* refactor: 💡 updates from PR feedback

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

* refactor: 💡 remove scope_id filter

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

* refactor: 💡 change session recording placeholder text

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

* chore: 🤖 increment indexedDB version

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

* test: 💍 add Date object tests for indexed-db

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

* refactor: 💡 small changes from PR feedback

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

* refactor: 💡 change times filter array to time date object

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

* test: 💍 add more date specific indexed-db test

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

* refactor: 💡 change Today time shortcut to "Last 24 hours"

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

* test: 💍 fix date mock in session-recording controller tests

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-15181
This commit is contained in:
Cameron Perera authored and GitHub committed 2024-10-01 13:55:15 -05:00
1 parent 15727f61fd
commit d3c789c66c
13 files changed
+1034 -191

No files matched your search

+3 -1
View File
@@ -29,6 +29,8 @@ export const modelIndexes = {
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id, attributes.plugin.name',
'auth-method':
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.is_primary, attributes.scope.scope_id',
'session-recording':
'&id, attributes.created_time, attributes.type, attributes.state, attributes.start_time, attributes.end_time, attributes.duration, attributes.scope.scope_id, attributes.create_time_values.user.id, attributes.create_time_values.user.name, attributes.create_time_values.target.id, attributes.create_time_values.target.name, attributes.create_time_values.target.scope.id, attributes.create_time_values.target.scope.name',
alias:
'&id, attributes.created_time, attributes.type, attributes.value, attributes.name, attributes.description, attributes.destination_id, attributes.scope.scope_id',
};
@@ -111,7 +113,7 @@ export default class IndexedDbService extends Service {
}
this.#db = new Dexie(dbName);
this.#db.version(1).stores(modelIndexes);
this.#db.version(2).stores(modelIndexes);
}
/**
+1 -12
View File
@@ -778,18 +778,7 @@ function routes() {
});
// session recordings
this.get(
'/session-recordings',
(
{ sessionRecordings },
{ queryParams: { scope_id: scopeId, recursive } },
) => {
if (recursive && scopeId === 'global') {
return sessionRecordings.all();
}
return sessionRecordings.where({ scopeId });
},
);
this.get('/session-recordings');
this.get(
'/session-recordings/:idMethod',
async ({ sessionRecordings }, { params: { idMethod } }) => {
@@ -11,6 +11,7 @@ import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import { pluralize } from 'ember-inflector';
import { camelize } from '@ember/string';
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
import { faker } from '@faker-js/faker';
const seedIndexDb = async (resource, store, indexedDb, server) => {
const resourceData =
@@ -529,4 +530,80 @@ module('Unit | Utility | indexed-db-query', function (hooks) {
assert.strictEqual(result.length, 0);
});
test('it filters on date type fields and returns results', async function (assert) {
const pastDate = new Date('2024-09-19T10:00:00.000Z');
const createdTime = new Date('2024-09-23T10:00:00.000Z');
this.server.create('session-recording', {
created_time: createdTime,
});
await seedIndexDb('session-recording', store, indexedDb, this.server);
const query = { filters: { created_time: [{ gte: pastDate }] } };
const result = await queryIndexedDb(
indexedDb.db,
'session-recording',
query,
);
assert.strictEqual(result.length, 1);
});
test('it filters on date type fields and returns results - from and to', async function (assert) {
const from = new Date('2024-09-19T10:00:00.000Z');
const to = new Date('2024-09-25T10:00:00.000Z');
const createdTime = new Date('2024-09-23T10:00:00.000Z');
const createdTime2 = new Date('2024-09-28T10:00:00.000Z');
this.server.create('session-recording', {
created_time: createdTime,
});
this.server.create('session-recording', {
created_time: createdTime2,
});
await seedIndexDb('session-recording', store, indexedDb, this.server);
const query = {
filters: {
created_time: {
logicalOperator: 'and',
values: [{ gte: from }, { lte: to }],
},
},
};
const result = await queryIndexedDb(
indexedDb.db,
'session-recording',
query,
);
assert.strictEqual(result.length, 1);
assert.strictEqual(
result[0].attributes.created_time.toISOString(),
createdTime.toISOString(),
);
});
test('it filters on date type fields and returns no results', async function (assert) {
const createdTime = faker.date.between({
from: '2024-09-19T00:00:00.000Z',
to: '2024-09-20T00:00:00.000Z',
});
const pastDate = faker.date.between({
from: '2024-09-23T00:00:00.000Z',
to: '2024-09-24T00:00:00.000Z',
});
this.server.create('session-recording', {
created_time: createdTime,
});
await seedIndexDb('session-recording', store, indexedDb, this.server);
const query = { filters: { created_time: [{ gte: pastDate }] } };
const result = await queryIndexedDb(
indexedDb.db,
'session-recording',
query,
);
assert.strictEqual(result.length, 0);
});
});
@@ -357,6 +357,12 @@ session-recording:
duration: '{time} duration'
questions:
delete: Are you sure you want to delete this recording?
filters:
time:
title: Time
last-twenty-four-hours: Last 24 hours
last-three-days: Last 3 days
last-seven-days: Last 7 days
connection:
title: Connection
title_index: Connection {index}
@@ -4,21 +4,192 @@
*/
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { debounce } from 'core/decorators/debounce';
import { inject as service } from '@ember/service';
export default class ScopesScopeSessionRecordingsIndexController extends Controller {
// =services
@service store;
@service intl;
// =attributes
queryParams = [
'search',
'time',
{ users: { type: 'array' } },
{ scopes: { type: 'array' } },
{ targets: { type: 'array' } },
'page',
'pageSize',
];
now = new Date();
@tracked search = '';
@tracked time = null;
@tracked users = [];
@tracked scopes = [];
@tracked targets = [];
@tracked page = 1;
@tracked pageSize = 10;
/**
* Returns true if any session recordings exist
* @type {boolean}
* Returns object of filters to be used for displaying selected filters
* @returns {object}
*/
get hasSessionRecordings() {
return this.model?.sessionRecordings?.length;
get filters() {
return {
allFilters: {
time: this.timeOptions,
users: this.filterOptions('user'),
scopes: this.projectScopes,
targets: this.filterOptions('target'),
},
selectedFilters: {
time: [this.time],
users: this.users,
scopes: this.scopes,
targets: this.targets,
},
};
}
/**
* Returns true if any storage buckets exist
* @type {boolean}
* Returns array of time options for time filter
* @returns {[object]}
*/
get hasSessionRecordingsConfigured() {
return this.model?.storageBuckets?.length;
get timeOptions() {
const last24Hours = new Date(this.now.getTime() - 24 * 60 * 60 * 1000);
const last3Days = new Date(this.now);
last3Days.setDate(this.now.getDate() - 3);
const last7Days = new Date(this.now);
last7Days.setDate(this.now.getDate() - 7);
return [
{
id: last24Hours.toISOString(),
name: this.intl.t(
'resources.session-recording.filters.time.last-twenty-four-hours',
),
},
{
id: last3Days.toISOString(),
name: this.intl.t(
'resources.session-recording.filters.time.last-three-days',
),
},
{
id: last7Days.toISOString(),
name: this.intl.t(
'resources.session-recording.filters.time.last-seven-days',
),
},
];
}
/**
* Returns unique project scopes from targets
* linked to the session recordings
* @returns {[object]}
*/
get projectScopes() {
const uniqueMap = new Map();
this.model.allSessionRecordings.forEach(
({
create_time_values: {
target: {
scope: { id, name, parent_scope_id },
},
},
}) => {
if (!uniqueMap.has(id)) {
const projectName = name || id;
uniqueMap.set(id, { id, name: projectName, parent_scope_id });
}
},
);
return Array.from(uniqueMap.values());
}
// =actions
/**
* Looks up org by ID and returns the org name
* @param {string} orgID
* @returns {string}
*/
@action
orgName(orgID) {
const org = this.store.peekRecord('scope', orgID);
return org.displayName;
}
/**
* Returns all filter options for key for session recordings
* @param {string} key
* @returns {[object]}
*/
@action
filterOptions(key) {
const uniqueMap = new Map();
this.model.allSessionRecordings.forEach(
({
create_time_values: {
[key]: { id, name },
},
}) => {
if (!uniqueMap.has(id)) {
uniqueMap.set(id, { id, name });
}
},
);
return Array.from(uniqueMap.values());
}
/**
* 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;
}
/**
* Sets the time filter, sets the page to 1, and closes the filter dropdown
* @param {object} timeId
* @param {func} onClose
*/
@action
changeTimeFilter(timeId, onClose) {
this.time = timeId;
this.page = 1;
onClose();
}
/**
* Refreshes the all data for the current page
*/
@action
refresh() {
this.send('refreshAll');
}
}
@@ -5,15 +5,12 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import orderBy from 'lodash/orderBy';
export default class ScopesScopeSessionRecordingsRoute extends Route {
// =services
@service store;
@service can;
@service session;
@service router;
@service session;
// =methods
@@ -23,49 +20,4 @@ export default class ScopesScopeSessionRecordingsRoute extends Route {
beforeModel() {
if (!this.session.isAuthenticated) this.router.transitionTo('index');
}
/**
* Load all session recordings.
* @return {{sessionRecordings: Array, storageBuckets: Array}}
*/
async model() {
let storageBuckets;
const scope = this.modelFor('scopes.scope');
const { id: scope_id } = scope;
if (
this.can.can('list scope', scope, {
collection: 'session-recordings',
})
) {
const sessionRecordings = await this.store.query('session-recording', {
scope_id,
recursive: true,
});
// Sort sessions by created time descending (newest on top)
const sortedSessionRecordings = orderBy(
sessionRecordings,
'created_time',
'desc',
);
// Storage buckets could fail for a number of reasons, including that
// the user isn't authorized to access them.
try {
storageBuckets = await this.store.query('storage-bucket', {
scope_id,
recursive: true,
});
} catch (e) {
// no op
}
return {
sessionRecordings: sortedSessionRecordings,
storageBuckets,
};
}
}
}
@@ -4,5 +4,158 @@
*/
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class ScopesScopeSessionRecordingsIndexRoute extends Route {}
export default class ScopesScopeSessionRecordingsIndexRoute extends Route {
// =services
@service store;
@service router;
@service can;
// =attributes
queryParams = {
search: {
refreshModel: true,
replace: true,
},
time: {
refreshModel: true,
replace: true,
},
users: {
refreshModel: true,
replace: true,
},
scopes: {
refreshModel: true,
replace: true,
},
targets: {
refreshModel: true,
replace: true,
},
page: {
refreshModel: true,
},
pageSize: {
refreshModel: true,
},
};
allSessionRecordings;
/**
* Load all session recordings.
* @return {Promise<{ totalItems: number, sessionRecordings: [SessionRecordingModel], doSessionRecordingsExist: boolean, doStorageBucketsExist: boolean }>}
*/
async model({ search, time, users, scopes, targets, page, pageSize }) {
const scope = this.modelFor('scopes.scope');
const { id: scope_id } = scope;
let sessionRecordings;
let totalItems = 0;
let doSessionRecordingsExist = false;
let doStorageBucketsExist = false;
const filters = {
created_time: [],
'create_time_values.user.id': [],
'create_time_values.target.scope.id': [],
'create_time_values.target.id': [],
};
if (time) filters.created_time.push({ gte: new Date(time) });
users.forEach((user) => {
filters['create_time_values.user.id'].push({ equals: user });
});
scopes.forEach((scope) => {
filters['create_time_values.target.scope.id'].push({ equals: scope });
});
targets.forEach((target) => {
filters['create_time_values.target.id'].push({ equals: target });
});
if (
this.can.can('list scope', scope, {
collection: 'session-recordings',
})
) {
const queryOptions = {
scope_id,
recursive: true,
query: { search, filters },
page,
pageSize,
};
sessionRecordings = await this.store.query(
'session-recording',
queryOptions,
);
totalItems = sessionRecordings.meta?.totalItems;
// Query all session recordings for filtering values if entering route for the first time
if (!this.allSessionRecordings) {
await this.getAllSessionRecordings(scope_id);
}
doSessionRecordingsExist = Boolean(this.allSessionRecordings.length);
doStorageBucketsExist = await this.getDoStorageBucketsExist(scope_id);
return {
sessionRecordings,
doSessionRecordingsExist: doSessionRecordingsExist,
allSessionRecordings: this.allSessionRecordings,
totalItems,
doStorageBucketsExist: doStorageBucketsExist,
};
}
}
/**
* Sets allSessionRecordings to all session recordings for filters
* @param {string} scope_id
*/
async getAllSessionRecordings(scope_id) {
const options = { pushToStore: false, peekIndexedDB: true };
this.allSessionRecordings = await this.store.query(
'session-recording',
{
scope_id,
recursive: true,
},
options,
);
}
/**
* Returns true if any storage buckets exist.
* @param {string} scope_id
* @returns {Promise<boolean>}
*/
async getDoStorageBucketsExist(scope_id) {
// Storage buckets could fail for a number of reasons, including that
// the user isn't authorized to access them.
try {
const storageBuckets = await this.store.query('storage-bucket', {
scope_id,
recursive: true,
});
return Boolean(storageBuckets.length);
} catch (e) {
// no op
return false;
}
}
// =actions
/**
* refreshes all session recording route data.
*/
@action
async refreshAll() {
const scope = this.modelFor('scopes.scope');
await this.getAllSessionRecordings(scope.id);
return super.refresh(...arguments);
}
}
@@ -11,6 +11,8 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne
@service store;
@service can;
@service router;
@service flashMessages;
@service intl;
// =methods
/**
@@ -35,19 +37,30 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne
}
/**
* Redirects to route with correct session-recording id if incorrect.
* Redirects to route with correct session-recording id if incorrect. If
* the channelRecording is undefined we redirect the user back to
* the session recordings list page.
* @param {channelRecording: Object, sessionRecording: Object, storageBucket: Object} model
*/
redirect(model) {
const { channelRecording, sessionRecording } = model;
const session_recording_id =
channelRecording.connection_recording.session_recording.id;
if (session_recording_id !== sessionRecording.id) {
this.router.replaceWith(
'scopes.scope.session-recordings.session-recording.channels-by-connection.channel',
session_recording_id,
channelRecording.id,
);
if (channelRecording) {
const session_recording_id =
channelRecording.connection_recording.session_recording.id;
if (session_recording_id !== sessionRecording.id) {
this.router.replaceWith(
'scopes.scope.session-recordings.session-recording.channels-by-connection.channel',
session_recording_id,
channelRecording.id,
);
}
} else {
this.flashMessages.danger(this.intl.t('errors.404.title'), {
notificationType: 'error',
sticky: true,
dismiss: (flash) => flash.destroyMessage(),
});
this.router.transitionTo('scopes.scope.session-recordings');
}
}
}
@@ -15,118 +15,240 @@
<p>{{t 'resources.session-recording.description'}}</p>
</page.header>
<page.body>
{{#if this.hasSessionRecordings}}
<Hds::Table
@model={{@model.sessionRecordings}}
@density='tall'
@columns={{array
(hash label=(t 'resources.session-recording.form.time.label'))
(hash label=(t 'resources.session-recording.form.status.label'))
(hash label=(t 'resources.session-recording.form.user.label'))
(hash label=(t 'resources.session-recording.form.target.label'))
(hash label=(t 'resources.session-recording.form.duration.label'))
(hash label=(t 'actions.view') isVisuallyHidden=true align='right')
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
{{! Created Time }}
<B.Td>
{{#if B.data.created_time}}
<span class='hds-foreground-faint'>
<time datetime={{format-date-iso B.data.created_time}}>
{{format-date-iso B.data.created_time}}
</time>
</span>
{{/if}}
</B.Td>
{{! Status }}
<B.Td>
<SessionRecording::Status @status={{B.data.state}} />
</B.Td>
{{! User }}
<B.Td>
<div
class='session-recording-list-cell-item hds-font-weight-semibold'
<page.body class='search-filtering'>
{{#if @model.doSessionRecordingsExist}}
<div class='search-filtering-toolbar'>
<Hds::SegmentedGroup data-test-session-recordings-bar as |S|>
<S.TextInput
@value={{this.search}}
@type='search'
placeholder={{t 'actions.search'}}
aria-label={{t 'actions.search'}}
{{on 'input' this.handleSearchInput}}
/>
<S.Dropdown name='time' as |D|>
<D.ToggleButton
@text={{t 'resources.session-recording.filters.time.title'}}
@color='secondary'
/>
{{#each this.timeOptions as |time|}}
<D.Checkmark
@selected={{eq time.id this.time}}
{{on 'click' (fn this.changeTimeFilter time.id D.close)}}
>
<span><FlightIcon @name='user' /></span>
<span>{{B.data.userDisplayName}}</span>
</div>
</B.Td>
{{! Target }}
<B.Td>
<div
class='session-recording-list-cell-item target-column hds-font-weight-semibold'
>
<span>
<FlightIcon @name='target' />
{{B.data.create_time_values.target.name}}
</span>
<span>
<Hds::Badge
@text={{B.data.targetScopeDisplayName}}
@icon='grid'
/>
</span>
</div>
</B.Td>
{{! Duration }}
<B.Td>
{{#if B.data.duration}}
<div
class='session-recording-list-cell-item hds-foreground-faint'
{{time.name}}
</D.Checkmark>
{{/each}}
</S.Dropdown>
<S.Generic>
<Dropdown
name='user'
@toggleText={{t 'resources.user.title'}}
@itemOptions={{this.filterOptions 'user'}}
@checkedItems={{this.users}}
@applyFilter={{fn this.applyFilter 'users'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#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='scope'
@toggleText={{t 'resources.scope.title'}}
@itemOptions={{this.projectScopes}}
@checkedItems={{this.scopes}}
@applyFilter={{fn this.applyFilter 'scopes'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#each-in
(group-by 'parent_scope_id' itemOptions)
as |orgId items|
}}
<FD.Generic>
<FlightIcon @name='org' />
{{this.orgName orgId}}
</FD.Generic>
{{#each items as |project|}}
<FD.Checkbox
@value={{project.id}}
checked={{includes project.id this.scopes}}
{{on 'change' selectItem}}
>
{{project.name}}
</FD.Checkbox>
{{/each}}
{{/each-in}}
</Dropdown>
</S.Generic>
<S.Generic>
<Dropdown
name='target'
@toggleText={{t 'resources.target.title'}}
@itemOptions={{this.filterOptions 'target'}}
@checkedItems={{this.targets}}
@applyFilter={{fn this.applyFilter 'targets'}}
@isSearchable={{true}}
as |FD selectItem itemOptions|
>
{{#each itemOptions as |itemOption|}}
<FD.Checkbox
@value={{itemOption.id}}
checked={{includes itemOption.id this.targets}}
{{on 'change' selectItem}}
>
{{itemOption.name}}
</FD.Checkbox>
{{/each}}
</Dropdown>
</S.Generic>
</Hds::SegmentedGroup>
<span>
<ToolbarRefresher @onClick={{this.refresh}} />
</span>
</div>
<FilterTags @filters={{this.filters}} />
{{#if @model.sessionRecordings}}
<Hds::Table
@model={{@model.sessionRecordings}}
@density='tall'
@columns={{array
(hash label=(t 'resources.session-recording.form.time.label'))
(hash label=(t 'resources.session-recording.form.status.label'))
(hash label=(t 'resources.session-recording.form.user.label'))
(hash label=(t 'resources.session-recording.form.target.label'))
(hash label=(t 'resources.session-recording.form.duration.label'))
(hash label=(t 'actions.view') isVisuallyHidden=true align='right')
}}
@valign='middle'
>
<:body as |B|>
<B.Tr>
{{! Created Time }}
<B.Td>
{{#if B.data.created_time}}
<span class='hds-foreground-faint'>
<time datetime={{format-date-iso B.data.created_time}}>
{{format-date-iso B.data.created_time}}
</time>
</span>
{{/if}}
</B.Td>
{{! Status }}
<B.Td>
<SessionRecording::Status @status={{B.data.state}} />
</B.Td>
{{! User }}
<B.Td>
<div
class='session-recording-list-cell-item hds-font-weight-semibold'
>
<span><FlightIcon @name='user' /></span>
<span>{{B.data.userDisplayName}}</span>
</div>
</B.Td>
{{! Target }}
<B.Td>
<div
class='session-recording-list-cell-item target-column hds-font-weight-semibold'
>
<span><FlightIcon @name='clock' /></span>
<span>
{{format-time-duration B.data.duration}}
<FlightIcon @name='target' />
{{B.data.create_time_values.target.name}}
</span>
<span>
<Hds::Badge
@text={{B.data.targetScopeDisplayName}}
@icon='grid'
/>
</span>
</div>
{{/if}}
</B.Td>
<B.Td align='right'>
{{#if (can 'read session-recording' B.data)}}
<Hds::Button
@icon='arrow-right'
@iconPosition='trailing'
@color='secondary'
@text={{t 'actions.view'}}
@route='scopes.scope.session-recordings.session-recording.channels-by-connection'
@model={{B.data.id}}
/>
{{/if}}
</B.Td>
</B.Tr>
</:body>
</Hds::Table>
</B.Td>
{{! Duration }}
<B.Td>
{{#if B.data.duration}}
<div
class='session-recording-list-cell-item hds-foreground-faint'
>
<span><FlightIcon @name='clock' /></span>
<span>
{{format-time-duration B.data.duration}}
</span>
</div>
{{/if}}
</B.Td>
<B.Td align='right'>
{{#if (can 'read session-recording' B.data)}}
<Hds::Button
@icon='arrow-right'
@iconPosition='trailing'
@color='secondary'
@text={{t 'actions.view'}}
@route='scopes.scope.session-recordings.session-recording.channels-by-connection'
@model={{B.data.id}}
/>
{{/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-recording-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-recording.title_plural')
}}
/>
</Hds::ApplicationState>
{{/if}}
{{else}}
<Rose::Layout::Centered>
<Rose::Message
<Hds::ApplicationState as |A|>
<A.Header
@title={{t 'resources.session-recording.messages.none.title'}}
as |message|
>
<message.description>
{{#if this.hasSessionRecordingsConfigured}}
{{! storage buckets exist but no recordings }}
{{t 'resources.session-recording.messages.none.description'}}
{{else}}
{{! No storage buckets exist }}
{{t
'resources.session-recording.messages.none.no-storage-bucket-description'
}}
{{/if}}
</message.description>
{{#unless this.hasSessionRecordingsConfigured}}
<Hds::Link::Standalone
/>
<A.Body
@text={{t
(if
@model.doStorageBucketsExist
'resources.session-recording.messages.none.description'
'resources.session-recording.messages.none.no-storage-bucket-description'
)
}}
/>
{{#unless @model.doStorageBucketsExist}}
<A.Footer as |F|>
<F.LinkStandalone
@icon='plus-circle'
@iconPosition='leading'
@route='scopes.scope.storage-buckets.new'
@text={{t 'resources.storage-bucket.messages.none.link'}}
@route='scopes.scope.storage-buckets.new'
/>
{{/unless}}
</Rose::Message>
</Rose::Layout::Centered>
</A.Footer>
{{/unless}}
</Hds::ApplicationState>
{{/if}}
</page.body>
</Rose::Layout::Page>
@@ -4,11 +4,20 @@
*/
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 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
import { authenticateSession } from 'ember-simple-auth/test-support';
import * as commonSelectors from 'admin/tests/helpers/selectors';
import { faker } from '@faker-js/faker';
module('Acceptance | session recordings | list', function (hooks) {
setupApplicationTest(hooks);
@@ -19,14 +28,29 @@ module('Acceptance | session recordings | list', function (hooks) {
// Selectors
const SESSION_RECORDING_TITLE = 'Session Recordings';
const SEARCH_INPUT_SELECTOR = '.search-filtering [type="search"]';
const NO_RESULTS_MSG_SELECTOR = '[data-test-no-session-recording-results]';
const FILTER_TOGGLE_SELECTOR = (name) =>
`[data-test-session-recordings-bar] div[name="${name}"] button`;
const FILTER_APPLY_BUTTON = (name) =>
`[data-test-session-recordings-bar] div[name="${name}"] div div:last-child button[type="button"]`;
const LAST_3_DAYS_OPTION =
'[data-test-session-recordings-bar] div[name="time"] li:nth-child(2) button';
// Instances
const instances = {
scopes: {
global: null,
org: null,
project: null,
project2: null,
},
target: null,
target2: null,
user: null,
user2: null,
sessionRecording: null,
sessionRecording2: null,
};
// Urls
@@ -34,6 +58,7 @@ module('Acceptance | session recordings | list', function (hooks) {
globalScope: null,
sessionRecordings: null,
sessionRecording: null,
sessionRecording2: null,
};
hooks.beforeEach(function () {
@@ -42,17 +67,57 @@ module('Acceptance | session recordings | list', function (hooks) {
type: 'org',
scope: { id: 'global', type: 'global' },
});
instances.scopes.targetModel = this.server.create('target', {
scope: instances.scopes.global,
instances.scopes.project = this.server.create('scope', {
type: 'project',
scope: { id: instances.scopes.org.id, type: 'org' },
});
instances.scopes.project2 = this.server.create('scope', {
type: 'project',
scope: { id: instances.scopes.org.id, type: 'org' },
});
instances.target = this.server.create('target', {
scope: instances.scopes.project,
});
instances.target2 = this.server.create('target', {
scope: instances.scopes.project2,
});
instances.user = this.server.create('user');
instances.user2 = this.server.create('user');
instances.sessionRecording = this.server.create('session-recording', {
scope: instances.scopes.global,
create_time_values: {
target: instances.scopes.targetModel.attrs,
target: {
id: instances.target.id,
name: instances.target.name,
scope: {
id: instances.scopes.project.id,
name: instances.scopes.project.name,
parent_scope_id: instances.scopes.org.id,
},
},
user: instances.user.attrs,
},
});
instances.sessionRecording2 = this.server.create('session-recording', {
scope: instances.scopes.global,
created_time: faker.date.past(),
create_time_values: {
target: {
id: instances.target2.id,
name: instances.target2.name,
scope: {
id: instances.scopes.project2.id,
name: instances.scopes.project2.name,
parent_scope_id: instances.scopes.org.id,
},
},
user: instances.user2.attrs,
},
});
urls.globalScope = `/scopes/global`;
urls.sessionRecordings = `${urls.globalScope}/session-recordings`;
urls.sessionRecording = `${urls.sessionRecordings}/${instances.sessionRecording.id}`;
urls.sessionRecording = `${urls.sessionRecordings}/${instances.sessionRecording.id}/channels-by-connection`;
urls.sessionRecording2 = `${urls.sessionRecordings}/${instances.sessionRecording2.id}/channels-by-connection`;
featuresService = this.owner.lookup('service:features');
featuresService.enable('ssh-session-recording');
@@ -91,4 +156,80 @@ module('Acceptance | session recordings | list', function (hooks) {
assert.dom('[title="General"]').doesNotIncludeText(SESSION_RECORDING_TITLE);
assert.dom(`[href="${urls.sessionRecordings}"]`).doesNotExist();
});
test('user can search for a session recording by id', async function (assert) {
await visit(urls.sessionRecordings);
assert.dom(commonSelectors.HREF(urls.sessionRecording)).exists();
assert.dom(commonSelectors.HREF(urls.sessionRecording2)).exists();
await fillIn(SEARCH_INPUT_SELECTOR, instances.sessionRecording.id);
await waitUntil(
() => findAll(commonSelectors.HREF(urls.sessionRecording2)).length === 0,
);
assert.dom(commonSelectors.HREF(urls.sessionRecording)).exists();
assert.dom(commonSelectors.HREF(urls.sessionRecording2)).doesNotExist();
});
test('user can search for a session recording by id and get no results', async function (assert) {
await visit(urls.sessionRecordings);
assert.dom(commonSelectors.HREF(urls.sessionRecording)).exists();
assert.dom(commonSelectors.HREF(urls.sessionRecording2)).exists();
await fillIn(SEARCH_INPUT_SELECTOR, 'sr_404');
await waitUntil(() => findAll(NO_RESULTS_MSG_SELECTOR).length === 1);
assert.dom(commonSelectors.HREF(urls.sessionRecording)).doesNotExist();
assert.dom(commonSelectors.HREF(urls.sessionRecording2)).doesNotExist();
assert.dom(NO_RESULTS_MSG_SELECTOR).includesText('No results found');
});
test('user can filter session recordings by user', async function (assert) {
await visit(urls.sessionRecordings);
assert.dom('tbody tr').exists({ count: 2 });
await click(FILTER_TOGGLE_SELECTOR('user'));
await click(`input[value="${instances.user.id}"]`);
await click(FILTER_APPLY_BUTTON('user'));
assert.dom('tbody tr').exists({ count: 1 });
});
test('user can filter session recordings by scope', async function (assert) {
await visit(urls.sessionRecordings);
assert.dom('tbody tr').exists({ count: 2 });
await click(FILTER_TOGGLE_SELECTOR('target'));
await click(`input[value="${instances.target.id}"]`);
await click(FILTER_APPLY_BUTTON('target'));
assert.dom('tbody tr').exists({ count: 1 });
});
test('user can filter session recordings by target', async function (assert) {
await visit(urls.sessionRecordings);
assert.dom('tbody tr').exists({ count: 2 });
await click(FILTER_TOGGLE_SELECTOR('scope'));
await click(`input[value="${instances.target.scope.id}"]`);
await click(FILTER_APPLY_BUTTON('scope'));
assert.dom('tbody tr').exists({ count: 1 });
});
test('user can filter session recordings by time', async function (assert) {
await visit(urls.sessionRecordings);
assert.dom('tbody tr').exists({ count: 2 });
await click(FILTER_TOGGLE_SELECTOR('time'));
await click(LAST_3_DAYS_OPTION);
assert.dom('tbody tr').exists({ count: 1 });
});
});
@@ -9,10 +9,12 @@ import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import { authenticateSession } from 'ember-simple-auth/test-support';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
module('Acceptance | session-recordings | read', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
setupIndexedDb(hooks);
let featuresService;
@@ -28,6 +30,8 @@ module('Acceptance | session-recordings | read', function (hooks) {
global: null,
org: null,
},
target: null,
user: null,
sessionRecording: null,
};
// Urls
@@ -47,10 +51,12 @@ module('Acceptance | session-recordings | read', function (hooks) {
instances.target = this.server.create('target', {
scope: instances.scopes.global,
});
instances.user = this.server.create('user');
instances.sessionRecording = this.server.create('session-recording', {
scope: instances.scopes.global,
create_time_values: {
target: instances.target.attrs,
user: instances.user.attrs,
},
});
instances.connectionRecording = this.server.create('connection-recording', {
@@ -9,24 +9,30 @@ import { setupApplicationTest } from 'admin/tests/helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { authenticateSession } from 'ember-simple-auth/test-support';
import { Response } from 'miragejs';
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
module(
'Acceptance | session-recordings | session-recording | channels-by-connection | channel',
function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
setupIndexedDb(hooks);
let featuresService;
let getRecordingCount;
const DELETE_DROPDOWN_SELECTOR = '[data-test-manage-dropdown-delete]';
const DROPDOWN_SELECTOR = '[data-test-manage-dropdown]';
const ERROR_MSG_SELECTOR = '.rose-notification-body';
// Instances
const instances = {
scopes: {
global: null,
org: null,
},
target: null,
user: null,
sessionRecording: null,
connectionRecording: null,
channelRecording: null,
@@ -46,13 +52,15 @@ module(
type: 'org',
scope: { id: 'global', type: 'global' },
});
instances.scopes.targetModel = this.server.create('target', {
instances.target = this.server.create('target', {
scope: instances.scopes.global,
});
instances.user = this.server.create('user');
instances.sessionRecording = this.server.create('session-recording', {
scope: instances.scopes.global,
create_time_values: {
target: instances.scopes.targetModel.attrs,
target: instances.target.attrs,
user: instances.user.attrs,
},
});
instances.connectionRecording = this.server.create(
@@ -153,6 +161,7 @@ module(
scope: instances.scopes.global,
});
const incorrectUrl = `${urls.sessionRecordings}/${sessionRecording.id}/channels-by-connection/${instances.channelRecording.id}`;
await visit(urls.sessionRecording);
await visit(incorrectUrl);
@@ -160,6 +169,23 @@ module(
assert.strictEqual(currentURL(), urls.channelRecording);
});
test('users are redirected to session-recordings list with incorrect url', async function (assert) {
featuresService.enable('ssh-session-recording');
const sessionRecording = this.server.create('session-recording', {
scope: instances.scopes.global,
create_time_values: {
target: instances.target.attrs,
user: instances.user.attrs,
},
});
const incorrectUrl = `${urls.sessionRecordings}/${sessionRecording.id}/channels-by-connection/${instances.channelRecording.id}`;
await visit(incorrectUrl);
assert.strictEqual(currentURL(), urls.sessionRecordings);
assert.dom(ERROR_MSG_SELECTOR).hasText('Resource not found');
});
test('user cannot view manage dropdown without proper authorization', async function (assert) {
// Visit channel
featuresService.enable('ssh-session-recording');
@@ -5,17 +5,202 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { waitUntil } from '@ember/test-helpers';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import { setupIntl } from 'ember-intl/test-support';
import sinon from 'sinon';
module(
'Unit | Controller | scopes/scope/session-recordings/index',
function (hooks) {
setupTest(hooks);
setupMirage(hooks);
setupIntl(hooks, 'en-us');
test('it exists', function (assert) {
let controller = this.owner.lookup(
let controller;
let model;
let date;
let last24Hours, last3Days, last7Days;
const instances = {
scopes: {
global: null,
org: null,
project: null,
},
target: null,
user: null,
sessionRecording: null,
storageBucket: null,
};
hooks.beforeEach(function () {
date = sinon.useFakeTimers(new Date(2024, 9, 19).getTime());
const now = new Date();
last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
last3Days = new Date(now);
last3Days.setDate(now.getDate() - 3);
last7Days = new Date(now);
last7Days.setDate(now.getDate() - 7);
controller = this.owner.lookup(
'controller:scopes/scope/session-recordings/index',
);
instances.scopes.global = this.server.create('scope', { id: 'global' });
instances.scopes.org = this.server.create('scope', {
type: 'org',
scope: { id: 'global', type: 'global' },
});
instances.scopes.project = this.server.create('scope', {
type: 'project',
parent_scope_id: instances.scopes.org.id,
scope: { id: instances.scopes.org.id, type: 'org' },
});
instances.target = this.server.create('target', {
scope: instances.scopes.project,
});
instances.user = this.server.create('user');
instances.sessionRecording = this.server.create('session-recording', {
scope: instances.scopes.global,
create_time_values: {
target: {
id: instances.target.id,
name: instances.target.name,
scope: {
id: instances.scopes.project.id,
name: instances.scopes.project.name,
parent_scope_id: instances.scopes.org.id,
},
},
user: instances.user.attrs,
},
});
instances.storageBucket = this.server.create('storage-bucket', {
scope: instances.scopes.global,
});
model = {
sessionRecordings: [instances.sessionRecording],
doSessionRecordingsExist: true,
allSessionRecordings: [instances.sessionRecording],
totalItems: 1,
doStorageBucketsExist: true,
};
controller.set('model', model);
});
hooks.afterEach(function () {
date.restore();
});
test('it exists', function (assert) {
assert.ok(controller);
});
test('filters returns expected entries', function (assert) {
assert.deepEqual(controller.filters.allFilters, {
time: [
{
id: last24Hours.toISOString(),
name: 'Last 24 hours',
},
{
id: last3Days.toISOString(),
name: 'Last 3 days',
},
{
id: last7Days.toISOString(),
name: 'Last 7 days',
},
],
users: [{ id: instances.user.id, name: instances.user.name }],
scopes: [
{
id: instances.scopes.project.id,
name: instances.scopes.project.name,
parent_scope_id: instances.scopes.project.parent_scope_id,
},
],
targets: [{ id: instances.target.id, name: instances.target.name }],
});
assert.deepEqual(controller.filters.selectedFilters, {
time: [null],
users: [],
scopes: [],
targets: [],
});
});
test('timeOptions returns expected filter options', function (assert) {
assert.deepEqual(controller.timeOptions, [
{
id: last24Hours.toISOString(),
name: 'Last 24 hours',
},
{
id: last3Days.toISOString(),
name: 'Last 3 days',
},
{
id: last7Days.toISOString(),
name: 'Last 7 days',
},
]);
});
test('projectScopes returns an array of unique projects', function (assert) {
assert.deepEqual(controller.projectScopes, [
{
id: instances.scopes.project.id,
name: instances.scopes.project.name,
parent_scope_id: instances.scopes.project.parent_scope_id,
},
]);
});
test('handleSearchInput action sets expected values correctly', async function (assert) {
// Date mock in beforeEach is causing waitUntil to fail so I restore it
// back before running this test
date.restore();
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) {
const selectedItems = ['admin'];
controller.applyFilter('users', selectedItems);
assert.strictEqual(controller.page, 1);
assert.deepEqual(controller.users, selectedItems);
});
test('changeTimeFilter action sets the time filter', function (assert) {
assert.expect(3);
this.onClose = () => assert.ok(true, 'onClose was called');
controller.changeTimeFilter(last24Hours.toISOString(), this.onClose);
assert.strictEqual(controller.page, 1);
assert.deepEqual(controller.time, last24Hours.toISOString());
});
test('refresh action calls refreshAll', async function (assert) {
assert.expect(2);
controller.set('target', {
send(actionName, ...args) {
assert.strictEqual(actionName, 'refreshAll');
assert.deepEqual(
args,
[],
'refreshAll was called with the correct arguments',
);
},
});
await controller.refresh();
});
},
);