diff --git a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/__snapshots__/index.spec.tsx.snap index b54172120..47b356c2f 100644 --- a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/__snapshots__/index.spec.tsx.snap @@ -170,3 +170,60 @@ exports[`Issue component should render the certification error 1`] = `

`; + +exports[`Issue component should render the workspaceInactive error 1`] = ` +
+

+ + + + Warning +

+
+    The workspace is inactive.
+  
+

+ + Restart your workspace + +

+

+ + Return to the dashboard + +

+
+`; diff --git a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/index.spec.tsx index 2c087a314..51aeea44c 100644 --- a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/__tests__/index.spec.tsx @@ -44,6 +44,17 @@ describe('Issue component', () => { expect(renderer.create(component).toJSON()).toMatchSnapshot(); }); + it('should render the workspaceInactive error', () => { + const issue = { + type: 'workspaceInactive', + error: new Error('The workspace is inactive.'), + data: { ideLoader: '', workspaceDetails: '' }, + } as Issue; + const component = ; + + expect(renderer.create(component).toJSON()).toMatchSnapshot(); + }); + it('should render an unknown error', () => { const issue = { type: 'unknown', diff --git a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.tsx b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.tsx index d697a030e..ab12251d2 100644 --- a/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.tsx +++ b/packages/dashboard-frontend/src/Layout/ErrorReporter/Issue/index.tsx @@ -14,7 +14,7 @@ import { TextContent, Text, TextVariants } from '@patternfly/react-core'; import { WarningTriangleIcon } from '@patternfly/react-icons'; import React from 'react'; import { BrandingData } from '../../../services/bootstrap/branding.constant'; -import { Issue } from '../../../services/bootstrap/issuesReporter'; +import { Issue, WorkspaceRoutes } from '../../../services/bootstrap/issuesReporter'; import * as styles from './index.module.css'; @@ -32,6 +32,8 @@ export class IssueComponent extends React.PureComponent { return this.renderCertError(); case 'sso': return this.renderSsoError(issue.error); + case 'workspaceInactive': + return this.renderWorkspaceInactiveError(issue.error, issue.data); default: return this.renderUnknownError(issue.error); } @@ -92,6 +94,53 @@ export class IssueComponent extends React.PureComponent { ); } + private renderWorkspaceInactiveError( + error: Error, + workspaceRoutes: WorkspaceRoutes | undefined, + ): React.ReactNode { + const linkOnClick = (hash: string) => { + return () => { + window.location.hash = hash; + window.location.reload(); + }; + }; + + let ideLoader: React.ReactNode; + let workspaceDetails: React.ReactNode; + + if (workspaceRoutes) { + ideLoader = ( + + Restart your workspace + + ); + + workspaceDetails = ( + + Return to the dashboard + + ); + } + + const warningTextbox = !error ? undefined : ( + + {error.message} + + ); + + return ( + + + + Warning + + {warningTextbox} + {ideLoader} + {workspaceDetails} + + ); + } + private renderUnknownError(error: Error): React.ReactNode { const errorTextbox = !error ? undefined : ( diff --git a/packages/dashboard-frontend/src/preload/__tests__/location.spec.ts b/packages/dashboard-frontend/src/preload/__tests__/location.spec.ts index a700c8117..00905afd5 100644 --- a/packages/dashboard-frontend/src/preload/__tests__/location.spec.ts +++ b/packages/dashboard-frontend/src/preload/__tests__/location.spec.ts @@ -10,7 +10,8 @@ * Red Hat, Inc. - initial API and implementation */ -import { buildFactoryLoaderPath } from '../'; +import SessionStorageService, { SessionStorageKey } from '../../services/session-storage'; +import { buildFactoryLoaderPath, storePathIfNeeded } from '../'; describe('Location test', () => { test('new policy', () => { @@ -49,3 +50,22 @@ describe('Location test', () => { ); }); }); + +describe('storePathnameIfNeeded test', () => { + let mockUpdate: jest.Mock; + + beforeAll(() => { + mockUpdate = jest.fn(); + SessionStorageService.update = mockUpdate; + }); + + test('empty path', () => { + storePathIfNeeded('/'); + expect(mockUpdate).toBeCalledTimes(0); + }); + + test('regular path', () => { + storePathIfNeeded('/test'); + expect(mockUpdate).toBeCalledWith(SessionStorageKey.ORIGINAL_LOCATION_PATH, '/test'); + }); +}); diff --git a/packages/dashboard-frontend/src/preload/index.ts b/packages/dashboard-frontend/src/preload/index.ts index 72b13c935..23d01dc89 100644 --- a/packages/dashboard-frontend/src/preload/index.ts +++ b/packages/dashboard-frontend/src/preload/index.ts @@ -10,10 +10,15 @@ * Red Hat, Inc. - initial API and implementation */ +import SessionStorageService, { SessionStorageKey } from '../services/session-storage'; + (function acceptNewFactoryLink(): void { if (window.location.pathname.startsWith('/dashboard/')) { return; } + + storePathIfNeeded(window.location.pathname); + const hash = window.location.hash.replace(/(\/?)#(\/?)/, '#'); if (hash.startsWith('#http')) { let factoryUrl = hash.substring(1); @@ -27,6 +32,12 @@ } })(); +export function storePathIfNeeded(path: string) { + if (path !== '/') { + SessionStorageService.update(SessionStorageKey.ORIGINAL_LOCATION_PATH, path); + } +} + export function buildFactoryLoaderPath(url: string): string { const fullUrl = new window.URL(url); const editor = extractUrlParam(fullUrl, 'che-editor'); diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index d51a6fc2a..854d04042 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -38,6 +38,9 @@ import { selectDefaultNamespace } from '../../store/InfrastructureNamespaces/sel import { selectDevWorkspacesResourceVersion } from '../../store/Workspaces/devWorkspaces/selectors'; import { AppAlerts } from '../alerts/appAlerts'; import { AlertVariant } from '@patternfly/react-core'; +import SessionStorageService, { SessionStorageKey } from '../session-storage'; +import { buildDetailsLocation, buildIdeLoaderLocation } from '../helpers/location'; +import { selectAllWorkspaces } from '../../store/Workspaces/selectors'; /** * This class executes a few initial instructions @@ -80,10 +83,11 @@ export default class Bootstrap { this.fetchRegistriesMetadata(settings), this.watchNamespaces(), this.updateDevWorkspaceTemplates(settings), - this.fetchWorkspaces(), + this.fetchWorkspaces().then(() => this.checkInactivityShutdown()), this.fetchClusterInfo(), this.fetchClusterConfig(), ]); + const errors = results .filter(result => result.status === 'rejected') .map(result => (result as PromiseRejectedResult).reason.toString()); @@ -267,4 +271,34 @@ export default class Bootstrap { const { requestUserProfile } = UserProfileStore.actionCreators; return requestUserProfile()(this.store.dispatch, this.store.getState, undefined); } + + private checkInactivityShutdown() { + const path = SessionStorageService.remove(SessionStorageKey.ORIGINAL_LOCATION_PATH); + if (!path) { + return; + } + + const state = this.store.getState(); + + const workspace = selectAllWorkspaces(state).find(w => w.ideUrl?.includes(path)); + if (!workspace) { + return; + } + + if (workspace.isRunning && workspace.ideUrl) { + window.location.href = workspace.ideUrl; + return; + } + + const ideLoader = buildIdeLoaderLocation(workspace).pathname; + const workspaceDetails = buildDetailsLocation(workspace).pathname; + + this.issuesReporterService.registerIssue( + 'workspaceInactive', + new Error( + 'Your workspace has stopped. This could happen if your workspace shuts down due to inactivity.', + ), + { ideLoader, workspaceDetails }, + ); + } } diff --git a/packages/dashboard-frontend/src/services/bootstrap/issuesReporter.ts b/packages/dashboard-frontend/src/services/bootstrap/issuesReporter.ts index e3be05175..f7042682b 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/issuesReporter.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/issuesReporter.ts @@ -12,12 +12,15 @@ import { injectable } from 'inversify'; -export type IssueType = 'cert' | 'sso' | 'unknown'; +export type IssueType = 'cert' | 'sso' | 'workspaceInactive' | 'unknown'; export type Issue = { type: IssueType; error: Error; + data?: WorkspaceRoutes; }; +export type WorkspaceRoutes = { ideLoader: string; workspaceDetails: string }; + @injectable() export class IssuesReporterService { private issues: Issue[] = []; @@ -26,8 +29,8 @@ export class IssuesReporterService { return this.issues.length !== 0; } - public registerIssue(type: IssueType, error: Error): void { - this.issues.push({ type, error }); + public registerIssue(type: IssueType, error: Error, data?: WorkspaceRoutes): void { + this.issues.push({ type, error, data }); } public reportIssue(): Issue | undefined { diff --git a/packages/dashboard-frontend/src/services/session-storage/index.ts b/packages/dashboard-frontend/src/services/session-storage/index.ts index 2c042ae13..96f109d62 100644 --- a/packages/dashboard-frontend/src/services/session-storage/index.ts +++ b/packages/dashboard-frontend/src/services/session-storage/index.ts @@ -12,6 +12,7 @@ export enum SessionStorageKey { PRIVATE_FACTORY_RELOADS = 'private-factory-reloads-number', + ORIGINAL_LOCATION_PATH = 'original-location-path', } export default class SessionStorageService { @@ -22,4 +23,10 @@ export default class SessionStorageService { static get(key: SessionStorageKey): string | undefined { return window.sessionStorage.getItem(key) || undefined; } + + static remove(key: SessionStorageKey): string | undefined { + const value = this.get(key); + window.sessionStorage.removeItem(key); + return value; + } }