Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ class ToastNotification {
this.findInfoAlert().should('contain.text', message);
}

findWarningAlert() {
return this.findAlert('warning');
}

assertWarningAlertExists() {
this.findWarningAlert().should('exist');
}

assertWarningAlertNotExists() {
this.find().findByTestId('toast-notification-warning').should('not.exist');
}

assertWarningAlertContainsMessage(message: string) {
this.findWarningAlert().should('contain.text', message);
}

closeErrorAlert() {
this.findErrorAlert().find('button[aria-label="Close"]').click();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,31 @@ class Workspaces {
cy.findByTestId('workspace-lastActivity-tooltip').should('contain.text', text);
}

findActivityWarningIndicator(rowIndex: number) {
return cy.findByTestId(`workspace-row-${rowIndex}`).findByTestId('activity-warning-indicator');
}

findActivityCriticalIndicator(rowIndex: number) {
return cy.findByTestId(`workspace-row-${rowIndex}`).findByTestId('activity-critical-indicator');
}

assertActivityWarningExists(rowIndex: number) {
this.findActivityWarningIndicator(rowIndex).should('exist');
}

assertActivityCriticalExists(rowIndex: number) {
this.findActivityCriticalIndicator(rowIndex).should('exist');
}

assertNoActivityIndicator(rowIndex: number) {
cy.findByTestId(`workspace-row-${rowIndex}`)
.findByTestId('activity-warning-indicator')
.should('not.exist');
cy.findByTestId(`workspace-row-${rowIndex}`)
.findByTestId('activity-critical-indicator')
.should('not.exist');
}

assertTooltipNotExists() {
cy.get('.pf-v6-c-tooltip').should('not.exist');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { mockModArchResponse } from 'mod-arch-core';
import {
buildMockNamespace,
buildMockWorkspace,
buildMockWorkspaceKindInfo,
} from '~/shared/mock/mockBuilder';
import { NOTEBOOKS_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api';
import { workspaces } from '~/__tests__/cypress/cypress/pages/workspaces/workspaces';
import { toastNotification } from '~/__tests__/cypress/cypress/pages/components/toastNotification';
import { V1Beta1WorkspaceState } from '~/generated/data-contracts';

const MINUTE = 60 * 1000;
const FROZEN_TIME = new Date('2025-06-15T12:00:00Z').getTime();

const mockNamespace = buildMockNamespace({ name: 'default' });
const mockKind = buildMockWorkspaceKindInfo({ name: 'jupyterlab' });

const buildActivityWorkspace = (
name: string,
eligibleAfterMs: number,
state: V1Beta1WorkspaceState = V1Beta1WorkspaceState.WorkspaceStateRunning,
) =>
buildMockWorkspace({
name,
namespace: 'default',
workspaceKind: mockKind,
state,
stateMessage: `Workspace is ${state}`,
activity: {
lastActivity: FROZEN_TIME - 10 * MINUTE,
lastUpdate: FROZEN_TIME - 10 * MINUTE,
rules: { pauseWorkspace: { eligibleAfter: FROZEN_TIME + eligibleAfterMs } },
},
});

const buildSafeWorkspace = (name: string) =>
buildMockWorkspace({
name,
namespace: 'default',
workspaceKind: mockKind,
state: V1Beta1WorkspaceState.WorkspaceStateRunning,
stateMessage: 'Workspace is Running',
activity: {
lastActivity: FROZEN_TIME - 5 * MINUTE,
lastUpdate: FROZEN_TIME - 5 * MINUTE,
},
});

const setupIntercepts = (mockWorkspaces: ReturnType<typeof buildMockWorkspace>[]) => {
cy.interceptApi(
'GET /api/:apiVersion/namespaces',
{ path: { apiVersion: NOTEBOOKS_API_VERSION } },
mockModArchResponse([mockNamespace]),
).as('getNamespaces');

cy.interceptApi(
'GET /api/:apiVersion/workspaces/:namespace',
{ path: { apiVersion: NOTEBOOKS_API_VERSION, namespace: 'default' } },
mockModArchResponse(mockWorkspaces),
).as('getWorkspaces');

cy.interceptApi(
'GET /api/:apiVersion/workspacekinds',
{ path: { apiVersion: NOTEBOOKS_API_VERSION } },
mockModArchResponse([]),
).as('getWorkspaceKinds');
};

describe('Activity warning indicators', () => {
beforeEach(() => {
cy.clock(FROZEN_TIME, ['Date']);
});

it('shows warning indicator for workspace within 15 minutes of auto-pause', () => {
const warningWorkspace = buildActivityWorkspace('ws-warning', 10 * MINUTE);
setupIntercepts([warningWorkspace]);

workspaces.visit();
cy.wait('@getWorkspaces');

workspaces.assertActivityWarningExists(0);
});

it('shows critical indicator for workspace within 5 minutes of auto-pause', () => {
const criticalWorkspace = buildActivityWorkspace('ws-critical', 3 * MINUTE);
setupIntercepts([criticalWorkspace]);

workspaces.visit();
cy.wait('@getWorkspaces');

workspaces.assertActivityCriticalExists(0);
});

it('does not show indicator for workspace 30 minutes from auto-pause', () => {
const safeWorkspace = buildActivityWorkspace('ws-safe', 30 * MINUTE);
setupIntercepts([safeWorkspace]);

workspaces.visit();
cy.wait('@getWorkspaces');

workspaces.assertNoActivityIndicator(0);
});

it('does not show indicator for paused workspace regardless of eligibleAfter', () => {
const pausedWorkspace = buildActivityWorkspace(
'ws-paused',
3 * MINUTE,
V1Beta1WorkspaceState.WorkspaceStatePaused,
);
setupIntercepts([pausedWorkspace]);

workspaces.visit();
cy.wait('@getWorkspaces');

workspaces.assertNoActivityIndicator(0);
});

it('does not show indicator for workspace without activity rules', () => {
const noRulesWorkspace = buildSafeWorkspace('ws-no-rules');
setupIntercepts([noRulesWorkspace]);

workspaces.visit();
cy.wait('@getWorkspaces');

workspaces.assertNoActivityIndicator(0);
});

it('shows toast when data refresh transitions workspace into warning state', () => {
const safeWorkspace = buildActivityWorkspace('ws-transition', 20 * MINUTE);
setupIntercepts([safeWorkspace]);

workspaces.visit();
cy.wait('@getWorkspaces');

workspaces.assertNoActivityIndicator(0);
toastNotification.assertWarningAlertNotExists();

const warningWorkspace = buildActivityWorkspace('ws-transition', 10 * MINUTE);
cy.interceptApi(
'GET /api/:apiVersion/workspaces/:namespace',
{ path: { apiVersion: NOTEBOOKS_API_VERSION, namespace: 'default' } },
mockModArchResponse([warningWorkspace]),
).as('getWorkspacesRefresh');

cy.findByTestId('workspace-refresh-now').click();
cy.wait('@getWorkspacesRefresh');

toastNotification.assertWarningAlertExists();
toastNotification.assertWarningAlertContainsMessage('ws-transition');
workspaces.assertActivityWarningExists(0);
});
});
45 changes: 34 additions & 11 deletions workspaces/frontend/src/app/components/WorkspaceTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import React, {
} from 'react';
import { Timestamp } from '@patternfly/react-core/dist/esm/components/Timestamp';
import { Label } from '@patternfly/react-core/dist/esm/components/Label';
import { ExclamationTriangleIcon } from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon';
import { ExclamationCircleIcon } from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon';
import {
PaginationVariant,
Pagination,
Expand Down Expand Up @@ -45,13 +47,12 @@ import {
extractWorkspaceStateColor,
normalizeWorkspaceState,
WORKSPACE_STATE_COLORS,
ActivityWarningLevel,
getActivityStatus,
formatTimeRemaining,
} from '~/shared/utilities/WorkspaceUtils';
import CustomEmptyState from '~/shared/components/CustomEmptyState';
import {
WorkspacesActivity,
WorkspacesWorkspaceListItem,
V1Beta1WorkspaceState,
} from '~/generated/data-contracts';
import { WorkspacesWorkspaceListItem, V1Beta1WorkspaceState } from '~/generated/data-contracts';
import { RedirectIconWithPopover } from '~/app/components/RedirectIconWithPopover';
import { POLL_INTERVAL } from '~/shared/utilities/const';
import { RefreshCounter } from '~/app/components/RefreshCounter';
Expand Down Expand Up @@ -117,7 +118,19 @@ type WorkspaceFilterKey = keyof typeof filterConfig;
// Defines which filters should appear in the dropdown
const visibleFilterKeys: readonly WorkspaceFilterKey[] = ['name', 'kind', 'image', 'state'];

const LastActivityCell: React.FC<{ activity: WorkspacesActivity }> = ({ activity }) => {
const ACTIVITY_WARNING_ICONS: Record<ActivityWarningLevel, React.ReactNode> = {
[ActivityWarningLevel.Warning]: (
<ExclamationTriangleIcon color="orange" data-testid="activity-warning-indicator" />
),
[ActivityWarningLevel.Critical]: (
<ExclamationCircleIcon color="red" data-testid="activity-critical-indicator" />
),
[ActivityWarningLevel.None]: null,
};

const LastActivityCell: React.FC<{ workspace: WorkspacesWorkspaceListItem }> = ({ workspace }) => {
const { activity } = workspace;

if (activity.lastActivity === 0) {
return <span className="pf-v6-c-timestamp pf-m-help-text">unknown</span>;
}
Expand All @@ -128,17 +141,27 @@ const LastActivityCell: React.FC<{ activity: WorkspacesActivity }> = ({ activity
</Timestamp>
);

const pauseRule = activity.rules?.pauseWorkspace;
if (!pauseRule) {
const { warningLevel, timeRemainingMs, actionMessage } = getActivityStatus(workspace);
const eligibleAfter = activity.rules?.pauseWorkspace?.eligibleAfter;

if (eligibleAfter == null) {
return timestamp;
}

const action = actionMessage ?? 'paused';
const tooltipTime =
timeRemainingMs != null
? formatTimeRemaining(timeRemainingMs)
: formatDistanceToNow(new Date(eligibleAfter));

return (
<Tooltip
data-testid="workspace-lastActivity-tooltip"
content={`Workspace will be paused in ${formatDistanceToNow(new Date(pauseRule.eligibleAfter))}`}
content={`Workspace will be ${action} in ${tooltipTime}`}
>
<span>{timestamp}</span>
<span>
{timestamp} {ACTIVITY_WARNING_ICONS[warningLevel]}
</span>
</Tooltip>
);
};
Expand Down Expand Up @@ -568,7 +591,7 @@ const WorkspaceTable = React.forwardRef<WorkspaceTableRef, WorkspaceTableProps>(
{columnKey === 'gpu' && formatResourceFromWorkspace(workspace, 'gpu')}
{columnKey === 'idleGpu' && formatWorkspaceIdleState(workspace)}
{columnKey === 'lastActivity' && (
<LastActivityCell activity={workspace.activity} />
<LastActivityCell workspace={workspace} />
)}
</Td>
);
Expand Down
Loading