diff --git a/addons/api/addon/services/indexed-db.js b/addons/api/addon/services/indexed-db.js index ca067ba5c5..898cda3b65 100644 --- a/addons/api/addon/services/indexed-db.js +++ b/addons/api/addon/services/indexed-db.js @@ -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); } /** diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index dd36763760..ccf3639a99 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -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 } }) => { diff --git a/addons/api/tests/unit/utils/indexed-db-query-test.js b/addons/api/tests/unit/utils/indexed-db-query-test.js index 45b2cb15fe..955e3059fe 100644 --- a/addons/api/tests/unit/utils/indexed-db-query-test.js +++ b/addons/api/tests/unit/utils/indexed-db-query-test.js @@ -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); + }); }); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 4d1a6270e4..eaad618a78 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -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} diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js index 1ebf45d8ea..7aa719879f 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -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 object of filters to be used for displaying selected filters + * @returns {object} + */ + 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 array of time options for time filter + * @returns {[object]} + */ + 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; + } + /** - * Returns true if any session recordings exist - * @type {boolean} + * Sets the time filter, sets the page to 1, and closes the filter dropdown + * @param {object} timeId + * @param {func} onClose */ - get hasSessionRecordings() { - return this.model?.sessionRecordings?.length; + @action + changeTimeFilter(timeId, onClose) { + this.time = timeId; + this.page = 1; + onClose(); } /** - * Returns true if any storage buckets exist - * @type {boolean} + * Refreshes the all data for the current page */ - get hasSessionRecordingsConfigured() { - return this.model?.storageBuckets?.length; + @action + refresh() { + this.send('refreshAll'); } } diff --git a/ui/admin/app/routes/scopes/scope/session-recordings.js b/ui/admin/app/routes/scopes/scope/session-recordings.js index 717c74c858..e516633c89 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings.js @@ -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, - }; - } - } } diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/index.js b/ui/admin/app/routes/scopes/scope/session-recordings/index.js index 842e83672a..1b3bcf23b5 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings/index.js @@ -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} + */ + 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); + } +} diff --git a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js index a67f464088..497f38a3a8 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings/session-recording/channels-by-connection/channel.js @@ -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'); } } } diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs index 68660eaefd..56c1c65135 100644 --- a/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs +++ b/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs @@ -15,118 +15,240 @@

{{t 'resources.session-recording.description'}}

- - {{#if this.hasSessionRecordings}} - - <:body as |B|> - - {{! Created Time }} - - {{#if B.data.created_time}} - - - - {{/if}} - - {{! Status }} - - - - {{! User }} - -
- - {{B.data.userDisplayName}} -
-
- {{! Target }} - -
+ {{#if @model.doSessionRecordingsExist}} +
+ + + + + {{#each this.timeOptions as |time|}} + - - - {{B.data.create_time_values.target.name}} - - - - -
- - {{! Duration }} - - {{#if B.data.duration}} + {{time.name}} + + {{/each}} + + + + {{#each itemOptions as |itemOption|}} + + {{itemOption.name}} + + {{/each}} + + + + + {{#each-in + (group-by 'parent_scope_id' itemOptions) + as |orgId items| + }} + + + {{this.orgName orgId}} + + {{#each items as |project|}} + + {{project.name}} + + {{/each}} + {{/each-in}} + + + + + {{#each itemOptions as |itemOption|}} + + {{itemOption.name}} + + {{/each}} + + + + + + +
+ + + + {{#if @model.sessionRecordings}} + + <:body as |B|> + + {{! Created Time }} + + {{#if B.data.created_time}} + + + + {{/if}} + + {{! Status }} + + + + {{! User }} +
- + + {{B.data.userDisplayName}} +
+
+ {{! Target }} + +
+ + + {{B.data.create_time_values.target.name}} + - {{format-time-duration B.data.duration}} +
- {{/if}} -
- - {{#if (can 'read session-recording' B.data)}} - - {{/if}} - -
- -
+
+ {{! Duration }} + + {{#if B.data.duration}} +
+ + + {{format-time-duration B.data.duration}} + +
+ {{/if}} +
+ + {{#if (can 'read session-recording' B.data)}} + + {{/if}} + +
+ +
+ + {{else}} + + + + + {{/if}} {{else}} - - + - - {{#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}} - - {{#unless this.hasSessionRecordingsConfigured}} - + + {{#unless @model.doStorageBucketsExist}} + + - {{/unless}} - - + + {{/unless}} + {{/if}}
\ No newline at end of file diff --git a/ui/admin/tests/acceptance/session-recordings/list-test.js b/ui/admin/tests/acceptance/session-recordings/list-test.js index 3f2b457a4b..42e077c066 100644 --- a/ui/admin/tests/acceptance/session-recordings/list-test.js +++ b/ui/admin/tests/acceptance/session-recordings/list-test.js @@ -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 }); + }); }); diff --git a/ui/admin/tests/acceptance/session-recordings/read-test.js b/ui/admin/tests/acceptance/session-recordings/read-test.js index 3253318828..542ce77afd 100644 --- a/ui/admin/tests/acceptance/session-recordings/read-test.js +++ b/ui/admin/tests/acceptance/session-recordings/read-test.js @@ -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', { diff --git a/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js b/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js index 248150caa5..14cda35542 100644 --- a/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js +++ b/ui/admin/tests/acceptance/session-recordings/session-recording/channels-by-connection/channel-test.js @@ -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'); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js index 84215df6bb..8e5cecb34a 100644 --- a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/index-test.js @@ -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(); + }); }, );