Skip to content

feat(graph): enhance migration state management and introduce migration stopping functionality. #31626

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 25, 2025
Merged
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
8 changes: 8 additions & 0 deletions graph/client/src/app/console-migrate/migrate.app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export function MigrateApp({
});
};

const onStopMigration = (migration: MigrationDetailsWithId) => {
externalApiService.postEvent({
type: 'stop-migration',
payload: { migration },
});
};

return (
<MigrateUI
migrations={migrations}
Expand All @@ -119,6 +126,7 @@ export function MigrateApp({
onUndoMigration={onUndoMigration}
onViewImplementation={onViewImplementation}
onViewDocumentation={onViewDocumentation}
onStopMigration={onStopMigration}
></MigrateUI>
);
}
21 changes: 11 additions & 10 deletions graph/migrate/src/lib/components/automatic-migration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import type { MigrationsJsonMetadata } from 'nx/src/command-line/migrate/migrate
import { useSelector } from '@xstate/react';
import {
currentMigrationHasChanges,
currentMigrationHasFailed,
currentMigrationHasSucceeded,
getCurrentMigrationType,
} from '../state/automatic/selectors';
import { MigrationTimeline } from './migration-timeline';
import { Interpreter } from 'xstate';
Expand Down Expand Up @@ -45,17 +44,18 @@ export function AutomaticMigration(props: {
(migration) => migration.id === currentMigration?.id
);

const currentMigrationRunning = useSelector(
const currentMigrationFailed = useSelector(
props.actor,
(state) => state.context.currentMigrationRunning
(state) => getCurrentMigrationType(state.context) === 'failed'
);

const currentMigrationFailed = useSelector(props.actor, (state) =>
currentMigrationHasFailed(state.context)
const isCurrentMigrationStopped = useSelector(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit but why does this one start with is... when the others don't?

props.actor,
(state) => getCurrentMigrationType(state.context) === 'stopped'
);

const currentMigrationSuccess = useSelector(props.actor, (state) =>
currentMigrationHasSucceeded(state.context)
const currentMigrationSuccess = useSelector(
props.actor,
(state) => getCurrentMigrationType(state.context) === 'successful'
);

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

return (
<MigrationTimeline
actor={props.actor}
migrations={props.migrations}
nxConsoleMetadata={props.nxConsoleMetadata}
currentMigrationIndex={
currentMigrationIndex >= 0 ? currentMigrationIndex : 0
}
currentMigrationRunning={currentMigrationRunning}
currentMigrationFailed={currentMigrationFailed}
currentMigrationSuccess={currentMigrationSuccess}
currentMigrationHasChanges={currentMigrationChanges}
currentMigrationStopped={isCurrentMigrationStopped}
isDone={isDone}
isInit={isInit}
onRunMigration={props.onRunMigration}
Expand Down
116 changes: 87 additions & 29 deletions graph/migrate/src/lib/components/migration-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ import {
type ReactNode,
} from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { Interpreter } from 'xstate';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from '../state/automatic/types';
import { useSelector } from '@xstate/react';
import {
currentMigrationHasChanges,
getMigrationType,
isMigrationRunning,
} from '../state/automatic/selectors';

export interface MigrationCardHandle {
expand: () => void;
Expand All @@ -37,6 +48,7 @@ function convertUrlsToLinks(text: string): ReactNode[] {
result.push(
<a
key={i}
rel="noopener noreferrer"
href={urls[i - 1]}
target="_blank"
className="text-blue-500 hover:underline"
Expand All @@ -54,6 +66,13 @@ function convertUrlsToLinks(text: string): ReactNode[] {
export const MigrationCard = forwardRef<
MigrationCardHandle,
{
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
migration: MigrationDetailsWithId;
nxConsoleMetadata: MigrationsJsonMetadata;
isSelected?: boolean;
Expand All @@ -62,11 +81,11 @@ export const MigrationCard = forwardRef<
onFileClick: (file: Omit<FileChange, 'content'>) => void;
onViewImplementation: () => void;
onViewDocumentation: () => void;
forceIsRunning?: boolean;
isExpanded?: boolean;
}
>(function MigrationCard(
{
actor,
migration,
nxConsoleMetadata,
isSelected,
Expand All @@ -75,7 +94,6 @@ export const MigrationCard = forwardRef<
onFileClick,
onViewImplementation,
onViewDocumentation,
forceIsRunning,
isExpanded: isExpandedProp,
},
ref
Expand All @@ -99,14 +117,35 @@ export const MigrationCard = forwardRef<
}, [isExpandedProp]);

const migrationResult = nxConsoleMetadata.completedMigrations?.[migration.id];
const succeeded = migrationResult?.type === 'successful';
const failed = migrationResult?.type === 'failed';
const skipped = migrationResult?.type === 'skipped';
const inProgress = nxConsoleMetadata.runningMigrations?.includes(
migration.id
);

const madeChanges = succeeded && !!migrationResult?.changedFiles.length;
const filesChanged =
migrationResult?.type === 'successful' ? migrationResult.changedFiles : [];

const nextSteps =
migrationResult?.type === 'successful' ? migrationResult.nextSteps : [];

const isSucceeded = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'successful'
);
const isFailed = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'failed'
);
const isSkipped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'skipped'
);
const isStopped = useSelector(
actor,
(state) => getMigrationType(state.context, migration.id) === 'stopped'
);
const hasChanges = useSelector(actor, (state) =>
currentMigrationHasChanges(state.context)
);
const isRunning = useSelector(actor, (state) =>
isMigrationRunning(state.context, migration.id)
);

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

Expand All @@ -130,9 +169,9 @@ export const MigrationCard = forwardRef<
value={migration.id}
type="checkbox"
className={`h-4 w-4 ${
succeeded
isSucceeded
? 'accent-green-600 dark:accent-green-500'
: failed
: isFailed
? 'accent-red-600 dark:accent-red-500'
: 'accent-blue-500 dark:accent-sky-500'
}`}
Expand Down Expand Up @@ -169,10 +208,10 @@ export const MigrationCard = forwardRef<

<div className="flex items-center gap-2">
{' '}
{succeeded && !madeChanges && (
{isSucceeded && !hasChanges && (
<Pill text="No changes made" color="green" />
)}
{succeeded && madeChanges && (
{isSucceeded && hasChanges && (
<div>
<div
className="cursor-pointer"
Expand All @@ -182,38 +221,43 @@ export const MigrationCard = forwardRef<
>
<Pill
key="changes"
text={`${migrationResult?.changedFiles.length} changes`}
text={`${filesChanged.length} changes`}
color="green"
/>
</div>
</div>
)}
{failed && (
{isFailed && (
<div>
<Pill text="Failed" color="red" />
</div>
)}
{skipped && (
{isSkipped && (
<div>
<Pill text="Skipped" color="grey" />
</div>
)}
{(onRunMigration || forceIsRunning) && (
{isStopped && (
<div>
<Pill text="Stopped" color="yellow" />
</div>
)}
{onRunMigration && !isStopped && (
<span
className={`rounded-md p-1 text-sm ring-1 ring-inset transition-colors ${
succeeded
isSucceeded
? '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'
: failed
: isFailed
? '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'
: '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'
}`}
>
{inProgress || forceIsRunning ? (
{isRunning ? (
<ArrowPathIcon
className="h-6 w-6 animate-spin cursor-not-allowed text-blue-500"
aria-label="Migration in progress"
/>
) : !succeeded && !failed ? (
) : !isSucceeded && !isFailed && !isStopped ? (
<PlayIcon
onClick={onRunMigration}
className="h-6 w-6 !cursor-pointer"
Expand All @@ -230,14 +274,14 @@ export const MigrationCard = forwardRef<
)}
</div>
</div>
{succeeded && migrationResult?.nextSteps?.length ? (
{isSucceeded && nextSteps?.length ? (
<div className="pt-2">
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60" />
<span className="pb-2 text-sm font-bold">
More Information & Next Steps
</span>
<ul className="list-inside list-disc pl-2">
{migrationResult?.nextSteps.map((step, idx) => (
{nextSteps.map((step, idx) => (
<li key={idx} className="text-sm">
{convertUrlsToLinks(step)}
</li>
Expand All @@ -255,7 +299,7 @@ export const MigrationCard = forwardRef<
<CodeBracketIcon className="h-4 w-4" />
View Source
</button>
{failed && (
{isFailed && (
<button
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"
onClick={() => {
Expand All @@ -266,7 +310,7 @@ export const MigrationCard = forwardRef<
{isExpanded ? 'Hide Errors' : 'View Errors'}
</button>
)}
{succeeded && madeChanges && (
{isSucceeded && hasChanges && (
<button
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"
onClick={() => {
Expand All @@ -279,21 +323,35 @@ export const MigrationCard = forwardRef<
)}
</div>
<AnimatePresence>
{failed && isExpanded && (
{isFailed && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex overflow-hidden pt-2"
>
<pre>{(migrationResult as any)?.error}</pre>
</motion.div>
)}
</AnimatePresence>

<AnimatePresence>
{isStopped && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="flex overflow-hidden pt-2"
>
<pre>{migrationResult?.error}</pre>
<pre>{(migrationResult as any)?.error}</pre>
</motion.div>
)}
</AnimatePresence>

<AnimatePresence>
{succeeded && madeChanges && isExpanded && (
{isSucceeded && hasChanges && isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: isExpanded ? 'auto' : 0 }}
Expand All @@ -304,7 +362,7 @@ export const MigrationCard = forwardRef<
<div className="my-2 border-t border-slate-200 dark:border-slate-700/60"></div>
<span className="pb-2 text-sm font-bold">File Changes</span>
<ul className="flex flex-col gap-2">
{migrationResult?.changedFiles.map((file) => {
{filesChanged.map((file) => {
return (
<li
className="cursor-pointer text-sm hover:underline"
Expand Down
13 changes: 13 additions & 0 deletions graph/migrate/src/lib/components/migration-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ import type { MigrationDetailsWithId } from 'nx/src/config/misc-interfaces';
import { PlayIcon } from '@heroicons/react/24/outline';
import { useCallback, useMemo, useState } from 'react';
import { MigrationCard } from './migration-card';
import type { Interpreter } from 'xstate';
import type {
AutomaticMigrationState,
AutomaticMigrationEvents,
} from '../state/automatic/types';

export function MigrationList(props: {
actor: Interpreter<
AutomaticMigrationState,
any,
AutomaticMigrationEvents,
any,
any
>;
migrations: MigrationDetailsWithId[];
nxConsoleMetadata: MigrationsJsonMetadata;
onRunMigration: (migration: MigrationDetailsWithId) => void;
Expand Down Expand Up @@ -133,6 +145,7 @@ export function MigrationList(props: {
<div>
{props.migrations.map((migration) => (
<MigrationCard
actor={props.actor}
key={migration.id}
migration={migration}
nxConsoleMetadata={props.nxConsoleMetadata}
Expand Down
Loading
Loading