Skip to content

Commit 92e9133

Browse files
authored
feat(graph): enhance migration state management and introduce migration stopping functionality. (#31626)
## Overview This PR enhances the Migrate UI by adding a "Stop" button that allows users to halt in-progress migrations at any point during execution. It works in tandem with nrwl/nx-console#2567 ### Currently When a migration is running and needs to be stopped for any reason: - User accidentally triggered the migration - Migration is taking longer than expected - Changes are needed before completion Users must wait for the migration to complete before using the available "Undo" or "Skip" options. ### Expected Users should be able to stop a currently running migration at any time before it completes, providing immediate control over the migration process. ### Key Features: - Refactor guards to improve migration state checks and add conditions for running and completing migrations. - Update the state machine to handle new states for running, stopped, and evaluating migrations. - Implement logic to track running migrations and allow for stopping them gracefully. - Introduce a new process for running migrations in a separate child process to support cancellation. - Enhance metadata management to include stopped migrations and update UI accordingly. (The UI is completely driven by the backend now aka Nx Console) - Add tests to cover new migration states and behaviours.
1 parent b7e73fe commit 92e9133

File tree

14 files changed

+1057
-633
lines changed

14 files changed

+1057
-633
lines changed

graph/client/src/app/console-migrate/migrate.app.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ export function MigrateApp({
106106
});
107107
};
108108

109+
const onStopMigration = (migration: MigrationDetailsWithId) => {
110+
externalApiService.postEvent({
111+
type: 'stop-migration',
112+
payload: { migration },
113+
});
114+
};
115+
109116
return (
110117
<MigrateUI
111118
migrations={migrations}
@@ -119,6 +126,7 @@ export function MigrateApp({
119126
onUndoMigration={onUndoMigration}
120127
onViewImplementation={onViewImplementation}
121128
onViewDocumentation={onViewDocumentation}
129+
onStopMigration={onStopMigration}
122130
></MigrateUI>
123131
);
124132
}

graph/migrate/src/lib/components/automatic-migration.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import type { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate
66
import { useSelector } from '@xstate/react';
77
import {
88
currentMigrationHasChanges,
9-
currentMigrationHasFailed,
10-
currentMigrationHasSucceeded,
9+
getCurrentMigrationType,
1110
} from '../state/automatic/selectors';
1211
import { MigrationTimeline } from './migration-timeline';
1312
import { Interpreter } from 'xstate';
@@ -45,17 +44,18 @@ export function AutomaticMigration(props: {
4544
(migration) => migration.id === currentMigration?.id
4645
);
4746

48-
const currentMigrationRunning = useSelector(
47+
const currentMigrationFailed = useSelector(
4948
props.actor,
50-
(state) => state.context.currentMigrationRunning
49+
(state) => getCurrentMigrationType(state.context) === 'failed'
5150
);
5251

53-
const currentMigrationFailed = useSelector(props.actor, (state) =>
54-
currentMigrationHasFailed(state.context)
52+
const isCurrentMigrationStopped = useSelector(
53+
props.actor,
54+
(state) => getCurrentMigrationType(state.context) === 'stopped'
5555
);
56-
57-
const currentMigrationSuccess = useSelector(props.actor, (state) =>
58-
currentMigrationHasSucceeded(state.context)
56+
const currentMigrationSuccess = useSelector(
57+
props.actor,
58+
(state) => getCurrentMigrationType(state.context) === 'successful'
5959
);
6060

6161
const currentMigrationChanges = useSelector(props.actor, (state) =>
@@ -75,15 +75,16 @@ export function AutomaticMigration(props: {
7575

7676
return (
7777
<MigrationTimeline
78+
actor={props.actor}
7879
migrations={props.migrations}
7980
nxConsoleMetadata={props.nxConsoleMetadata}
8081
currentMigrationIndex={
8182
currentMigrationIndex >= 0 ? currentMigrationIndex : 0
8283
}
83-
currentMigrationRunning={currentMigrationRunning}
8484
currentMigrationFailed={currentMigrationFailed}
8585
currentMigrationSuccess={currentMigrationSuccess}
8686
currentMigrationHasChanges={currentMigrationChanges}
87+
currentMigrationStopped={isCurrentMigrationStopped}
8788
isDone={isDone}
8889
isInit={isInit}
8990
onRunMigration={props.onRunMigration}

graph/migrate/src/lib/components/migration-card.tsx

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ import {
2020
type ReactNode,
2121
} from 'react';
2222
import { AnimatePresence, motion } from 'framer-motion';
23+
import type { Interpreter } from 'xstate';
24+
import type {
25+
AutomaticMigrationState,
26+
AutomaticMigrationEvents,
27+
} from '../state/automatic/types';
28+
import { useSelector } from '@xstate/react';
29+
import {
30+
currentMigrationHasChanges,
31+
getMigrationType,
32+
isMigrationRunning,
33+
} from '../state/automatic/selectors';
2334

2435
export interface MigrationCardHandle {
2536
expand: () => void;
@@ -37,6 +48,7 @@ function convertUrlsToLinks(text: string): ReactNode[] {
3748
result.push(
3849
<a
3950
key={i}
51+
rel="noopener noreferrer"
4052
href={urls[i - 1]}
4153
target="_blank"
4254
className="text-blue-500 hover:underline"
@@ -54,6 +66,13 @@ function convertUrlsToLinks(text: string): ReactNode[] {
5466
export const MigrationCard = forwardRef<
5567
MigrationCardHandle,
5668
{
69+
actor: Interpreter<
70+
AutomaticMigrationState,
71+
any,
72+
AutomaticMigrationEvents,
73+
any,
74+
any
75+
>;
5776
migration: MigrationDetailsWithId;
5877
nxConsoleMetadata: MigrationsJsonMetadata;
5978
isSelected?: boolean;
@@ -62,11 +81,11 @@ export const MigrationCard = forwardRef<
6281
onFileClick: (file: Omit<FileChange, 'content'>) => void;
6382
onViewImplementation: () => void;
6483
onViewDocumentation: () => void;
65-
forceIsRunning?: boolean;
6684
isExpanded?: boolean;
6785
}
6886
>(function MigrationCard(
6987
{
88+
actor,
7089
migration,
7190
nxConsoleMetadata,
7291
isSelected,
@@ -75,7 +94,6 @@ export const MigrationCard = forwardRef<
7594
onFileClick,
7695
onViewImplementation,
7796
onViewDocumentation,
78-
forceIsRunning,
7997
isExpanded: isExpandedProp,
8098
},
8199
ref
@@ -99,14 +117,35 @@ export const MigrationCard = forwardRef<
99117
}, [isExpandedProp]);
100118

101119
const migrationResult = nxConsoleMetadata.completedMigrations?.[migration.id];
102-
const succeeded = migrationResult?.type === 'successful';
103-
const failed = migrationResult?.type === 'failed';
104-
const skipped = migrationResult?.type === 'skipped';
105-
const inProgress = nxConsoleMetadata.runningMigrations?.includes(
106-
migration.id
107-
);
108120

109-
const madeChanges = succeeded && !!migrationResult?.changedFiles.length;
121+
const filesChanged =
122+
migrationResult?.type === 'successful' ? migrationResult.changedFiles : [];
123+
124+
const nextSteps =
125+
migrationResult?.type === 'successful' ? migrationResult.nextSteps : [];
126+
127+
const isSucceeded = useSelector(
128+
actor,
129+
(state) => getMigrationType(state.context, migration.id) === 'successful'
130+
);
131+
const isFailed = useSelector(
132+
actor,
133+
(state) => getMigrationType(state.context, migration.id) === 'failed'
134+
);
135+
const isSkipped = useSelector(
136+
actor,
137+
(state) => getMigrationType(state.context, migration.id) === 'skipped'
138+
);
139+
const isStopped = useSelector(
140+
actor,
141+
(state) => getMigrationType(state.context, migration.id) === 'stopped'
142+
);
143+
const hasChanges = useSelector(actor, (state) =>
144+
currentMigrationHasChanges(state.context)
145+
);
146+
const isRunning = useSelector(actor, (state) =>
147+
isMigrationRunning(state.context, migration.id)
148+
);
110149

111150
const renderSelectBox = onSelect && isSelected !== undefined;
112151

@@ -130,9 +169,9 @@ export const MigrationCard = forwardRef<
130169
value={migration.id}
131170
type="checkbox"
132171
className={`h-4 w-4 ${
133-
succeeded
172+
isSucceeded
134173
? 'accent-green-600 dark:accent-green-500'
135-
: failed
174+
: isFailed
136175
? 'accent-red-600 dark:accent-red-500'
137176
: 'accent-blue-500 dark:accent-sky-500'
138177
}`}
@@ -169,10 +208,10 @@ export const MigrationCard = forwardRef<
169208

170209
<div className="flex items-center gap-2">
171210
{' '}
172-
{succeeded && !madeChanges && (
211+
{isSucceeded && !hasChanges && (
173212
<Pill text="No changes made" color="green" />
174213
)}
175-
{succeeded && madeChanges && (
214+
{isSucceeded && hasChanges && (
176215
<div>
177216
<div
178217
className="cursor-pointer"
@@ -182,38 +221,43 @@ export const MigrationCard = forwardRef<
182221
>
183222
<Pill
184223
key="changes"
185-
text={`${migrationResult?.changedFiles.length} changes`}
224+
text={`${filesChanged.length} changes`}
186225
color="green"
187226
/>
188227
</div>
189228
</div>
190229
)}
191-
{failed && (
230+
{isFailed && (
192231
<div>
193232
<Pill text="Failed" color="red" />
194233
</div>
195234
)}
196-
{skipped && (
235+
{isSkipped && (
197236
<div>
198237
<Pill text="Skipped" color="grey" />
199238
</div>
200239
)}
201-
{(onRunMigration || forceIsRunning) && (
240+
{isStopped && (
241+
<div>
242+
<Pill text="Stopped" color="yellow" />
243+
</div>
244+
)}
245+
{onRunMigration && !isStopped && (
202246
<span
203247
className={`rounded-md p-1 text-sm ring-1 ring-inset transition-colors ${
204-
succeeded
248+
isSucceeded
205249
? 'bg-green-50 text-green-700 ring-green-200 hover:bg-green-100 dark:bg-green-900/20 dark:text-green-500 dark:ring-green-900/30 dark:hover:bg-green-900/30'
206-
: failed
250+
: isFailed
207251
? 'bg-red-50 text-red-700 ring-red-200 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:ring-red-900/30 dark:hover:bg-red-900/30'
208252
: 'bg-inherit text-slate-600 ring-slate-400/40 hover:bg-slate-200 dark:text-slate-300 dark:ring-slate-400/30 dark:hover:bg-slate-700/60'
209253
}`}
210254
>
211-
{inProgress || forceIsRunning ? (
255+
{isRunning ? (
212256
<ArrowPathIcon
213257
className="h-6 w-6 animate-spin cursor-not-allowed text-blue-500"
214258
aria-label="Migration in progress"
215259
/>
216-
) : !succeeded && !failed ? (
260+
) : !isSucceeded && !isFailed && !isStopped ? (
217261
<PlayIcon
218262
onClick={onRunMigration}
219263
className="h-6 w-6 !cursor-pointer"
@@ -230,14 +274,14 @@ export const MigrationCard = forwardRef<
230274
)}
231275
</div>
232276
</div>
233-
{succeeded && migrationResult?.nextSteps?.length ? (
277+
{isSucceeded && nextSteps?.length ? (
234278
<div className="pt-2">
235279
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60" />
236280
<span className="pb-2 text-sm font-bold">
237281
More Information & Next Steps
238282
</span>
239283
<ul className="list-inside list-disc pl-2">
240-
{migrationResult?.nextSteps.map((step, idx) => (
284+
{nextSteps.map((step, idx) => (
241285
<li key={idx} className="text-sm">
242286
{convertUrlsToLinks(step)}
243287
</li>
@@ -255,7 +299,7 @@ export const MigrationCard = forwardRef<
255299
<CodeBracketIcon className="h-4 w-4" />
256300
View Source
257301
</button>
258-
{failed && (
302+
{isFailed && (
259303
<button
260304
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
261305
onClick={() => {
@@ -266,7 +310,7 @@ export const MigrationCard = forwardRef<
266310
{isExpanded ? 'Hide Errors' : 'View Errors'}
267311
</button>
268312
)}
269-
{succeeded && madeChanges && (
313+
{isSucceeded && hasChanges && (
270314
<button
271315
className="flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 hover:dark:bg-slate-700"
272316
onClick={() => {
@@ -279,21 +323,35 @@ export const MigrationCard = forwardRef<
279323
)}
280324
</div>
281325
<AnimatePresence>
282-
{failed && isExpanded && (
326+
{isFailed && isExpanded && (
327+
<motion.div
328+
initial={{ opacity: 0, height: 0 }}
329+
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
330+
exit={{ opacity: 0, height: 0 }}
331+
transition={{ duration: 0.3, ease: 'easeInOut' }}
332+
className="flex overflow-hidden pt-2"
333+
>
334+
<pre>{(migrationResult as any)?.error}</pre>
335+
</motion.div>
336+
)}
337+
</AnimatePresence>
338+
339+
<AnimatePresence>
340+
{isStopped && isExpanded && (
283341
<motion.div
284342
initial={{ opacity: 0, height: 0 }}
285343
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
286344
exit={{ opacity: 0, height: 0 }}
287345
transition={{ duration: 0.3, ease: 'easeInOut' }}
288346
className="flex overflow-hidden pt-2"
289347
>
290-
<pre>{migrationResult?.error}</pre>
348+
<pre>{(migrationResult as any)?.error}</pre>
291349
</motion.div>
292350
)}
293351
</AnimatePresence>
294352

295353
<AnimatePresence>
296-
{succeeded && madeChanges && isExpanded && (
354+
{isSucceeded && hasChanges && isExpanded && (
297355
<motion.div
298356
initial={{ opacity: 0, height: 0 }}
299357
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
@@ -304,7 +362,7 @@ export const MigrationCard = forwardRef<
304362
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60"></div>
305363
<span className="pb-2 text-sm font-bold">File Changes</span>
306364
<ul className="flex flex-col gap-2">
307-
{migrationResult?.changedFiles.map((file) => {
365+
{filesChanged.map((file) => {
308366
return (
309367
<li
310368
className="cursor-pointer text-sm hover:underline"

graph/migrate/src/lib/components/migration-list.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,20 @@ import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
77
import { PlayIcon } from '@heroicons/react/24/outline';
88
import { useCallback, useMemo, useState } from 'react';
99
import { MigrationCard } from './migration-card';
10+
import type { Interpreter } from 'xstate';
11+
import type {
12+
AutomaticMigrationState,
13+
AutomaticMigrationEvents,
14+
} from '../state/automatic/types';
1015

1116
export function MigrationList(props: {
17+
actor: Interpreter<
18+
AutomaticMigrationState,
19+
any,
20+
AutomaticMigrationEvents,
21+
any,
22+
any
23+
>;
1224
migrations: MigrationDetailsWithId[];
1325
nxConsoleMetadata: MigrationsJsonMetadata;
1426
onRunMigration: (migration: MigrationDetailsWithId) => void;
@@ -133,6 +145,7 @@ export function MigrationList(props: {
133145
<div>
134146
{props.migrations.map((migration) => (
135147
<MigrationCard
148+
actor={props.actor}
136149
key={migration.id}
137150
migration={migration}
138151
nxConsoleMetadata={props.nxConsoleMetadata}

0 commit comments

Comments
 (0)