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,
+ );
+ });
},
);