diff --git a/addons/api/addon/handlers/sqlite-handler.js b/addons/api/addon/handlers/sqlite-handler.js index b68a10e29d..85317364a3 100644 --- a/addons/api/addon/handlers/sqlite-handler.js +++ b/addons/api/addon/handlers/sqlite-handler.js @@ -49,7 +49,13 @@ export default class SqliteHandler { const schema = store.modelFor(type); const serializer = store.serializerFor(type); - let { page, pageSize, query: queryObj, ...remainingQuery } = query; + let { + page, + pageSize, + select, + query: queryObj, + ...remainingQuery + } = query; let payload, listToken, writeToDbPromise, @@ -119,7 +125,7 @@ export default class SqliteHandler { const { sql, parameters } = generateSQLExpressions(type, queryObj, { page, pageSize, - select: ['data'], + select: select ?? [{ field: 'data' }], }); const rows = await this.sqlite.fetchResource({ @@ -129,7 +135,7 @@ export default class SqliteHandler { const { sql: countSql, parameters: countParams } = generateSQLExpressions(type, queryObj, { - select: ['count(*) as total'], + select: [{ field: '*', isCount: true, alias: 'total' }], }); const count = await this.sqlite.fetchResource({ sql: countSql, diff --git a/addons/api/addon/services/db.js b/addons/api/addon/services/db.js new file mode 100644 index 0000000000..07d0ace17a --- /dev/null +++ b/addons/api/addon/services/db.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Service, { service } from '@ember/service'; +import { modelMapping } from 'api/services/sqlite'; +import { assert } from '@ember/debug'; +import { generateSQLExpressions } from '../utils/sqlite-query'; + +/** + * Database service for querying Boundary resources from storage. + * Provides a high-level interface for executing queries against the local database. + */ +export default class DbService extends Service { + @service sqlite; + + /** + * Query resources from the database by type. + * + * @param {string} type - The resource type (e.g., 'scope', 'user', 'target'). + * Must be a supported model type defined in modelMapping. + * @param {Object} query - Query configuration object + * @returns {Promise} Promise resolving to query results from sqlite service + * @throws {Error} Assertion error if resource type is not supported + */ + query(type, query) { + const supportedModels = Object.keys(modelMapping); + assert('Resource type is not supported.', supportedModels.includes(type)); + + let { page, pageSize, select, query: queryObj } = query; + + const { sql, parameters } = generateSQLExpressions(type, queryObj, { + page, + pageSize, + select, + }); + + return this.sqlite.fetchResource({ + sql, + parameters, + }); + } +} diff --git a/addons/api/addon/services/sqlite.js b/addons/api/addon/services/sqlite.js index b931bb95aa..d31f2d0f8f 100644 --- a/addons/api/addon/services/sqlite.js +++ b/addons/api/addon/services/sqlite.js @@ -100,6 +100,8 @@ export const modelMapping = { target_name: 'create_time_values.target.name', target_scope_id: 'create_time_values.target.scope.id', target_scope_name: 'create_time_values.target.scope.name', + target_scope_parent_scope_id: + 'create_time_values.target.scope.parent_scope_id', created_time: 'created_time', }, session: { @@ -114,21 +116,6 @@ export const modelMapping = { }, }; -// A list of tables that we support searching using FTS5 in SQLite. -export const searchTables = new Set([ - 'target', - 'alias', - 'group', - 'role', - 'user', - 'credential-store', - 'scope', - 'auth-method', - 'host-catalog', - 'session-recording', - 'session', -]); - export default class SqliteDbService extends Service { // =attributes diff --git a/addons/api/addon/utils/sqlite-query.js b/addons/api/addon/utils/sqlite-query.js index 458a657fa2..4ee231f6d0 100644 --- a/addons/api/addon/utils/sqlite-query.js +++ b/addons/api/addon/utils/sqlite-query.js @@ -4,9 +4,9 @@ */ import { modelMapping } from 'api/services/sqlite'; -import { searchTables } from 'api/services/sqlite'; import { typeOf } from '@ember/utils'; import { underscore } from '@ember/string'; +import { assert } from '@ember/debug'; /** * Takes a POJO representing a filter query and builds a SQL query. @@ -35,8 +35,8 @@ export function generateSQLExpressions( addFilterConditions({ filters, parameters, conditions }); addSearchConditions({ search, resource, tableName, parameters, conditions }); + const selectClause = constructSelectClause(select, tableName); const orderByClause = constructOrderByClause(resource, sort); - const whereClause = conditions.length ? `WHERE ${and(conditions)}` : ''; const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : ''; @@ -44,8 +44,6 @@ export function generateSQLExpressions( parameters.push(pageSize, (page - 1) * pageSize); } - const selectClause = `SELECT ${select ? select.join(', ') : '*'} FROM "${tableName}"`; - return { // Replace any empty newlines or leading whitespace on each line to be consistent with formatting // This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution. @@ -142,32 +140,83 @@ function addSearchConditions({ parameters, conditions, }) { - if (!search) { + if (!search || !modelMapping[resource]) { return; } - if (searchTables.has(resource)) { - // Use the special prefix indicator "*" for full-text search - parameters.push(`"${search}"*`); - // Use a subquery to match against the FTS table with rowids as SQLite is - // much more efficient with FTS queries when using rowids or MATCH (or both). - // We could have also used a join here but a subquery is simpler. - conditions.push( - `rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`, + // Use the special prefix indicator "*" for full-text search + if (typeOf(search) === 'object') { + if (!search?.text) { + return; + } + + parameters.push( + or(search.fields.map((field) => `${field}:"${search.text}"*`)), ); - return; + } else { + parameters.push(`"${search}"*`); } - const fields = Object.keys(modelMapping[resource]); - const searchConditions = parenthetical( - or( - fields.map((field) => { - parameters.push(`%${search}%`); - return `${field}${OPERATORS['contains']}`; - }), - ), + // Use a subquery to match against the FTS table with rowids as SQLite is + // much more efficient with FTS queries when using rowids or MATCH (or both). + // We could have also used a join here but a subquery is simpler. + conditions.push( + `rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`, ); - conditions.push(searchConditions); +} + +function constructSelectClause(select = [{ field: '*' }], tableName) { + const distinctColumns = select.filter(({ isDistinct }) => isDistinct); + const nonDistinctColumns = select.filter(({ isDistinct }) => !isDistinct); + + const buildColumnExpression = ({ field, isCount, alias, isDistinct }) => { + let column = field; + + // If there's just distinct with nothing else, this will be handled separately + // and we will just return the column back as is + if (isCount && isDistinct) { + column = `count(DISTINCT ${column})`; + } else if (isCount) { + column = `count(${column})`; + } + + if (alias) { + column = `${column} as ${alias}`; + } + + return column; + }; + + let selectColumns; + + if (distinctColumns.length > 0) { + // Check if any columns also have COUNT (or other aggregate function in the future) + const hasAggregateDistinct = distinctColumns.some((col) => col.isCount); + + if (hasAggregateDistinct) { + selectColumns = distinctColumns.map(buildColumnExpression).join(', '); + + // Add back in the non distinct columns + if (nonDistinctColumns.length > 0) { + const regularPart = nonDistinctColumns + .map(buildColumnExpression) + .join(', '); + selectColumns = `${selectColumns}, ${regularPart}`; + } + } else { + assert( + 'Can not combine non-distincts with multi column distincts', + nonDistinctColumns.length === 0, + ); + + // Only do multi column DISTINCT with no aggregate functions + selectColumns = `DISTINCT ${distinctColumns.map(buildColumnExpression).join(', ')}`; + } + } else { + selectColumns = select.map(buildColumnExpression).join(', '); + } + + return `SELECT ${selectColumns} FROM "${tableName}"`; } function constructOrderByClause(resource, sort) { diff --git a/addons/api/addon/workers/utils/schema.js b/addons/api/addon/workers/utils/schema.js index 52b22e7d55..fd762cc934 100644 --- a/addons/api/addon/workers/utils/schema.js +++ b/addons/api/addon/workers/utils/schema.js @@ -352,6 +352,7 @@ CREATE TABLE IF NOT EXISTS session_recording ( target_name TEXT, target_scope_id TEXT, target_scope_name TEXT, + target_scope_parent_scope_id TEXT, created_time TEXT NOT NULL, data TEXT NOT NULL ); @@ -371,21 +372,22 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5( target_name, target_scope_id, target_scope_name, + target_scope_parent_scope_id, created_time, content='', ); CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN INSERT INTO session_recording_fts( - rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time + rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time ) VALUES ( - new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.created_time + new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.target_scope_parent_scope_id, new.created_time ); END; CREATE TRIGGER IF NOT EXISTS session_recording_ad AFTER DELETE ON session_recording BEGIN - INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time) - VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.created_time); + INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time) + VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.target_scope_parent_scope_id, old.created_time); END;`; const createSessionTables = ` diff --git a/addons/api/app/services/db.js b/addons/api/app/services/db.js new file mode 100644 index 0000000000..b025e38275 --- /dev/null +++ b/addons/api/app/services/db.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'api/services/db'; diff --git a/addons/api/tests/unit/services/db-test.js b/addons/api/tests/unit/services/db-test.js new file mode 100644 index 0000000000..62d99cd611 --- /dev/null +++ b/addons/api/tests/unit/services/db-test.js @@ -0,0 +1,19 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | db', function (hooks) { + setupTest(hooks); + + test('query throws assertion error for unsupported resource type', function (assert) { + let service = this.owner.lookup('service:db'); + + assert.throws(() => { + service.query('unsupported-type', { query: {} }); + }, /Resource type is not supported/); + }); +}); diff --git a/addons/api/tests/unit/services/sqlite-test.js b/addons/api/tests/unit/services/sqlite-test.js index 88e84c914c..cdf92e2796 100644 --- a/addons/api/tests/unit/services/sqlite-test.js +++ b/addons/api/tests/unit/services/sqlite-test.js @@ -6,11 +6,10 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; -import { modelMapping, searchTables } from 'api/services/sqlite'; +import { modelMapping } from 'api/services/sqlite'; import { underscore } from '@ember/string'; const supportedModels = Object.keys(modelMapping); -const supportedFtsTables = [...searchTables]; module('Unit | Service | sqlite', function (hooks) { setupTest(hooks); @@ -38,7 +37,7 @@ module('Unit | Service | sqlite', function (hooks) { test.each( 'Mapping matches fts table columns', - supportedFtsTables, + supportedModels, async function (assert, resource) { const service = this.owner.lookup('service:sqlite'); diff --git a/addons/api/tests/unit/utils/sqlite-query-test.js b/addons/api/tests/unit/utils/sqlite-query-test.js index 57a47cbfe3..a7e134b610 100644 --- a/addons/api/tests/unit/utils/sqlite-query-test.js +++ b/addons/api/tests/unit/utils/sqlite-query-test.js @@ -20,21 +20,6 @@ module('Unit | Utility | sqlite-query', function (hooks) { delete String.prototype.removeExtraWhiteSpace; }); - // TODO: Add a normal LIKE search when we add a resource that uses it - test('it generates search correctly with FTS5', function (assert) { - const query = { search: 'favorite' }; - - const { sql, parameters } = generateSQLExpressions('target', query); - assert.strictEqual( - sql, - ` - SELECT * FROM "target" - WHERE rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?) - ORDER BY created_time DESC`.removeExtraWhiteSpace(), - ); - assert.deepEqual(parameters, ['"favorite"*']); - }); - test('it grabs resources not in the model mapping', function (assert) { const query = { filters: { id: [{ equals: 'tokenKey' }] }, @@ -52,7 +37,7 @@ module('Unit | Utility | sqlite-query', function (hooks) { test('it executes count queries correctly', function (assert) { const select = { - select: ['count(*) as total'], + select: [{ field: '*', isCount: true, alias: 'total' }], }; const { sql, parameters } = generateSQLExpressions('target', {}, select); @@ -65,6 +50,123 @@ module('Unit | Utility | sqlite-query', function (hooks) { assert.deepEqual(parameters, []); }); + test.each( + 'it generates select clause with various options', + { + 'single field': { + select: [{ field: 'name' }], + expectedSelect: 'name', + }, + 'multiple fields': { + select: [{ field: 'name' }, { field: 'type' }, { field: 'id' }], + expectedSelect: 'name, type, id', + }, + 'field with alias': { + select: [{ field: 'name', alias: 'resource_name' }], + expectedSelect: 'name as resource_name', + }, + 'multiple fields with aliases': { + select: [ + { field: 'name', alias: 'resource_name' }, + { field: 'type', alias: 'resource_type' }, + ], + expectedSelect: 'name as resource_name, type as resource_type', + }, + 'count with alias': { + select: [{ field: 'id', isCount: true, alias: 'total_count' }], + expectedSelect: 'count(id) as total_count', + }, + 'count without alias': { + select: [{ field: '*', isCount: true }], + expectedSelect: 'count(*)', + }, + 'count distinct': { + select: [{ field: 'status', isCount: true, isDistinct: true }], + expectedSelect: 'count(DISTINCT status)', + }, + 'count distinct with alias': { + select: [ + { + field: 'type', + isCount: true, + isDistinct: true, + alias: 'unique_types', + }, + ], + expectedSelect: 'count(DISTINCT type) as unique_types', + }, + 'mixed regular and count fields': { + select: [ + { field: 'name' }, + { field: 'id', isCount: true, alias: 'count' }, + ], + expectedSelect: 'name, count(id) as count', + }, + 'field with distinct': { + select: [{ field: 'type', isDistinct: true }], + expectedSelect: 'DISTINCT type', + }, + 'multiple distinct fields': { + select: [ + { field: 'type', isDistinct: true }, + { field: 'status', isDistinct: true }, + ], + expectedSelect: 'DISTINCT type, status', + }, + 'multiple distinct with count fields': { + select: [ + { field: 'type', isDistinct: true, isCount: true }, + { field: 'status', isDistinct: true, isCount: true }, + ], + expectedSelect: 'count(DISTINCT type), count(DISTINCT status)', + }, + 'distinct with alias': { + select: [{ field: 'type', isDistinct: true, alias: 'unique_type' }], + expectedSelect: 'DISTINCT type as unique_type', + }, + 'multiple distinct fields with aliases': { + select: [ + { field: 'type', isDistinct: true, alias: 'unique_type' }, + { field: 'status', isDistinct: true, alias: 'unique_status' }, + ], + expectedSelect: 'DISTINCT type as unique_type, status as unique_status', + }, + 'count distinct multiple fields': { + select: [ + { + field: 'type', + isCount: true, + isDistinct: true, + alias: 'type_count', + }, + { + field: 'status', + isCount: true, + isDistinct: true, + alias: 'status_count', + }, + ], + expectedSelect: + 'count(DISTINCT type) as type_count, count(DISTINCT status) as status_count', + }, + }, + function (assert, { select, expectedSelect }) { + const { sql, parameters } = generateSQLExpressions( + 'target', + {}, + { select }, + ); + + assert.strictEqual( + sql, + ` + SELECT ${expectedSelect} FROM "target" + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, []); + }, + ); + test.each( 'it generates filters correctly', { @@ -283,6 +385,31 @@ module('Unit | Utility | sqlite-query', function (hooks) { ]); }); + test('it generates FTS5 search with object parameter', function (assert) { + const query = { + search: { + text: 'favorite', + fields: ['name', 'description'], + }, + filters: { + type: [{ equals: 'ssh' }], + }, + }; + + const { sql, parameters } = generateSQLExpressions('target', query); + assert.strictEqual( + sql, + ` + SELECT * FROM "target" + WHERE (type = ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?) + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, [ + 'ssh', + 'name:"favorite"* OR description:"favorite"*', + ]); + }); + test('it generates SQL with all clauses combined', function (assert) { const query = { search: 'favorite', @@ -300,7 +427,7 @@ module('Unit | Utility | sqlite-query', function (hooks) { const { sql, parameters } = generateSQLExpressions('target', query, { page: 2, pageSize: 15, - select: ['data'], + select: [{ field: 'data' }], }); assert.strictEqual( @@ -323,6 +450,35 @@ module('Unit | Utility | sqlite-query', function (hooks) { ]); }); + test.each( + 'it throws assertion error for invalid select combinations', + { + 'mixing distinct with non-distinct columns': { + select: [ + { field: 'type', isDistinct: true }, + { field: 'status', isDistinct: true }, + { field: 'name' }, + ], + expectedError: + /Can not combine non-distincts with multi column distincts/, + }, + 'mixing distinct with aggregate non-distinct columns': { + select: [ + { field: 'type', isDistinct: true }, + { field: 'status', isDistinct: true }, + { field: 'name', isCount: true }, + ], + expectedError: + /Can not combine non-distincts with multi column distincts/, + }, + }, + function (assert, { select, expectedError }) { + assert.throws(() => { + generateSQLExpressions('target', {}, { select }); + }, expectedError); + }, + ); + test.each( 'it handles empty and invalid fields', { diff --git a/addons/core/addon/components/dropdown/index.hbs b/addons/core/addon/components/dropdown/index.hbs index 5bea7ab433..3a23c44842 100644 --- a/addons/core/addon/components/dropdown/index.hbs +++ b/addons/core/addon/components/dropdown/index.hbs @@ -20,7 +20,7 @@ {{/if}} - {{#unless this.itemOptions}} + + {{#unless (or this.itemOptions @isLoading)}} {{/unless}} - {{#if (has-block)}} + {{#if @isLoading}} + + + + + + {{else if (has-block)}} {{yield DD this.selectItem this.itemOptions}} {{else}} {{#each this.itemOptions as |itemOption|}} @@ -47,13 +54,15 @@ {{/each}} {{/if}} - - - + {{#unless @isLoading}} + + + + {{/unless}} \ No newline at end of file diff --git a/addons/core/addon/components/dropdown/index.js b/addons/core/addon/components/dropdown/index.js index f139dbf1db..a06fb3ac5e 100644 --- a/addons/core/addon/components/dropdown/index.js +++ b/addons/core/addon/components/dropdown/index.js @@ -21,7 +21,8 @@ export default class DropdownComponent extends Component { */ get itemOptions() { let items = this.args.itemOptions; - if (this.searchTerm) { + + if (this.searchTerm && !this.args.updateSearchTerm) { const searchTerm = this.searchTerm.toLowerCase(); items = this.args.itemOptions.filter((item) => { const isNameMatch = item.name?.toLowerCase().includes(searchTerm); @@ -29,7 +30,7 @@ export default class DropdownComponent extends Component { return isNameMatch || isIdMatch; }); } - return items.slice(0, 500); + return items.slice(0, 250); } // =methods @@ -39,10 +40,15 @@ export default class DropdownComponent extends Component { * @param {object} event */ @action - @debounce(150) - filterItems(event) { + @debounce(250) + async filterItems(event) { const { value } = event.target; - this.searchTerm = value; + + if (this.args.updateSearchTerm) { + this.args.updateSearchTerm(value); + } else { + this.searchTerm = value; + } } /** diff --git a/addons/core/addon/styles/addon.scss b/addons/core/addon/styles/addon.scss index 2a9218e05d..a5f0eef3fc 100644 --- a/addons/core/addon/styles/addon.scss +++ b/addons/core/addon/styles/addon.scss @@ -3,6 +3,9 @@ * SPDX-License-Identifier: BUSL-1.1 */ +@use 'rose/app/styles/rose/variables/sizing' as sizing; +@use 'sass:meta'; + .filters-applied { display: flex; flex-wrap: wrap; @@ -72,3 +75,34 @@ margin-bottom: 0.5rem; } } + +$margin-directions: ( + '': 'margin', + '-top': 'margin-top', + '-right': 'margin-right', + '-bottom': 'margin-bottom', + '-left': 'margin-left', + '-x': ( + 'margin-left', + 'margin-right', + ), + '-y': ( + 'margin-top', + 'margin-bottom', + ), +); + +// Generate margin utility classes with sizes and directions +@each $size-name, $size-coefficient in sizing.$size-coefficients { + @each $direction-suffix, $properties in $margin-directions { + .margin#{$direction-suffix}-#{$size-name} { + @if meta.type-of($properties) == 'list' { + @each $property in $properties { + #{$property}: #{sizing.rems($size-name)}; + } + } @else { + #{$properties}: #{sizing.rems($size-name)}; + } + } + } +} diff --git a/addons/core/tests/integration/components/dropdown/index-test.js b/addons/core/tests/integration/components/dropdown/index-test.js index 54a061ea72..5f95c4c94b 100644 --- a/addons/core/tests/integration/components/dropdown/index-test.js +++ b/addons/core/tests/integration/components/dropdown/index-test.js @@ -127,7 +127,7 @@ module('Integration | Component | dropdown/index', function (hooks) { assert.true(result); }); - test('it only renders a max of 500 items but allows searching on all items', async function (assert) { + test('it only renders a max of 250 items but allows searching on all items', async function (assert) { this.set('checkedItems', []); this.set( 'itemOptions', @@ -145,7 +145,7 @@ module('Integration | Component | dropdown/index', function (hooks) { await click(TOGGLE_DROPDOWN_SELECTOR); - assert.dom(ITEM_SELECTOR).exists({ count: 500 }); + assert.dom(ITEM_SELECTOR).exists({ count: 250 }); // Random number between 500 and 599 to search await fillIn(SEARCH_INPUT_SELECTOR, '591'); 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 e025b20ad1..658458fb51 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -7,12 +7,20 @@ import Controller from '@ember/controller'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { service } from '@ember/service'; +import { assert } from '@ember/debug'; +import { restartableTask } from 'ember-concurrency'; + +class FilterOptions { + @tracked search; + @tracked options = []; +} export default class ScopesScopeSessionRecordingsIndexController extends Controller { // =services @service store; @service intl; + @service db; // =attributes @@ -30,6 +38,10 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control now = new Date(); + userFilters = new FilterOptions(); + scopeFilters = new FilterOptions(); + targetFilters = new FilterOptions(); + @tracked search; @tracked time = null; @tracked users = []; @@ -48,9 +60,9 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control return { allFilters: { time: this.timeOptions, - users: this.filterOptions('user'), - scopes: this.projectScopes, - targets: this.filterOptions('target'), + users: this.userFilters.options, + scopes: this.scopeFilters.options, + targets: this.targetFilters.options, }, selectedFilters: { time: [this.time], @@ -95,29 +107,87 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control } /** - * Returns unique project scopes from targets - * linked to the session recordings + * Configurations for different filter options * @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 }); - } + get filterConfigs() { + return { + userFilters: { + select: [ + { field: 'user_id', isDistinct: true }, + { field: 'user_name', isDistinct: true }, + ], + searchFields: ['user_id', 'user_name'], + mapper: ({ user_id, user_name }) => ({ + id: user_id, + name: user_name, + }), + }, + scopeFilters: { + select: [ + { field: 'target_scope_id', isDistinct: true }, + { field: 'target_scope_name', isDistinct: true }, + { field: 'target_scope_parent_scope_id', isDistinct: true }, + ], + searchFields: [ + 'target_scope_id', + 'target_scope_name', + 'target_scope_parent_scope_id', + ], + mapper: ({ + target_scope_id, + target_scope_name, + target_scope_parent_scope_id, + }) => ({ + id: target_scope_id, + name: target_scope_name ?? target_scope_id, + parent_scope_id: target_scope_parent_scope_id, + }), + }, + targetFilters: { + select: [ + { field: 'target_id', isDistinct: true }, + { field: 'target_name', isDistinct: true }, + ], + searchFields: ['target_id', 'target_name'], + mapper: ({ target_id, target_name }) => ({ + id: target_id, + name: target_name, + }), + }, + }; + } + + /** + * Generic retrieve function for session recording options + * @param {string} type - The type of options to retrieve (user, scope, target) + * @param {string} search - Search term + * @returns {Promise} + */ + async retrieveFilterOptions(type, search) { + const config = this.filterConfigs[type]; + assert(`Unknown filter type: ${type}`, config); + + const results = await this.db.query('session-recording', { + select: config.select, + query: { + search: { text: search, fields: config.searchFields }, }, - ); - return Array.from(uniqueMap.values()); + page: 1, + pageSize: 250, + }); + + return results.map(config.mapper); } + loadItems = restartableTask(async () => { + this.userFilters.options = await this.retrieveFilterOptions('userFilters'); + this.scopeFilters.options = + await this.retrieveFilterOptions('scopeFilters'); + this.targetFilters.options = + await this.retrieveFilterOptions('targetFilters'); + }); + // =actions /** @@ -131,28 +201,6 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control 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 @@ -192,7 +240,7 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control */ @action refresh() { - this.send('refreshAll'); + this.router.refresh('scopes.scope.session-recordings'); } /** @@ -206,4 +254,9 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control this.sortDirection = sortOrder; this.page = 1; } + + onFilterSearch = restartableTask(async (filter, value) => { + this[filter].search = value; + this[filter].options = await this.retrieveFilterOptions(filter, value); + }); } 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 dd9f3f15d0..74ae99c0da 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings/index.js @@ -5,7 +5,6 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; -import { action } from '@ember/object'; import { restartableTask, timeout } from 'ember-concurrency'; import { STATE_SESSION_RECORDING_STARTED, @@ -59,8 +58,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { }, }; - allSessionRecordings; - /** * Load all session recordings. * @return {Promise<{ totalItems: number, sessionRecordings: [SessionRecordingModel], doSessionRecordingsExist: boolean, doStorageBucketsExist: boolean }>} @@ -144,10 +141,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { 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 = await this.getDoSessionRecordingsExist( scope_id, totalItems, @@ -157,7 +150,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { return { sessionRecordings, doSessionRecordingsExist: doSessionRecordingsExist, - allSessionRecordings: this.allSessionRecordings, totalItems, doStorageBucketsExist: doStorageBucketsExist, }; @@ -165,22 +157,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { }, ); - /** - * Sets allSessionRecordings to all session recordings for filters - * @param {string} scope_id - */ - async getAllSessionRecordings(scope_id) { - const options = { pushToStore: false, peekDb: true }; - this.allSessionRecordings = await this.store.query( - 'session-recording', - { - scope_id, - recursive: true, - }, - options, - ); - } - /** * Sets doSessionRecordingsExist to true if there are any session recordings. * @param {string} scope_id @@ -228,14 +204,11 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { // =actions /** - * refreshes all session recording route data. + * Loads initial filter options in controller so it happens outside of model hook + * @param controller */ - @action - async refreshAll() { - const scope = this.modelFor('scopes.scope'); - - await this.getAllSessionRecordings(scope.id); - - return super.refresh(...arguments); + setupController(controller) { + super.setupController(...arguments); + controller.loadItems.perform(); } } 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 65b50e7869..1c9bbe4791 100644 --- a/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs +++ b/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs @@ -49,10 +49,16 @@ {{#each itemOptions as |itemOption|}} @@ -70,10 +76,19 @@ {{#each-in @@ -100,10 +115,19 @@ {{#each itemOptions as |itemOption|}} diff --git a/ui/admin/tests/acceptance/session-recordings/list-test.js b/ui/admin/tests/acceptance/session-recordings/list-test.js index 62a65c0015..b92fdcb485 100644 --- a/ui/admin/tests/acceptance/session-recordings/list-test.js +++ b/ui/admin/tests/acceptance/session-recordings/list-test.js @@ -232,13 +232,14 @@ module('Acceptance | session-recordings | list', function (hooks) { assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 }); await click(commonSelectors.FILTER_DROPDOWN('user')); + await waitFor(commonSelectors.FILTER_DROPDOWN_ITEM(instances.user.id)); await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.user.id)); await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('user')); assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 1 }); }); - test('user can filter session recordings by scope', async function (assert) { + test('user can filter session recordings by target', async function (assert) { setRunOptions({ rules: { 'color-contrast': { @@ -255,13 +256,14 @@ module('Acceptance | session-recordings | list', function (hooks) { assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 }); await click(commonSelectors.FILTER_DROPDOWN('target')); + await waitFor(commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.id)); await click(commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.id)); await click(commonSelectors.FILTER_DROPDOWN_ITEM_APPLY_BTN('target')); assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 1 }); }); - test('user can filter session recordings by target', async function (assert) { + test('user can filter session recordings by scope', async function (assert) { setRunOptions({ rules: { 'color-contrast': { @@ -278,6 +280,9 @@ module('Acceptance | session-recordings | list', function (hooks) { assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 }); await click(commonSelectors.FILTER_DROPDOWN('scope')); + await waitFor( + commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.scope.id), + ); await click( commonSelectors.FILTER_DROPDOWN_ITEM(instances.target.scope.id), ); 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 8e5cecb34a..58c913d419 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 @@ -93,44 +93,6 @@ module( 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, [ { @@ -148,16 +110,6 @@ module( ]); }); - 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 @@ -186,21 +138,5 @@ module( 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(); - }); }, );