Display session playback error (#3280)

This commit is contained in:
Dharini authored and GitHub committed 2026-05-29 09:54:55 -07:00
1 parent 9b43819865
commit 6eb21d3e13
11 files changed
+432 -7

No files matched your search

@@ -96,19 +96,19 @@ export default class GeneratedSessionRecordingModel extends BaseModel {
}) })
duration; duration;
@attr('string', { @attr({
description: 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, readOnly: true,
}) })
state; recording_state;
@attr('string', { @attr('string', {
description: 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, readOnly: true,
}) })
error_details; availability;
@attr('date', { @attr('date', {
description: 'The time until a session recording is required to be stored.', description: 'The time until a session recording is required to be stored.',
@@ -122,4 +122,19 @@ export default class GeneratedSessionRecordingModel extends BaseModel {
readOnly: true, readOnly: true,
}) })
delete_after; 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;
} }
@@ -10,8 +10,8 @@ import { faker } from '@faker-js/faker';
import { trait } from 'miragejs'; import { trait } from 'miragejs';
import { import {
TYPES_SESSION_RECORDING as types, TYPES_SESSION_RECORDING as types,
STATE_SESSION_RECORDING_STARTED,
STATE_SESSION_RECORDING_AVAILABLE, STATE_SESSION_RECORDING_AVAILABLE,
STATE_SESSION_RECORDING_STARTED,
STATE_SESSION_RECORDING_UNKNOWN, STATE_SESSION_RECORDING_UNKNOWN,
} from 'api/models/session-recording'; } from 'api/models/session-recording';
@@ -27,6 +27,29 @@ export default factory.extend({
// Cycle through available types // Cycle through available types
type: (i) => types[i % types.length], type: (i) => types[i % types.length],
state: (i) => states[i % states.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() { start_time() {
return this.state === STATE_SESSION_RECORDING_AVAILABLE return this.state === STATE_SESSION_RECORDING_AVAILABLE
? faker.date.recent({ days: 3, refDate: this.end_time }) ? faker.date.recent({ days: 3, refDate: this.end_time })
@@ -71,3 +71,4 @@ edit-worker-filter: Edit Worker Filter
add-worker-filter: Add Worker Filter add-worker-filter: Add Worker Filter
open: Open open: Open
download: Download download: Download
show-more: Show more
@@ -420,6 +420,13 @@ session-recording:
title: Channels by connection title: Channels by connection
messages: messages:
no-channels: There are no channels for this connection 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: started-no-connections:
title: Session Recording In Progress title: Session Recording In Progress
description: The Session has started but no connections have been made. description: The Session has started but no connections have been made.
@@ -0,0 +1,107 @@
{{!
Copyright IBM Corp. 2021, 2026
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (or @syncError @verifyError)}}
<Hds::Alert
data-test-session-playback-alert
@type='inline'
@color='critical'
as |A|
>
<A.Title>{{t
'resources.session-recording.channels-by-connection.messages.sync-error.title'
}}</A.Title>
<A.Description>
{{#if (and @syncError @verifyError)}}
<ul>
<li class='playback-error-item margin-bottom-s'>
<Hds::Text::Body @tag='p' class='playback-error-message'>
{{@syncError}}
</Hds::Text::Body>
</li>
<li class='playback-error-item'>
<Hds::Text::Body @tag='p' class='playback-error-message'>
{{@verifyError}}
</Hds::Text::Body>
</li>
</ul>
{{else}}
<Hds::Text::Body @tag='p' class='playback-error-message'>
{{or @syncError @verifyError}}
</Hds::Text::Body>
{{/if}}
</A.Description>
<A.Button
@text={{t 'actions.show-more'}}
@color='secondary'
{{on 'click' @onShowFlyout}}
/>
</Hds::Alert>
{{/if}}
{{#if @showFlyout}}
<Hds::Flyout
class='session-playback-error-flyout'
@size='large'
@onClose={{@onShowFlyout}}
data-test-error-flyout
as |F|
>
<F.Header>{{t
'resources.session-recording.channels-by-connection.messages.flyout.title'
}}</F.Header>
<F.Body>
<Hds::Accordion @size='large' as |A|>
{{#if @syncErrorLines.length}}
<A.Item @isOpen={{true}}>
<:toggle>
<Hds::Text::Body @tag='p' @size='300' @weight='medium'>
{{t
'resources.session-recording.channels-by-connection.messages.sync-error.flyout-section-title'
}}
</Hds::Text::Body>
<Hds::Copy::Button
@size='small'
@text={{t 'actions.copy'}}
@isIconOnly={{true}}
@textToCopy={{@syncError}}
/>
</:toggle>
<:content>
{{#each @syncErrorLines as |error|}}
<Hds::Text::Body @tag='p' class='flyout-error-message'>
{{error}}
</Hds::Text::Body>
{{/each}}
</:content>
</A.Item>
{{/if}}
{{#if @verifyErrorLines.length}}
<A.Item @isOpen={{true}}>
<:toggle>
<Hds::Text::Body @tag='p' @size='300' @weight='medium'>
{{t
'resources.session-recording.channels-by-connection.messages.verification-error.flyout-section-title'
}}
</Hds::Text::Body>
<Hds::Copy::Button
@size='small'
@text={{t 'actions.copy'}}
@isIconOnly={{true}}
@textToCopy={{@verifyError}}
/>
</:toggle>
<:content>
{{#each @verifyErrorLines as |error|}}
<Hds::Text::Body @tag='p' class='flyout-error-message'>
{{error}}
</Hds::Text::Body>
{{/each}}
</:content>
</A.Item>
{{/if}}
</Hds::Accordion>
</F.Body>
</Hds::Flyout>
{{/if}}
@@ -13,12 +13,19 @@ import {
STATE_SESSION_RECORDING_STARTED, STATE_SESSION_RECORDING_STARTED,
STATE_SESSION_RECORDING_UNKNOWN, STATE_SESSION_RECORDING_UNKNOWN,
} from 'api/models/session-recording'; } from 'api/models/session-recording';
import { tracked } from '@glimmer/tracking';
export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionIndexController extends Controller { export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConnectionIndexController extends Controller {
// =services // =services
@service router; @service router;
// =tracked
@tracked showErrorFlyout = false;
@tracked syncErrorLines = [];
@tracked verifyErrorLines = [];
// =attributes // =attributes
/** /**
@@ -87,4 +94,24 @@ export default class ScopesScopeSessionRecordingsSessionRecordingChannelsByConne
throw new Error(e); 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;
}
}
} }
+29
View File
@@ -748,6 +748,35 @@
.no-channels-message { .no-channels-message {
padding: 0 1rem; 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 // Channel
@@ -46,6 +46,14 @@
<div class='channels-by-connection'> <div class='channels-by-connection'>
<Rose::Layout::BodyContent as |bc|> <Rose::Layout::BodyContent as |bc|>
<bc.Body> <bc.Body>
<SessionRecording::ErrorFlyoutAlert
@syncError={{@model.sessionRecording.recording_state.syncing_error_details}}
@verifyError={{@model.sessionRecording.recording_state.verification_error_details}}
@syncErrorLines={{this.syncErrorLines}}
@verifyErrorLines={{this.verifyErrorLines}}
@showFlyout={{this.showErrorFlyout}}
@onShowFlyout={{this.toggleErrorFlyout}}
/>
{{#if this.isSessionInprogressWithNoConnections}} {{#if this.isSessionInprogressWithNoConnections}}
<Hds::ApplicationState as |A|> <Hds::ApplicationState as |A|>
{{! Session recording in progress with no connections }} {{! Session recording in progress with no connections }}
@@ -4,7 +4,7 @@
*/ */
import { module, test } from 'qunit'; 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 { setupApplicationTest } from 'admin/tests/helpers';
import { setupIntl } from 'ember-intl/test-support'; import { setupIntl } from 'ember-intl/test-support';
import { setupSqlite } from 'api/test-support/helpers/sqlite'; import { setupSqlite } from 'api/test-support/helpers/sqlite';
@@ -175,4 +175,70 @@ module('Acceptance | session-recordings | read', function (hooks) {
assert.notEqual(currentURL(), incorrectUrl); assert.notEqual(currentURL(), incorrectUrl);
assert.strictEqual(currentURL(), urls.sessionRecording); 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();
});
}); });
@@ -22,3 +22,18 @@ export const MANAGE_DROPDOWN =
'[data-test-manage-channels-dropdown] button:first-child'; '[data-test-manage-channels-dropdown] button:first-child';
export const DELETE_ACTION = export const DELETE_ACTION =
'[data-test-manage-channels-dropdown] ul li:last-child button'; '[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';
@@ -135,5 +135,132 @@ module(
assert.notEqual(sessionRecording.delete_after, deleteAfter); 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,
);
});
}, },
); );