Skip to content

Commit

Permalink
WOR-534-support async cloning in workspace dashboard (#4819)
Browse files Browse the repository at this point in the history
Co-authored-by: Nick Watts <[email protected]>
  • Loading branch information
blakery and nawatts authored May 14, 2024
1 parent 7098a8d commit c15da18
Show file tree
Hide file tree
Showing 17 changed files with 775 additions and 494 deletions.
5 changes: 4 additions & 1 deletion src/analysis/Environments/Environments.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,17 @@ const getMockLeoDisks = (): EnvironmentsProps['leoDiskData'] => {
const getMockUseWorkspaces = (mockResults: WorkspaceWrapper[]): EnvironmentsProps['useWorkspaces'] => {
const useMockHook: UseWorkspaces = () => {
const [loading, setLoading] = useState<boolean>(false);

const [status, setStatus] = useState<'Ready' | 'Loading' | 'Error'>('Ready');
return {
workspaces: mockResults,
loading,
status,
refresh: async () => {
setLoading(true);
setStatus('Loading');
await delay(1000);
setLoading(false);
setStatus('Ready');
},
};
};
Expand Down
4 changes: 3 additions & 1 deletion src/analysis/Environments/Environments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { LeoRuntimeProvider } from 'src/libs/ajax/leonardo/providers/LeoRuntimeP
import { leoResourcePermissions } from 'src/pages/EnvironmentsPage/environmentsPermissions';
import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils';
import { defaultAzureWorkspace, defaultGoogleWorkspace } from 'src/testing/workspace-fixtures';
import { UseWorkspacesResult } from 'src/workspaces/common/state/useWorkspaces.models';
import { WorkspaceWrapper } from 'src/workspaces/utils';

import { DataRefreshInfo, EnvironmentNavActions, Environments, EnvironmentsProps } from './Environments';
Expand All @@ -37,10 +38,11 @@ const mockNav: NavLinkProvider<EnvironmentNavActions> = {
navTo: jest.fn(),
};

const defaultUseWorkspacesProps = {
const defaultUseWorkspacesProps: UseWorkspacesResult = {
workspaces: [defaultGoogleWorkspace] as WorkspaceWrapper[],
refresh: () => Promise.resolve(),
loading: false,
status: 'Ready',
};

const getMockLeoAppProvider = (overrides?: Partial<LeoAppProvider>): LeoAppProvider => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('useAnalysisExportState', () => {
workspaces: [],
loading: false,
refresh: jest.fn(),
status: 'Ready',
});

const sourceWorkspaceInfo: Partial<WorkspaceInfo> = {
Expand Down Expand Up @@ -117,6 +118,7 @@ describe('useAnalysisExportState', () => {
],
loading: false,
refresh: jest.fn(),
status: 'Ready',
});
asMockedFn(AnalysisProvider.listAnalyses).mockResolvedValue([analysis1, analysis2]);

Expand Down Expand Up @@ -176,6 +178,7 @@ describe('useAnalysisExportState', () => {
],
loading: false,
refresh: jest.fn(),
status: 'Ready',
});
asMockedFn(AnalysisProvider.listAnalyses).mockResolvedValue([
{ name: 'files/Analysis1.ipynb' as AbsolutePath } as AnalysisFile,
Expand Down Expand Up @@ -233,6 +236,7 @@ describe('useAnalysisExportState', () => {
],
loading: false,
refresh: jest.fn(),
status: 'Ready',
});
asMockedFn(AnalysisProvider.listAnalyses).mockResolvedValue([
{ name: 'files/Analysis1.ipynb' as AbsolutePath } as AnalysisFile,
Expand Down Expand Up @@ -299,6 +303,7 @@ describe('useAnalysisExportState', () => {
],
loading: false,
refresh: jest.fn(),
status: 'Ready',
});
asMockedFn(AnalysisProvider.listAnalyses).mockResolvedValue([
{ name: 'files/Analysis1.ipynb' as AbsolutePath } as AnalysisFile,
Expand Down
1 change: 1 addition & 0 deletions src/import-data/ImportData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ describe('ImportData', () => {
workspaces: [defaultAzureWorkspace, defaultGoogleWorkspace],
loading: false,
refresh: () => Promise.resolve(),
status: 'Ready',
});
});

Expand Down
1 change: 1 addition & 0 deletions src/import-data/ImportDataDestination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const setup = (opts: SetupOptions): void => {
asMockedFn(useWorkspaces).mockReturnValue({
loading: false,
refresh: () => Promise.resolve(),
status: 'Ready',
workspaces,
});

Expand Down
5 changes: 5 additions & 0 deletions src/libs/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ const Workspaces = (signal) => ({
const root = `workspaces/v2/${namespace}/${name}`;

return {
clone: async (body) => {
const res = await fetchRawls(`${root}/clone`, _.mergeAll([authOpts(), jsonBody(body), { signal, method: 'POST' }]));
return res.json();
},

delete: () => {
return fetchRawls(root, _.merge(authOpts(), { signal, method: 'DELETE' }));
},
Expand Down
1 change: 1 addition & 0 deletions src/pages/ImportWorkflow/ImportWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ describe('ImportWorkflow', () => {
] as WorkspaceWrapper[],
refresh: () => Promise.resolve(),
loading: false,
status: 'Ready',
});

asMockedFn(getTerraUser).mockReturnValue({
Expand Down
130 changes: 130 additions & 0 deletions src/workspaces/common/state/useWorkspaceStatePolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { LoadedState } from '@terra-ui-packages/core-utils';
import _ from 'lodash/fp';
import { useEffect, useRef, useState } from 'react';
import { Ajax } from 'src/libs/ajax';
import { workspacesStore, workspaceStore } from 'src/libs/state';
import { pollWithCancellation } from 'src/libs/utils';
import { BaseWorkspaceInfo, WorkspaceState, WorkspaceWrapper as Workspace } from 'src/workspaces/utils';

interface WorkspaceUpdate extends Partial<BaseWorkspaceInfo> {
workspaceId: string;
state: WorkspaceState;
errorMessage?: string;
}

// Returns a new list of workspaces with the workspace matching the update replaced with the updated version
const updateWorkspacesList = (workspaces: Workspace[], update: WorkspaceUpdate): Workspace[] =>
workspaces.map((ws) => updateWorkspaceIfMatching(ws, update));

// If the workspace matches the update, return a new workspace with the update applied
// otherwise return the original workspace unchanged
const updateWorkspaceIfMatching = <T extends Workspace>(workspace: T, update: WorkspaceUpdate): T => {
if (workspace.workspace?.workspaceId === update.workspaceId) {
return _.merge(_.cloneDeep(workspace), { workspace: update });
}
return workspace;
};

// Applies the update to the workspace stores and returns the update state
const doUpdate = (update: WorkspaceUpdate): WorkspaceState => {
workspacesStore.update((wsList) => updateWorkspacesList(wsList, update));
workspaceStore.update((ws) => {
if (ws) {
updateWorkspaceIfMatching(ws, update);
}
return undefined;
});
return update.state;
};

// Polls the workspace state, and updates the stores if the state changes
// Returns the new state or if the workspace state transitions or undefined if the workspace was not updated
const checkWorkspaceState = async (workspace: Workspace, signal: AbortSignal): Promise<WorkspaceState | undefined> => {
const startingState = workspace.workspace.state;
try {
const wsResp: Workspace = await Ajax(signal)
.Workspaces.workspace(workspace.workspace.namespace, workspace.workspace.name)
.details(['workspace.state', 'workspace.errorMessage']);
const state = wsResp.workspace.state;

if (!!state && state !== startingState) {
const update = {
workspaceId: workspace.workspace.workspaceId,
state,
errorMessage: wsResp.workspace.errorMessage,
};
return doUpdate(update);
}
return undefined;
} catch (error) {
// for workspaces that are being deleted, the details endpoint will return a 404 when the deletion is complete
if (startingState === 'Deleting' && error instanceof Response && error.status === 404) {
const update: WorkspaceUpdate = { workspaceId: workspace.workspace.workspaceId, state: 'Deleted' };
return doUpdate(update);
}
console.error(`Error checking workspace state for ${workspace.workspace.name}: ${JSON.stringify(error)}`);
return undefined;
}
};

const updatingStates: WorkspaceState[] = ['Deleting', 'Cloning', 'CloningContainer'];

export const useWorkspaceStatePolling = (workspaces: Workspace[], status: LoadedState<Workspace[]>['status']) => {
// we have to do the signal/abort manually instead of with useCancelable so that the it can be cleaned up in the
// this component's useEffect, instead of the useEffect in useCancelable
const controller = useRef(new window.AbortController());
const abort = () => {
controller.current.abort();
controller.current = new window.AbortController();
};

useEffect(() => {
const iterateUpdatingWorkspaces = async () => {
// wait for the workspaces to be loaded (LoadedState.status) before polling
if (status === 'Ready') {
const updatingWorkspaces = _.filter((ws) => _.contains(ws?.workspace?.state, updatingStates), workspaces);
for (const ws of updatingWorkspaces) {
const updatedState = await checkWorkspaceState(ws, controller.current.signal);
if (updatedState) {
abort();
}
}
}
};

pollWithCancellation(() => iterateUpdatingWorkspaces(), 30000, false, controller.current.signal);
return () => {
abort();
};
}, [workspaces, status]); // adding the controller to deps causes a double fire of the effect
};

// we need a separate implementation of this, because using useWorkspaceStatePolling with a list
// containing a single workspace in the WorkspaceContainer causes a rendering loop
export const useSingleWorkspaceStatePolling = (workspace: Workspace) => {
const [controller, setController] = useState(new window.AbortController());
const abort = () => {
controller.abort();
setController(new window.AbortController());
};

useEffect(() => {
pollWithCancellation(
async () => {
if (_.contains(workspace?.workspace?.state, updatingStates)) {
const updatedState = await checkWorkspaceState(workspace, controller.signal);
if (updatedState) {
abort();
}
}
},
30000,
false,
controller.signal
);
return () => {
abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace]); // adding the controller to deps causes a double fire of the effect
};
2 changes: 2 additions & 0 deletions src/workspaces/common/state/useWorkspaces.models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { LoadedState } from '@terra-ui-packages/core-utils';
import { FieldsArg } from 'src/libs/ajax/workspaces/providers/WorkspaceProvider';
import { WorkspaceWrapper } from 'src/workspaces/utils';

Expand All @@ -8,6 +9,7 @@ export interface UseWorkspacesResult {
workspaces: WorkspaceWrapper[];
refresh: () => Promise<void>;
loading: boolean;
status: LoadedState<WorkspaceWrapper[]>['status'];
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/workspaces/common/state/useWorkspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const useWorkspaces = (fieldsArg?: FieldsArg, stringAttributeMaxLength?:
workspaces: workspaces.state !== null ? workspaces.state : [],
refresh: () => updateWorkspaces(getData),
loading: workspaces.status === 'Loading',
status: workspaces.status,
};

return hookResult;
};
Loading

0 comments on commit c15da18

Please sign in to comment.