Display session playback error (#3280)
This commit is contained in:
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;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -71,3 +71,4 @@ edit-worker-filter: Edit Worker Filter
|
||||
add-worker-filter: Add Worker Filter
|
||||
open: Open
|
||||
download: Download
|
||||
show-more: Show more
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+8
@@ -46,6 +46,14 @@
|
||||
<div class='channels-by-connection'>
|
||||
<Rose::Layout::BodyContent as |bc|>
|
||||
<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}}
|
||||
<Hds::ApplicationState as |A|>
|
||||
{{! Session recording in progress with no connections }}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
+127
@@ -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,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user