From 7a888559e16df241e25c09dec0e386a26b1d70e5 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Fri, 29 Aug 2025 18:28:56 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Refactor=20session?= =?UTF-8?q?=20recording=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/api/addon/handlers/sqlite-handler.js | 17 ++- addons/api/addon/services/sqlite.js | 17 +-- addons/api/addon/utils/sqlite-query.js | 68 ++++++--- addons/api/addon/workers/utils/schema.js | 10 +- addons/api/tests/unit/services/sqlite-test.js | 5 +- .../api/tests/unit/utils/sqlite-query-test.js | 51 ++++--- .../core/addon/components/dropdown/index.hbs | 33 ++-- .../core/addon/components/dropdown/index.js | 14 +- addons/core/addon/styles/addon.scss | 34 +++++ .../scopes/scope/session-recordings/index.js | 142 +++++++++++++----- .../scopes/scope/session-recordings/index.js | 32 ++-- .../scopes/scope/session-recordings/index.hbs | 21 ++- .../scope/session-recordings/index-test.js | 48 ------ 13 files changed, 296 insertions(+), 196 deletions(-) diff --git a/addons/api/addon/handlers/sqlite-handler.js b/addons/api/addon/handlers/sqlite-handler.js index b68a10e29d..dd867392f2 100644 --- a/addons/api/addon/handlers/sqlite-handler.js +++ b/addons/api/addon/handlers/sqlite-handler.js @@ -35,6 +35,7 @@ export default class SqliteHandler { pushToStore = true, peekDb = false, storeToken = true, + returnRawData = false, } = {}, } = data; const supportedModels = Object.keys(modelMapping); @@ -49,7 +50,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 +126,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({ @@ -127,9 +134,13 @@ export default class SqliteHandler { parameters, }); + if (returnRawData) { + return rows; + } + 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/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..8c36065f78 100644 --- a/addons/api/addon/utils/sqlite-query.js +++ b/addons/api/addon/utils/sqlite-query.js @@ -4,7 +4,6 @@ */ import { modelMapping } from 'api/services/sqlite'; -import { searchTables } from 'api/services/sqlite'; import { typeOf } from '@ember/utils'; import { underscore } from '@ember/string'; @@ -35,8 +34,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 +43,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 +139,57 @@ 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?.value) { + return; + } + + parameters.push( + or(search.fields.map((field) => `${field}:"${search.value}"*`)), ); - 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); + let selectColumns; + + // Special case for distinct columns as they must be grouped together. + // We're only handling simple use cases as anything more complicated + // like windows/CTEs can be custom SQL. + if (distinctColumns.length > 0) { + selectColumns = `DISTINCT ${distinctColumns.map(({ field }) => field).join(', ')}`; + } else { + selectColumns = select + .map(({ field, isCount, alias }) => { + let column = field; + + if (isCount) { + column = `count(${column})`; + } + if (alias) { + column = `${column} as ${alias}`; + } + + return column; + }) + .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/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..23d96bff9b 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,38 @@ module('Unit | Utility | sqlite-query', function (hooks) { assert.deepEqual(parameters, []); }); + test.each( + 'it generates DISTINCT queries correctly', + [ + { + select: [{ field: 'type', isDistinct: true }], + expectedSelect: 'type', + }, + { + select: [ + { field: 'type', isDistinct: true }, + { field: 'status', isDistinct: true }, + ], + expectedSelect: 'type, status', + }, + ], + function (assert, { select, expectedSelect }) { + const { sql, parameters } = generateSQLExpressions( + 'target', + {}, + { select }, + ); + + assert.strictEqual( + sql, + ` + SELECT DISTINCT ${expectedSelect} FROM "target" + ORDER BY created_time DESC`.removeExtraWhiteSpace(), + ); + assert.deepEqual(parameters, []); + }, + ); + test.each( 'it generates filters correctly', { @@ -300,7 +317,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( 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..ad5ad6e918 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); @@ -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/ui/admin/app/controllers/scopes/scope/session-recordings/index.js b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js index e025b20ad1..49d06518b7 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -7,6 +7,13 @@ 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 @@ -30,6 +37,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 +59,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,27 +106,93 @@ 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 }, - }, + 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 options = { + peekDb: true, + returnRawData: true, + }; + const results = await this.store.query( + 'session-recording', + { + select: config.select, + query: { + search: { value: search, fields: config.searchFields }, }, - }) => { - if (!uniqueMap.has(id)) { - const projectName = name || id; - uniqueMap.set(id, { id, name: projectName, parent_scope_id }); - } + page: 1, + pageSize: 250, }, + options, ); - return Array.from(uniqueMap.values()); + + return results.map(config.mapper); + } + + async loadItems() { + this.userFilters.options = await this.retrieveFilterOptions('userFilters'); + this.scopeFilters.options = + await this.retrieveFilterOptions('scopeFilters'); + this.targetFilters.options = + await this.retrieveFilterOptions('targetFilters'); } // =actions @@ -131,28 +208,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 @@ -206,4 +261,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..f42be6ab63 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings/index.js @@ -59,8 +59,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { }, }; - allSessionRecordings; - /** * Load all session recordings. * @return {Promise<{ totalItems: number, sessionRecordings: [SessionRecordingModel], doSessionRecordingsExist: boolean, doStorageBucketsExist: boolean }>} @@ -144,10 +142,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 +151,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { return { sessionRecordings, doSessionRecordingsExist: doSessionRecordingsExist, - allSessionRecordings: this.allSessionRecordings, totalItems, doStorageBucketsExist: doStorageBucketsExist, }; @@ -165,22 +158,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 @@ -238,4 +215,13 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { return super.refresh(...arguments); } + + /** + * Loads initial filter options in controller so it happens outside of model hook + * @param controller + */ + setupController(controller) { + super.setupController(...arguments); + controller.loadItems(); + } } 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..a25480a190 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,13 @@ {{#each itemOptions as |itemOption|}} @@ -70,10 +73,16 @@ {{#each-in @@ -100,10 +109,16 @@ {{#each itemOptions as |itemOption|}} 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..84ec8c7ece 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 From 470ceeb704a49707424185583cfc3ff0346f8974 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Fri, 26 Sep 2025 14:23:03 -0400 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20Add=20loading=20?= =?UTF-8?q?indicator=20for=20initial=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/api/addon/utils/sqlite-query.js | 4 +-- .../api/tests/unit/utils/sqlite-query-test.js | 25 +++++++++++++++++++ .../scopes/scope/session-recordings/index.js | 6 ++--- .../scopes/scope/session-recordings/index.js | 2 +- .../scopes/scope/session-recordings/index.hbs | 15 ++++++++--- 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/addons/api/addon/utils/sqlite-query.js b/addons/api/addon/utils/sqlite-query.js index 8c36065f78..e3e708a1a1 100644 --- a/addons/api/addon/utils/sqlite-query.js +++ b/addons/api/addon/utils/sqlite-query.js @@ -145,12 +145,12 @@ function addSearchConditions({ // Use the special prefix indicator "*" for full-text search if (typeOf(search) === 'object') { - if (!search?.value) { + if (!search?.text) { return; } parameters.push( - or(search.fields.map((field) => `${field}:"${search.value}"*`)), + or(search.fields.map((field) => `${field}:"${search.text}"*`)), ); } else { parameters.push(`"${search}"*`); diff --git a/addons/api/tests/unit/utils/sqlite-query-test.js b/addons/api/tests/unit/utils/sqlite-query-test.js index 23d96bff9b..e527cf5661 100644 --- a/addons/api/tests/unit/utils/sqlite-query-test.js +++ b/addons/api/tests/unit/utils/sqlite-query-test.js @@ -300,6 +300,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', 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 49d06518b7..569913c937 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -176,7 +176,7 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control { select: config.select, query: { - search: { value: search, fields: config.searchFields }, + search: { text: search, fields: config.searchFields }, }, page: 1, pageSize: 250, @@ -187,13 +187,13 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control return results.map(config.mapper); } - async loadItems() { + 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 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 f42be6ab63..bbcf62e6fc 100644 --- a/ui/admin/app/routes/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/routes/scopes/scope/session-recordings/index.js @@ -222,6 +222,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { */ setupController(controller) { super.setupController(...arguments); - controller.loadItems(); + 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 a25480a190..1c9bbe4791 100644 --- a/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs +++ b/ui/admin/app/templates/scopes/scope/session-recordings/index.hbs @@ -55,7 +55,10 @@ @isSearchable={{true}} @searchTerm={{this.userFilters.search}} @updateSearchTerm={{fn this.onFilterSearch.perform 'userFilters'}} - @isLoading={{this.onFilterSearch.isRunning}} + @isLoading={{or + this.onFilterSearch.isRunning + this.loadItems.isRunning + }} as |FD selectItem itemOptions| > {{#each itemOptions as |itemOption|}} @@ -82,7 +85,10 @@ this.onFilterSearch.perform 'scopeFilters' }} - @isLoading={{this.onFilterSearch.isRunning}} + @isLoading={{or + this.onFilterSearch.isRunning + this.loadItems.isRunning + }} as |FD selectItem itemOptions| > {{#each-in @@ -118,7 +124,10 @@ this.onFilterSearch.perform 'targetFilters' }} - @isLoading={{this.onFilterSearch.isRunning}} + @isLoading={{or + this.onFilterSearch.isRunning + this.loadItems.isRunning + }} as |FD selectItem itemOptions| > {{#each itemOptions as |itemOption|}} From 2c73c4d6e1fe4a745238d2971d9cd02508dfd55d Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Thu, 23 Oct 2025 19:12:43 -0400 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20Refactor=20selec?= =?UTF-8?q?t=20query=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/api/addon/utils/sqlite-query.js | 61 +++++--- .../api/tests/unit/utils/sqlite-query-test.js | 130 ++++++++++++++++-- .../core/addon/components/dropdown/index.js | 2 +- .../scopes/scope/session-recordings/index.js | 2 +- .../scopes/scope/session-recordings/index.js | 13 -- 5 files changed, 168 insertions(+), 40 deletions(-) diff --git a/addons/api/addon/utils/sqlite-query.js b/addons/api/addon/utils/sqlite-query.js index e3e708a1a1..4ee231f6d0 100644 --- a/addons/api/addon/utils/sqlite-query.js +++ b/addons/api/addon/utils/sqlite-query.js @@ -6,6 +6,7 @@ import { modelMapping } 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. @@ -163,30 +164,56 @@ function addSearchConditions({ `rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`, ); } + 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; - // Special case for distinct columns as they must be grouped together. - // We're only handling simple use cases as anything more complicated - // like windows/CTEs can be custom SQL. if (distinctColumns.length > 0) { - selectColumns = `DISTINCT ${distinctColumns.map(({ field }) => field).join(', ')}`; - } else { - selectColumns = select - .map(({ field, isCount, alias }) => { - let column = field; + // Check if any columns also have COUNT (or other aggregate function in the future) + const hasAggregateDistinct = distinctColumns.some((col) => col.isCount); - if (isCount) { - column = `count(${column})`; - } - if (alias) { - column = `${column} as ${alias}`; - } + if (hasAggregateDistinct) { + selectColumns = distinctColumns.map(buildColumnExpression).join(', '); - return column; - }) - .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}"`; diff --git a/addons/api/tests/unit/utils/sqlite-query-test.js b/addons/api/tests/unit/utils/sqlite-query-test.js index e527cf5661..a7e134b610 100644 --- a/addons/api/tests/unit/utils/sqlite-query-test.js +++ b/addons/api/tests/unit/utils/sqlite-query-test.js @@ -51,20 +51,105 @@ module('Unit | Utility | sqlite-query', function (hooks) { }); test.each( - 'it generates DISTINCT queries correctly', - [ - { + '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: 'type', + expectedSelect: 'DISTINCT type', }, - { + 'multiple distinct fields': { select: [ { field: 'type', isDistinct: true }, { field: 'status', isDistinct: true }, ], - expectedSelect: 'type, status', + 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', @@ -75,7 +160,7 @@ module('Unit | Utility | sqlite-query', function (hooks) { assert.strictEqual( sql, ` - SELECT DISTINCT ${expectedSelect} FROM "target" + SELECT ${expectedSelect} FROM "target" ORDER BY created_time DESC`.removeExtraWhiteSpace(), ); assert.deepEqual(parameters, []); @@ -365,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.js b/addons/core/addon/components/dropdown/index.js index ad5ad6e918..a06fb3ac5e 100644 --- a/addons/core/addon/components/dropdown/index.js +++ b/addons/core/addon/components/dropdown/index.js @@ -30,7 +30,7 @@ export default class DropdownComponent extends Component { return isNameMatch || isIdMatch; }); } - return items.slice(0, 500); + return items.slice(0, 250); } // =methods 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 569913c937..8ba8fccd35 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -247,7 +247,7 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control */ @action refresh() { - this.send('refreshAll'); + this.router.refresh('scopes.scope.session-recordings'); } /** 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 bbcf62e6fc..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, @@ -204,18 +203,6 @@ export default class ScopesScopeSessionRecordingsIndexRoute extends Route { // =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); - } - /** * Loads initial filter options in controller so it happens outside of model hook * @param controller From e33bde242f4c07555f1775da48b9f7f3681170b1 Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Fri, 24 Oct 2025 17:41:26 -0400 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20Add=20a=20db=20s?= =?UTF-8?q?ervice=20as=20intermediate=20for=20direct=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/api/addon/handlers/sqlite-handler.js | 5 --- addons/api/addon/services/db.js | 39 +++++++++++++++++++ addons/api/app/services/db.js | 1 + addons/api/tests/unit/services/db-test.js | 14 +++++++ .../scopes/scope/session-recordings/index.js | 23 ++++------- 5 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 addons/api/addon/services/db.js create mode 100644 addons/api/app/services/db.js create mode 100644 addons/api/tests/unit/services/db-test.js diff --git a/addons/api/addon/handlers/sqlite-handler.js b/addons/api/addon/handlers/sqlite-handler.js index dd867392f2..85317364a3 100644 --- a/addons/api/addon/handlers/sqlite-handler.js +++ b/addons/api/addon/handlers/sqlite-handler.js @@ -35,7 +35,6 @@ export default class SqliteHandler { pushToStore = true, peekDb = false, storeToken = true, - returnRawData = false, } = {}, } = data; const supportedModels = Object.keys(modelMapping); @@ -134,10 +133,6 @@ export default class SqliteHandler { parameters, }); - if (returnRawData) { - return rows; - } - const { sql: countSql, parameters: countParams } = generateSQLExpressions(type, queryObj, { select: [{ field: '*', isCount: true, alias: 'total' }], diff --git a/addons/api/addon/services/db.js b/addons/api/addon/services/db.js new file mode 100644 index 0000000000..29a6c66d1c --- /dev/null +++ b/addons/api/addon/services/db.js @@ -0,0 +1,39 @@ +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/app/services/db.js b/addons/api/app/services/db.js new file mode 100644 index 0000000000..db40d60a5d --- /dev/null +++ b/addons/api/app/services/db.js @@ -0,0 +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..7e22aaf561 --- /dev/null +++ b/addons/api/tests/unit/services/db-test.js @@ -0,0 +1,14 @@ +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/ui/admin/app/controllers/scopes/scope/session-recordings/index.js b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js index 8ba8fccd35..658458fb51 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/index.js @@ -20,6 +20,7 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control @service store; @service intl; + @service db; // =attributes @@ -167,22 +168,14 @@ export default class ScopesScopeSessionRecordingsIndexController extends Control const config = this.filterConfigs[type]; assert(`Unknown filter type: ${type}`, config); - const options = { - peekDb: true, - returnRawData: true, - }; - const results = await this.store.query( - 'session-recording', - { - select: config.select, - query: { - search: { text: search, fields: config.searchFields }, - }, - page: 1, - pageSize: 250, + const results = await this.db.query('session-recording', { + select: config.select, + query: { + search: { text: search, fields: config.searchFields }, }, - options, - ); + page: 1, + pageSize: 250, + }); return results.map(config.mapper); } From 6a6d53b2c1467eb709ba2fb7cdbd7a381795351d Mon Sep 17 00:00:00 2001 From: ZedLi <5783847+ZedLi@users.noreply.github.com> Date: Fri, 24 Oct 2025 21:42:16 +0000 Subject: [PATCH 5/6] Add missing copyright headers --- addons/api/addon/services/db.js | 5 +++++ addons/api/app/services/db.js | 5 +++++ addons/api/tests/unit/services/db-test.js | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/addons/api/addon/services/db.js b/addons/api/addon/services/db.js index 29a6c66d1c..07d0ace17a 100644 --- a/addons/api/addon/services/db.js +++ b/addons/api/addon/services/db.js @@ -1,3 +1,8 @@ +/** + * 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'; diff --git a/addons/api/app/services/db.js b/addons/api/app/services/db.js index db40d60a5d..b025e38275 100644 --- a/addons/api/app/services/db.js +++ b/addons/api/app/services/db.js @@ -1 +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 index 7e22aaf561..62d99cd611 100644 --- a/addons/api/tests/unit/services/db-test.js +++ b/addons/api/tests/unit/services/db-test.js @@ -1,3 +1,8 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; From 361fdf67fdce8cdcad06506826e88a62e55b3b6e Mon Sep 17 00:00:00 2001 From: Zhihe Li Date: Fri, 24 Oct 2025 18:23:22 -0400 Subject: [PATCH 6/6] =?UTF-8?q?fixup!=20refactor:=20=F0=9F=92=A1=20Refacto?= =?UTF-8?q?r=20select=20query=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dropdown/index-test.js | 4 ++-- .../acceptance/session-recordings/list-test.js | 9 +++++++-- .../scope/session-recordings/index-test.js | 16 ---------------- 3 files changed, 9 insertions(+), 20 deletions(-) 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/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 84ec8c7ece..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 @@ -138,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(); - }); }, );