feat: 🎸 Refactor session recording filters (#2981)

This commit has a few notable changes:

1. A simple attempt at establishing a pattern for using SELECT statements. This will be useful in the future for joins/subquery support.
2. Expanded usage of searching to be able to pass instead of just a string, an object that lets a consumer choose which columns to search on.
3. Decouple our session recording filters from being loaded from the route model hook which is a departure from our convention. This has the benefit of actually letting the page first load and having the filters load async so the page doesn't wait for the filters to finish. This is also much faster to query for each individual filter option from SQLite rather than returning all data to filter in application code.
This commit is contained in:
Zhihe Li authored and GitHub committed 2025-10-31 16:58:05 -04:00
1 parent 72f7c8a92d
commit fa18bfff01
18 files changed
+558 -250

No files matched your search

+9 -3
View File
@@ -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,
+44
View File
@@ -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<Object>} 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,
});
}
}
+2 -15
View File
@@ -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
+72 -23
View File
@@ -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) {
+6 -4
View File
@@ -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 = `
+6
View File
@@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'api/services/db';
+19
View File
@@ -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/);
});
});
@@ -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');
+173 -17
View File
@@ -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',
{
+21 -12
View File
@@ -20,7 +20,7 @@
<DD.Header @hasDivider={{true}}>
<Hds::Form::TextInput::Base
@type='search'
@value={{this.searchTerm}}
@value={{or this.searchTerm @searchTerm}}
placeholder={{t 'actions.narrow-results'}}
aria-label={{t 'actions.narrow-results'}}
{{on 'input' this.filterItems}}
@@ -28,11 +28,18 @@
/>
</DD.Header>
{{/if}}
{{#unless this.itemOptions}}
{{#unless (or this.itemOptions @isLoading)}}
<DD.Description @text={{t 'titles.no-results'}} />
{{/unless}}
{{#if (has-block)}}
{{#if @isLoading}}
<DD.Generic>
<Hds::Layout::Flex @align='center' @justify='center'>
<Hds::Icon @name='loading' @size='24' class='margin-y-xs' />
</Hds::Layout::Flex>
</DD.Generic>
{{else if (has-block)}}
{{yield DD this.selectItem this.itemOptions}}
{{else}}
{{#each this.itemOptions as |itemOption|}}
@@ -47,13 +54,15 @@
{{/each}}
{{/if}}
<DD.Footer @hasDivider={{true}}>
<Hds::Button
@text='Apply'
@isFullWidth={{true}}
@size='small'
{{on 'click' (fn this.applyFilter DD.close)}}
data-test-dropdown-apply-button
/>
</DD.Footer>
{{#unless @isLoading}}
<DD.Footer @hasDivider={{true}}>
<Hds::Button
@text='Apply'
@isFullWidth={{true}}
@size='small'
{{on 'click' (fn this.applyFilter DD.close)}}
data-test-dropdown-apply-button
/>
</DD.Footer>
{{/unless}}
</Hds::Dropdown>
+11 -5
View File
@@ -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;
}
}
/**
+34
View File
@@ -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)};
}
}
}
}
@@ -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');
@@ -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,
}),
},
);
return Array.from(uniqueMap.values());
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<Array>}
*/
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 },
},
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);
});
}
@@ -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();
}
}
@@ -49,10 +49,16 @@
<Dropdown
name='user'
@toggleText={{t 'resources.user.title'}}
@itemOptions={{this.filterOptions 'user'}}
@itemOptions={{this.userFilters.options}}
@checkedItems={{this.users}}
@applyFilter={{fn this.applyFilter 'users'}}
@isSearchable={{true}}
@searchTerm={{this.userFilters.search}}
@updateSearchTerm={{fn this.onFilterSearch.perform 'userFilters'}}
@isLoading={{or
this.onFilterSearch.isRunning
this.loadItems.isRunning
}}
as |FD selectItem itemOptions|
>
{{#each itemOptions as |itemOption|}}
@@ -70,10 +76,19 @@
<Dropdown
name='scope'
@toggleText={{t 'resources.scope.title'}}
@itemOptions={{this.projectScopes}}
@itemOptions={{this.scopeFilters.options}}
@checkedItems={{this.scopes}}
@applyFilter={{fn this.applyFilter 'scopes'}}
@isSearchable={{true}}
@searchTerm={{this.scopeFilters.search}}
@updateSearchTerm={{fn
this.onFilterSearch.perform
'scopeFilters'
}}
@isLoading={{or
this.onFilterSearch.isRunning
this.loadItems.isRunning
}}
as |FD selectItem itemOptions|
>
{{#each-in
@@ -100,10 +115,19 @@
<Dropdown
name='target'
@toggleText={{t 'resources.target.title'}}
@itemOptions={{this.filterOptions 'target'}}
@itemOptions={{this.targetFilters.options}}
@checkedItems={{this.targets}}
@applyFilter={{fn this.applyFilter 'targets'}}
@isSearchable={{true}}
@searchTerm={{this.targetFilters.search}}
@updateSearchTerm={{fn
this.onFilterSearch.perform
'targetFilters'
}}
@isLoading={{or
this.onFilterSearch.isRunning
this.loadItems.isRunning
}}
as |FD selectItem itemOptions|
>
{{#each itemOptions as |itemOption|}}
@@ -232,35 +232,13 @@ 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) {
setRunOptions({
rules: {
'color-contrast': {
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01
enabled: false,
},
},
});
await visit(urls.globalScope);
await click(commonSelectors.HREF(urls.sessionRecordings));
assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 2 });
await click(commonSelectors.FILTER_DROPDOWN('target'));
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) {
setRunOptions({
rules: {
@@ -277,7 +255,34 @@ 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 scope', async function (assert) {
setRunOptions({
rules: {
'color-contrast': {
// [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01
enabled: false,
},
},
});
await visit(urls.globalScope);
await click(commonSelectors.HREF(urls.sessionRecordings));
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),
);
@@ -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();
});
},
);