From 6eb21d3e139c0a9dd3787aa7b968e1c3982161ae Mon Sep 17 00:00:00 2001 From: Dharini Date: Fri, 29 May 2026 09:54:55 -0700 Subject: [PATCH] Display session playback error (#3280) --- .../generated/models/session-recording.js | 25 +++- .../app/mirage/factories/session-recording.js | 25 +++- addons/core/translations/actions/en-us.yaml | 1 + addons/core/translations/resources/en-us.yaml | 7 + .../session-recording/error-flyout-alert.hbs | 107 +++++++++++++++ .../channels-by-connection/index.js | 27 ++++ ui/admin/app/styles/app.scss | 29 ++++ .../channels-by-connection/index.hbs | 8 ++ .../session-recordings/read-test.js | 68 +++++++++- .../session-recordings/selectors.js | 15 +++ .../channels-by-connection/index-test.js | 127 ++++++++++++++++++ 11 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 ui/admin/app/components/session-recording/error-flyout-alert.hbs diff --git a/addons/api/addon/generated/models/session-recording.js b/addons/api/addon/generated/models/session-recording.js index 92e19c756..22a2e158c 100644 --- a/addons/api/addon/generated/models/session-recording.js +++ b/addons/api/addon/generated/models/session-recording.js @@ -96,19 +96,19 @@ export default class GeneratedSessionRecordingModel extends BaseModel { }) duration; - @attr('string', { + @attr({ description: - 'The current state of the session recording. One of "started", "available" and "unknown".', + 'The detailed state of the session recording. This contains storage_state, syncing_error_details, and verification_error_details.', readOnly: true, }) - state; + recording_state; @attr('string', { description: - 'Any error seen during the closing of the session recording. Currently only set if state is "unknown".', + 'The availability of the session recording. One of "available" or "not available".', readOnly: true, }) - error_details; + availability; @attr('date', { description: 'The time until a session recording is required to be stored.', @@ -122,4 +122,19 @@ export default class GeneratedSessionRecordingModel extends BaseModel { readOnly: true, }) delete_after; + + // Deprecated: These two fields should be removed once support for RDP is added. + @attr('string', { + description: + 'The current state of the session recording. One of "started", "available" and "unknown".', + readOnly: true, + }) + state; + + @attr('string', { + description: + 'Any error seen during the closing of the session recording. Currently only set if state is "unknown".', + readOnly: true, + }) + error_details; } diff --git a/addons/api/app/mirage/factories/session-recording.js b/addons/api/app/mirage/factories/session-recording.js index 3ba57e0c3..808f8b3b0 100644 --- a/addons/api/app/mirage/factories/session-recording.js +++ b/addons/api/app/mirage/factories/session-recording.js @@ -10,8 +10,8 @@ import { faker } from '@faker-js/faker'; import { trait } from 'miragejs'; import { TYPES_SESSION_RECORDING as types, - STATE_SESSION_RECORDING_STARTED, STATE_SESSION_RECORDING_AVAILABLE, + STATE_SESSION_RECORDING_STARTED, STATE_SESSION_RECORDING_UNKNOWN, } from 'api/models/session-recording'; @@ -27,6 +27,29 @@ export default factory.extend({ // Cycle through available types type: (i) => types[i % types.length], state: (i) => states[i % states.length], + + recording_state(i) { + // Only return error details if state is 'unknown' (failed) + if (this.state !== STATE_SESSION_RECORDING_UNKNOWN) { + return {}; + } + + const errorScenarios = [ + { + syncing_error_details: + 'sync errors in container "sr_jovU2XLzBP.bsr": file "sr_jovU2XLzBP.bsr/session-recording.meta": storage.(syncingFile).sync: storage.parseStoragePluginError: plugin error, external system issue: error #3001: rpc error: \ncode = Internal desc = failed to put object into minio: Get "http://minio:9000/my-special-storage-bucket/?location=": dial tcp: lookup minio on 10.89.0.1:53: no such host', + verification_error_details: + 'integrity check failed for session recording file "sr_jovU2XLzBP.bsr/channels/connection-1/channel-recording-data.bin": expected SHA-256 checksum "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6" but computed \nchecksum was "z6y5x4w3v2u1t0s9r8q7p6o5n4m3l2k1j0i9h8g7f6e5d4c3b2a1" indicating potential data corruption during transfer or storage', + }, + { + syncing_error_details: + 'sync errors in container "sr_jovU2XLzBP.bsr": file "sr_jovU2XLzBP.bsr/session-recording.meta": storage.(syncingFile).sync: storage.parseStoragePluginError: plugin error, external system issue: error #3001: rpc error: code = Internal desc = failed to put object into minio: Get "http://minio:9000/my-special-storage-bucket/?location=": dial tcp: lookup minio on 10.89.0.1:53: no such host', + verification_error_details: null, + }, + ]; + return errorScenarios[i % 2]; + }, + start_time() { return this.state === STATE_SESSION_RECORDING_AVAILABLE ? faker.date.recent({ days: 3, refDate: this.end_time }) diff --git a/addons/core/translations/actions/en-us.yaml b/addons/core/translations/actions/en-us.yaml index 49f509ee1..f78578375 100644 --- a/addons/core/translations/actions/en-us.yaml +++ b/addons/core/translations/actions/en-us.yaml @@ -71,3 +71,4 @@ edit-worker-filter: Edit Worker Filter add-worker-filter: Add Worker Filter open: Open download: Download +show-more: Show more diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 6498013d1..cd2ccab7a 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -420,6 +420,13 @@ session-recording: title: Channels by connection messages: no-channels: There are no channels for this connection + flyout: + title: Session playback errors + sync-error: + title: This recording is not available + flyout-section-title: Sync error + verification-error: + flyout-section-title: Verification error started-no-connections: title: Session Recording In Progress description: The Session has started but no connections have been made. diff --git a/ui/admin/app/components/session-recording/error-flyout-alert.hbs b/ui/admin/app/components/session-recording/error-flyout-alert.hbs new file mode 100644 index 000000000..5552d07c9 --- /dev/null +++ b/ui/admin/app/components/session-recording/error-flyout-alert.hbs @@ -0,0 +1,107 @@ +{{! + Copyright IBM Corp. 2021, 2026 + SPDX-License-Identifier: BUSL-1.1 +}} +{{#if (or @syncError @verifyError)}} + + {{t + 'resources.session-recording.channels-by-connection.messages.sync-error.title' + }} + + {{#if (and @syncError @verifyError)}} +
    +
  • + + {{@syncError}} + +
  • +
  • + + {{@verifyError}} + +
  • +
+ {{else}} + + {{or @syncError @verifyError}} + + {{/if}} +
+ +
+{{/if}} + +{{#if @showFlyout}} + + {{t + 'resources.session-recording.channels-by-connection.messages.flyout.title' + }} + + + {{#if @syncErrorLines.length}} + + <:toggle> + + {{t + 'resources.session-recording.channels-by-connection.messages.sync-error.flyout-section-title' + }} + + + + <:content> + {{#each @syncErrorLines as |error|}} + + {{error}} + + {{/each}} + + + {{/if}} + {{#if @verifyErrorLines.length}} + + <:toggle> + + {{t + 'resources.session-recording.channels-by-connection.messages.verification-error.flyout-section-title' + }} + + + + <:content> + {{#each @verifyErrorLines as |error|}} + + {{error}} + + {{/each}} + + + {{/if}} + + + +{{/if}} \ No newline at end of file diff --git a/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js index 99dbdfe17..d0d3f97de 100644 --- a/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js +++ b/ui/admin/app/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index.js @@ -13,12 +13,19 @@ import { STATE_SESSION_RECORDING_STARTED, STATE_SESSION_RECORDING_UNKNOWN, } from 'api/models/session-recording'; +import { tracked } from '@glimmer/tracking'; export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionIndexController extends Controller { // =services @service router; + // =tracked + + @tracked showErrorFlyout = false; + @tracked syncErrorLines = []; + @tracked verifyErrorLines = []; + // =attributes /** @@ -87,4 +94,24 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne throw new Error(e); } } + + // =actions + /** + * Toggles the error flyout open and closed, and sets the error messages to be displayed when opening. + */ + @action + toggleErrorFlyout() { + if (this.showErrorFlyout) { + this.showErrorFlyout = false; + } else { + const recordingState = this.model.sessionRecording?.recording_state || {}; + + const { syncing_error_details, verification_error_details } = + recordingState; + + this.syncErrorLines = syncing_error_details?.split('\n') ?? []; + this.verifyErrorLines = verification_error_details?.split('\n') ?? []; + this.showErrorFlyout = true; + } + } } diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index 9396d2bf7..7866109c4 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -748,6 +748,35 @@ .no-channels-message { padding: 0 1rem; } + + .playback-error-item { + list-style: disc; + } + + .playback-error-message { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* stylelint-disable-next-line selector-class-pattern */ + .hds-alert__content { + min-width: 0; + } +} + +// Session playback error flyout +.session-playback-error-flyout { + .flyout-error-message { + margin-bottom: 0.5rem; + } + /* stylelint-disable-next-line selector-class-pattern */ + .hds-accordion-item__toggle-content { + display: flex; + align-items: center; + justify-content: space-between; + } } // Channel diff --git a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs index a005124d8..de283efb9 100644 --- a/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs +++ b/ui/admin/app/templates/scopes/scope/session-recordings/session-recording/channels-by-connection/index.hbs @@ -46,6 +46,14 @@
+ {{#if this.isSessionInprogressWithNoConnections}} {{! Session recording in progress with no connections }} diff --git a/ui/admin/tests/acceptance/session-recordings/read-test.js b/ui/admin/tests/acceptance/session-recordings/read-test.js index 87aebf8d6..713ecc217 100644 --- a/ui/admin/tests/acceptance/session-recordings/read-test.js +++ b/ui/admin/tests/acceptance/session-recordings/read-test.js @@ -4,7 +4,7 @@ */ import { module, test } from 'qunit'; -import { visit, currentURL, click } from '@ember/test-helpers'; +import { visit, currentURL, click, findAll } from '@ember/test-helpers'; import { setupApplicationTest } from 'admin/tests/helpers'; import { setupIntl } from 'ember-intl/test-support'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; @@ -175,4 +175,70 @@ module('Acceptance | session-recordings | read', function (hooks) { assert.notEqual(currentURL(), incorrectUrl); assert.strictEqual(currentURL(), urls.sessionRecording); }); + + test('it displays error messages for session recording', async function (assert) { + setRunOptions({ + rules: { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2026-05-22 + 'color-contrast': { + enabled: false, + }, + }, + }); + + instances.sessionRecording.recording_state = { + syncing_error_details: 'sync errors in container', + verification_error_details: 'verification error', + }; + + featuresService.enable('ssh-session-recording'); + + await visit(urls.sessionRecordings); + + await click(commonSelectors.HREF(urls.sessionRecording)); + + assert.dom(selectors.SESSION_PLAYBACK_ERROR_ALERT).isVisible(); + + const errorMessages = findAll(selectors.SESSION_PLAYBACK_ERROR_MESSAGE).map( + (err) => err.textContent.trim(), + ); + + assert.ok(errorMessages[0].includes('sync errors in container')); + assert.ok(errorMessages[1].includes('verification error')); + }); + + test('flyout opens, displays errors, and can be closed', async function (assert) { + setRunOptions({ + rules: { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2026-05-27 + 'color-contrast': { enabled: false }, + }, + }); + + instances.sessionRecording.recording_state = { + syncing_error_details: 'Test sync error line 1\nTest sync error line 2', + verification_error_details: 'Test verification error', + }; + featuresService.enable('ssh-session-recording'); + + await visit(urls.sessionRecordings); + await click(commonSelectors.HREF(urls.sessionRecording)); + + assert.dom(selectors.SESSION_PLAYBACK_ERROR_ALERT).isVisible(); + assert + .dom(`${selectors.SESSION_PLAYBACK_ERROR_ALERT} button`) + .hasTextContaining('Show more'); + + // Open the flyout + await click(`${selectors.SESSION_PLAYBACK_ERROR_ALERT} button`); + assert.dom(selectors.SESSION_ERROR_FLYOUT).isVisible(); + + // Check for sync error section and copy button + assert.dom(selectors.SESSION_ERROR_FLYOUT_ACCORDION_ITEM).isVisible(); + assert.dom(selectors.SESSION_ERROR_FLYOUT_COPY_BUTTON).isVisible(); + // Close the flyout + await click(selectors.SESSION_ERROR_FLYOUT_CLOSE); + + assert.dom(selectors.SESSION_ERROR_FLYOUT).isNotVisible(); + }); }); diff --git a/ui/admin/tests/acceptance/session-recordings/selectors.js b/ui/admin/tests/acceptance/session-recordings/selectors.js index 0075941bb..589c1e82d 100644 --- a/ui/admin/tests/acceptance/session-recordings/selectors.js +++ b/ui/admin/tests/acceptance/session-recordings/selectors.js @@ -22,3 +22,18 @@ export const MANAGE_DROPDOWN = '[data-test-manage-channels-dropdown] button:first-child'; export const DELETE_ACTION = '[data-test-manage-channels-dropdown] ul li:last-child button'; + +// Playback error selectors +export const SESSION_PLAYBACK_ERROR_ALERT = + '[data-test-session-playback-alert]'; +export const SESSION_PLAYBACK_ERROR_MESSAGE = '.playback-error-message'; +export const SESSION_PLAYBACK_ERROR_COPY_BUTTON = + '.playback-error-item .hds-copy-button'; + +// Flyout and alert selectors +export const SESSION_ERROR_FLYOUT = '[data-test-error-flyout]'; +export const SESSION_ERROR_FLYOUT_COPY_BUTTON = + '[data-test-error-flyout] .hds-copy-button'; +export const SESSION_ERROR_FLYOUT_ACCORDION_ITEM = + '[data-test-error-flyout] .hds-accordion-item'; +export const SESSION_ERROR_FLYOUT_CLOSE = '[data-test-error-flyout] button'; diff --git a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js index f6f920520..84764581d 100644 --- a/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js +++ b/ui/admin/tests/unit/controllers/scopes/scope/session-recordings/session-recording/channels-by-connection/index-test.js @@ -135,5 +135,132 @@ module( assert.notEqual(sessionRecording.delete_after, deleteAfter); }); + + test('isSessionInprogressWithNoConnections handles session with syncing_error_details', function (assert) { + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_STARTED, + connection_recordings: [], + recording_state: { + syncing_error_details: 'sync error occurred', + verification_error_details: null, + }, + }); + controller.set('model', { sessionRecording }); + + assert.true(controller.isSessionInprogressWithNoConnections); + }); + + test('isSessionUnknownWithNoConnections handles session with verification_error_details', function (assert) { + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + recording_state: { + syncing_error_details: null, + verification_error_details: 'verification failed', + }, + }); + controller.set('model', { sessionRecording }); + + assert.true(controller.isSessionUnknownWithNoConnections); + }); + + test('isSessionUnknownWithNoConnections handles session with both error types', function (assert) { + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + recording_state: { + syncing_error_details: 'sync error occurred', + verification_error_details: 'verification failed', + }, + }); + controller.set('model', { sessionRecording }); + + assert.true(controller.isSessionUnknownWithNoConnections); + }); + + test('model handles recording_state with only syncing_error_details', function (assert) { + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + recording_state: { + syncing_error_details: + 'sync errors in container "sr_jovU2XLzBP.bsr": storage error', + verification_error_details: null, + }, + }); + controller.set('model', { sessionRecording }); + + assert.strictEqual( + sessionRecording.recording_state.syncing_error_details, + 'sync errors in container "sr_jovU2XLzBP.bsr": storage error', + ); + assert.strictEqual( + sessionRecording.recording_state.verification_error_details, + null, + ); + }); + + test('model handles recording_state with only verification_error_details', function (assert) { + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + recording_state: { + syncing_error_details: null, + verification_error_details: + 'integrity check failed for session recording file', + }, + }); + controller.set('model', { sessionRecording }); + + assert.strictEqual( + sessionRecording.recording_state.syncing_error_details, + null, + ); + assert.strictEqual( + sessionRecording.recording_state.verification_error_details, + 'integrity check failed for session recording file', + ); + }); + + test('model handles recording_state with both error details', function (assert) { + const syncError = 'sync errors in container'; + const verifyError = 'integrity check failed'; + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + recording_state: { + syncing_error_details: syncError, + verification_error_details: verifyError, + }, + }); + controller.set('model', { sessionRecording }); + + assert.strictEqual( + sessionRecording.recording_state.syncing_error_details, + syncError, + ); + assert.strictEqual( + sessionRecording.recording_state.verification_error_details, + verifyError, + ); + }); + + test('model handles empty recording_state object', function (assert) { + const sessionRecording = store.createRecord('session-recording', { + state: STATE_SESSION_RECORDING_UNKNOWN, + connection_recordings: [], + recording_state: {}, + }); + controller.set('model', { sessionRecording }); + + assert.strictEqual( + sessionRecording.recording_state.syncing_error_details, + undefined, + ); + assert.strictEqual( + sessionRecording.recording_state.verification_error_details, + undefined, + ); + }); }, );