Skip to content

Commit 2379c86

Browse files
nickmazziclaude
andauthored
feat(autorag/automl): add retry and stop actions to runs table rows (#7321)
* feat(autorag/automl): add retry and stop actions to runs table rows Add kebab action menus with Stop and Retry actions to each row in the AutoML and AutoRAG runs tables. Extract shared utilities (isRunActive, isRunRetryable) and hooks (useAutomlRunActions, useAutoragRunActions) so both the results page and table rows consume the same logic. Update StopRunModal to optionally display the run name. Thread onActionComplete callback from usePipelineRuns.refresh through the table for immediate data refresh after actions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(autorag/automl): invalidate query cache on stop and improve action types Add query cache invalidation after stop actions (matching retry behavior), support async onActionComplete callbacks, destructure options for stable useCallback deps, and add screenReaderText to actions column for a11y. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(automl,autorag): remove CANCELING from terminatable states and clarify run state utilities CANCELING runs are still in progress but should not be stoppable again. Introduces isRunInProgress (RUNNING, PENDING, CANCELING) for leaderboard in-progress checks, renames isRunActive to isRunTerminatable for clarity, and removes CANCELING from BFF terminatableStates and API docs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(automl,autorag): remove screenReaderText from table action columns Empty-label columns correctly render as <Td> which is valid for a11y — only empty <Th> headers are problematic. Remove the unnecessary screenReaderText prop from TableBase and its callers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(autorag+automl): improve run action error handling and simplify hook API Move onActionComplete outside the error catch block so caller refresh failures don't mask successful retry/stop operations. Simplify the hook signature from an options object to a direct callback parameter. Pass runName to StopRunModal for better UX. Update docs to use consistent "terminatable state" wording. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f5c2c10 commit 2379c86

32 files changed

Lines changed: 942 additions & 208 deletions

packages/automl/bff/internal/api/pipeline_runs_handler.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,15 @@ func (app *App) mapMutationError(w http.ResponseWriter, r *http.Request, err err
246246

247247
// terminatableStates lists the run states that are eligible for termination.
248248
var terminatableStates = map[string]bool{
249-
"PENDING": true,
250-
"RUNNING": true,
251-
"PAUSED": true,
252-
"CANCELING": true,
249+
"PENDING": true,
250+
"RUNNING": true,
251+
"PAUSED": true,
253252
}
254253

255254
// TerminatePipelineRunHandler handles POST /api/v1/pipeline-runs/:runId/terminate
256255
//
257256
// Terminates an active AutoML pipeline run. The run must be in an active state
258-
// (PENDING, RUNNING, PAUSED, or CANCELING) and belong to one of the discovered
257+
// (PENDING, RUNNING, or PAUSED) and belong to one of the discovered
259258
// AutoML pipelines (timeseries or tabular) in the namespace. The run transitions
260259
// to CANCELING and then CANCELED state.
261260
//
@@ -276,7 +275,7 @@ func (app *App) TerminatePipelineRunHandler(w http.ResponseWriter, r *http.Reque
276275
// Validate the run is in a terminatable state
277276
runState := strings.ToUpper(run.State)
278277
if !terminatableStates[runState] {
279-
app.badRequestResponse(w, r, fmt.Errorf("run %s is in state %s and cannot be terminated; only PENDING, RUNNING, PAUSED, or CANCELING runs can be terminated", run.RunID, runState))
278+
app.badRequestResponse(w, r, fmt.Errorf("run %s is in state %s and cannot be terminated; only PENDING, RUNNING, or PAUSED runs can be terminated", run.RunID, runState))
280279
return
281280
}
282281

packages/automl/docs/pipeline-runs-api.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ Returns `200 OK` with the created pipeline run (same `PipelineRun` structure as
579579
POST /api/v1/pipeline-runs/{runId}/terminate
580580
```
581581

582-
Sends an asynchronous request to cancel an active pipeline run. The run must be in an active state (PENDING, RUNNING, PAUSED, or CANCELING) and belong to one of the discovered AutoML pipelines (timeseries or tabular) in the namespace. The API requests a transition to CANCELING and attempts to cancel running tasks, which may result in a CANCELED final state if successful. However, the final state is not guaranteed — races or failures during cancellation may cause the run to end in a different terminal state.
582+
Sends an asynchronous request to cancel an active pipeline run. The run must be in an active state (PENDING, RUNNING, or PAUSED) and belong to one of the discovered AutoML pipelines (timeseries or tabular) in the namespace. The API requests a transition to CANCELING and attempts to cancel running tasks, which may result in a CANCELED final state if successful. However, the final state is not guaranteed — races or failures during cancellation may cause the run to end in a different terminal state.
583583

584584
### Parameters
585585

@@ -593,8 +593,8 @@ Sends an asynchronous request to cancel an active pipeline run. The run must be
593593
This endpoint enforces ownership and state validation:
594594

595595
- Fetches the run and validates it belongs to one of the discovered AutoML pipelines before terminating
596-
- Validates the run is in an active state (PENDING, RUNNING, PAUSED, or CANCELING) before proceeding
597-
- Returns `400 Bad Request` if the run is not in a terminatable state
596+
- Validates the run is in a terminatable state (PENDING, RUNNING, or PAUSED) before proceeding
597+
- Returns `400 Bad Request` if the run is not in a terminatable state (PENDING, RUNNING, or PAUSED)
598598
- Returns `404 Not Found` if the run does not exist or belongs to a different pipeline
599599
- Prevents users from terminating runs from other pipelines in the same namespace
600600

@@ -613,7 +613,7 @@ Returns `200 OK` with an empty body on success.
613613

614614
| Status | Condition |
615615
|--------|-----------|
616-
| `400 Bad Request` | Missing `runId` parameter, or run is not in an active state (PENDING, RUNNING, PAUSED, or CANCELING) |
616+
| `400 Bad Request` | Missing `runId` parameter, or run is not in a terminatable state (PENDING, RUNNING, or PAUSED) |
617617
| `401 Unauthorized` | Missing or invalid authentication |
618618
| `403 Forbidden` | User lacks permission to access pipeline servers in the namespace |
619619
| `404 Not Found` | Run not found, or run belongs to a different pipeline |

packages/automl/frontend/src/app/components/AutomlRunsTable/AutomlRunsTable.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type AutomlRunsTableProps = {
1414
namespace: string;
1515
onPageChange: (page: number) => void;
1616
onPerPageChange: (pageSize: number) => void;
17+
onRunActionComplete?: () => void | Promise<void>;
1718
toolbarContent?: React.ReactElement<typeof ToolbarItem | typeof ToolbarGroup>;
1819
};
1920

@@ -25,6 +26,7 @@ const AutomlRunsTable: React.FC<AutomlRunsTableProps> = ({
2526
namespace,
2627
onPageChange,
2728
onPerPageChange,
29+
onRunActionComplete,
2830
toolbarContent,
2931
}) => (
3032
<TableBase
@@ -35,7 +37,14 @@ const AutomlRunsTable: React.FC<AutomlRunsTableProps> = ({
3537
columns={automlRunsColumns}
3638
emptyTableView={<DashboardEmptyTableView onClearFilters={() => undefined} />}
3739
toolbarContent={toolbarContent}
38-
rowRenderer={(run) => <AutomlRunsTableRow key={run.run_id} run={run} namespace={namespace} />}
40+
rowRenderer={(run) => (
41+
<AutomlRunsTableRow
42+
key={run.run_id}
43+
run={run}
44+
namespace={namespace}
45+
onActionComplete={onRunActionComplete}
46+
/>
47+
)}
3948
itemCount={totalSize}
4049
page={page}
4150
perPage={pageSize}

packages/automl/frontend/src/app/components/AutomlRunsTable/AutomlRunsTableRow.tsx

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as React from 'react';
22
import { Label, type LabelProps } from '@patternfly/react-core';
3-
import { Td, Tr } from '@patternfly/react-table';
3+
import { ActionsColumn, Td, Tr } from '@patternfly/react-table';
44
import { Link } from 'react-router-dom';
55
import RunStartTimestamp from '@odh-dashboard/internal/concepts/pipelines/content/tables/RunStartTimestamp';
66
import type { PipelineRun } from '~/app/types';
7+
import StopRunModal from '~/app/components/run-results/StopRunModal';
8+
import { useAutomlRunActions } from '~/app/hooks/useAutomlRunActions';
79
import { TASK_TYPE_LABELS } from '~/app/utilities/const';
810
import { automlResultsPathname } from '~/app/utilities/routes';
9-
import { getTaskType } from '~/app/utilities/utils';
11+
import { getTaskType, isRunTerminatable, isRunRetryable } from '~/app/utilities/utils';
1012
import { automlRunsColumns } from './columns';
1113

1214
/** Run state values (API / display). Use lowercase for case-insensitive matching. */
@@ -24,6 +26,7 @@ export const RUN_STATE = {
2426
type AutomlRunsTableRowProps = {
2527
run: PipelineRun;
2628
namespace: string;
29+
onActionComplete?: () => void | Promise<void>;
2730
};
2831

2932
export const getStatusLabelProps = (
@@ -51,35 +54,84 @@ export const getStatusLabelProps = (
5154
return { color: 'grey' };
5255
};
5356

54-
const AutomlRunsTableRow: React.FC<AutomlRunsTableRowProps> = ({ run, namespace }) => {
57+
const AutomlRunsTableRow: React.FC<AutomlRunsTableRowProps> = ({
58+
run,
59+
namespace,
60+
onActionComplete,
61+
}) => {
5562
const taskType = getTaskType(run);
5663
const predictionTypeLabel = taskType ? (TASK_TYPE_LABELS[taskType] ?? taskType) : '—';
64+
const [isStopModalOpen, setIsStopModalOpen] = React.useState(false);
65+
const { handleRetry, handleConfirmStop, isRetrying, isTerminating } = useAutomlRunActions(
66+
namespace,
67+
run.run_id,
68+
onActionComplete,
69+
);
70+
71+
const runTerminatable = isRunTerminatable(run.state);
72+
const runRetryable = isRunRetryable(run.state);
73+
74+
const handleStop = React.useCallback(async () => {
75+
await handleConfirmStop();
76+
setIsStopModalOpen(false);
77+
}, [handleConfirmStop]);
78+
79+
const actions = React.useMemo(() => {
80+
const items: React.ComponentProps<typeof ActionsColumn>['items'] = [];
81+
82+
if (runTerminatable) {
83+
items.push({
84+
title: <span data-testid="stop-run-action">Stop</span>,
85+
onClick: () => setIsStopModalOpen(true),
86+
});
87+
}
88+
89+
if (runRetryable) {
90+
items.push({
91+
title: <span data-testid="retry-run-action">Retry</span>,
92+
onClick: () => void handleRetry(),
93+
isDisabled: isRetrying,
94+
});
95+
}
96+
97+
return items;
98+
}, [runTerminatable, runRetryable, handleRetry, isRetrying]);
5799

58100
return (
59-
<Tr>
60-
<Td dataLabel={automlRunsColumns[0].label}>
61-
<Link
62-
to={`${automlResultsPathname}/${namespace}/${run.run_id}`}
63-
data-testid={`run-name-${run.run_id}`}
64-
>
65-
{run.display_name}
66-
</Link>
67-
</Td>
68-
<Td dataLabel={automlRunsColumns[1].label}>{run.description ?? '—'}</Td>
69-
<Td dataLabel={automlRunsColumns[2].label}>{predictionTypeLabel}</Td>
70-
<Td dataLabel={automlRunsColumns[3].label}>
71-
<RunStartTimestamp run={run} />
72-
</Td>
73-
<Td dataLabel={automlRunsColumns[4].label}>
74-
{run.state ? (
75-
<Label variant="outline" isCompact {...getStatusLabelProps(run.state)}>
76-
{run.state}
77-
</Label>
78-
) : (
79-
'—'
80-
)}
81-
</Td>
82-
</Tr>
101+
<>
102+
<Tr>
103+
<Td dataLabel={automlRunsColumns[0].label}>
104+
<Link
105+
to={`${automlResultsPathname}/${namespace}/${run.run_id}`}
106+
data-testid={`run-name-${run.run_id}`}
107+
>
108+
{run.display_name}
109+
</Link>
110+
</Td>
111+
<Td dataLabel={automlRunsColumns[1].label}>{run.description ?? '—'}</Td>
112+
<Td dataLabel={automlRunsColumns[2].label}>{predictionTypeLabel}</Td>
113+
<Td dataLabel={automlRunsColumns[3].label}>
114+
<RunStartTimestamp run={run} />
115+
</Td>
116+
<Td dataLabel={automlRunsColumns[4].label}>
117+
{run.state ? (
118+
<Label variant="outline" isCompact {...getStatusLabelProps(run.state)}>
119+
{run.state}
120+
</Label>
121+
) : (
122+
'—'
123+
)}
124+
</Td>
125+
<Td isActionCell>{actions.length > 0 ? <ActionsColumn items={actions} /> : null}</Td>
126+
</Tr>
127+
<StopRunModal
128+
isOpen={isStopModalOpen}
129+
onClose={() => setIsStopModalOpen(false)}
130+
onConfirm={handleStop}
131+
isTerminating={isTerminating}
132+
runName={run.display_name}
133+
/>
134+
</>
83135
);
84136
};
85137

packages/automl/frontend/src/app/components/AutomlRunsTable/__tests__/AutomlRunsTable.spec.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ jest.mock('@odh-dashboard/internal/concepts/dashboard/DashboardEmptyTableView',
3636
default: () => <div data-testid="empty-view">Empty</div>,
3737
}));
3838

39+
jest.mock('~/app/hooks/useAutomlRunActions', () => ({
40+
useAutomlRunActions: () => ({
41+
handleRetry: jest.fn(),
42+
handleConfirmStop: jest.fn(),
43+
isRetrying: false,
44+
isTerminating: false,
45+
}),
46+
}));
47+
3948
const mockRuns: PipelineRun[] = [
4049
{
4150
run_id: 'r1',

packages/automl/frontend/src/app/components/AutomlRunsTable/__tests__/AutomlRunsTableRow.spec.tsx

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
/* eslint-disable camelcase -- PipelineRun type uses snake_case */
22
import '@testing-library/jest-dom';
33
import React from 'react';
4-
import { render, screen } from '@testing-library/react';
4+
import { render, screen, within } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
56
import { MemoryRouter } from 'react-router-dom';
67
import type { PipelineRun } from '~/app/types';
78
import AutomlRunsTableRow, {
89
getStatusLabelProps,
910
} from '~/app/components/AutomlRunsTable/AutomlRunsTableRow';
1011

12+
const mockHandleRetry = jest.fn();
13+
const mockHandleConfirmStop = jest.fn();
14+
15+
jest.mock('~/app/hooks/useAutomlRunActions', () => ({
16+
useAutomlRunActions: () => ({
17+
handleRetry: mockHandleRetry,
18+
handleConfirmStop: mockHandleConfirmStop,
19+
isRetrying: false,
20+
isTerminating: false,
21+
}),
22+
}));
23+
1124
describe('getStatusLabelProps', () => {
1225
it('should return success status for SUCCEEDED', () => {
1326
expect(getStatusLabelProps('SUCCEEDED')).toEqual({ status: 'success' });
@@ -218,4 +231,92 @@ describe('AutomlRunsTableRow', () => {
218231
);
219232
expect(container).toBeInTheDocument();
220233
});
234+
235+
describe('kebab action menu', () => {
236+
beforeEach(() => {
237+
jest.clearAllMocks();
238+
});
239+
240+
it('should not show kebab menu for succeeded runs', () => {
241+
render(
242+
<MemoryRouter>
243+
<AutomlRunsTableRow run={{ ...mockRun, state: 'SUCCEEDED' }} namespace={mockNamespace} />
244+
</MemoryRouter>,
245+
);
246+
expect(screen.queryByRole('button', { name: 'Kebab toggle' })).not.toBeInTheDocument();
247+
});
248+
249+
it('should show stop action for running runs', async () => {
250+
render(
251+
<MemoryRouter>
252+
<AutomlRunsTableRow run={{ ...mockRun, state: 'RUNNING' }} namespace={mockNamespace} />
253+
</MemoryRouter>,
254+
);
255+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
256+
await userEvent.click(kebab);
257+
expect(screen.getByTestId('stop-run-action')).toBeInTheDocument();
258+
});
259+
260+
it('should show retry action for failed runs', async () => {
261+
render(
262+
<MemoryRouter>
263+
<AutomlRunsTableRow run={{ ...mockRun, state: 'FAILED' }} namespace={mockNamespace} />
264+
</MemoryRouter>,
265+
);
266+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
267+
await userEvent.click(kebab);
268+
expect(screen.getByTestId('retry-run-action')).toBeInTheDocument();
269+
});
270+
271+
it('should show retry action for canceled runs', async () => {
272+
render(
273+
<MemoryRouter>
274+
<AutomlRunsTableRow run={{ ...mockRun, state: 'CANCELED' }} namespace={mockNamespace} />
275+
</MemoryRouter>,
276+
);
277+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
278+
await userEvent.click(kebab);
279+
expect(screen.getByTestId('retry-run-action')).toBeInTheDocument();
280+
});
281+
282+
it('should open stop modal when stop action is clicked', async () => {
283+
render(
284+
<MemoryRouter>
285+
<AutomlRunsTableRow run={{ ...mockRun, state: 'RUNNING' }} namespace={mockNamespace} />
286+
</MemoryRouter>,
287+
);
288+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
289+
await userEvent.click(kebab);
290+
await userEvent.click(screen.getByTestId('stop-run-action'));
291+
expect(screen.getByTestId('stop-run-modal')).toBeInTheDocument();
292+
});
293+
294+
it('should show run name in stop modal', async () => {
295+
render(
296+
<MemoryRouter>
297+
<AutomlRunsTableRow
298+
run={{ ...mockRun, state: 'RUNNING', display_name: 'My Run' }}
299+
namespace={mockNamespace}
300+
/>
301+
</MemoryRouter>,
302+
);
303+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
304+
await userEvent.click(kebab);
305+
await userEvent.click(screen.getByTestId('stop-run-action'));
306+
const modal = screen.getByTestId('stop-run-modal');
307+
expect(within(modal).getByText(/My Run/)).toBeInTheDocument();
308+
});
309+
310+
it('should call handleRetry when retry action is clicked', async () => {
311+
render(
312+
<MemoryRouter>
313+
<AutomlRunsTableRow run={{ ...mockRun, state: 'FAILED' }} namespace={mockNamespace} />
314+
</MemoryRouter>,
315+
);
316+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
317+
await userEvent.click(kebab);
318+
await userEvent.click(screen.getByTestId('retry-run-action'));
319+
expect(mockHandleRetry).toHaveBeenCalledTimes(1);
320+
});
321+
});
221322
});

packages/automl/frontend/src/app/components/AutomlRunsTable/columns.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export const automlRunsColumns: SortableData<PipelineRun>[] = [
1111
{ label: 'Prediction type', field: 'task_type', sortable: false, width: 15 },
1212
{ label: 'Started', field: 'created_at', sortable: false, width: 15 },
1313
{ label: 'Status', field: 'state', sortable: false, width: 10 },
14+
{ label: '', field: 'actions', sortable: false },
1415
];

packages/automl/frontend/src/app/components/experiments/AutomlExperiments.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function AutomlExperiments({ onExperimentsListStatus }: AutomlExperimentsProps):
5353
setPageSize,
5454
loaded: runsLoaded,
5555
error: runsError,
56+
refresh: refreshRuns,
5657
} = usePipelineRuns(effectiveNamespace);
5758

5859
const loaded = defsLoaded && runsLoaded;
@@ -158,6 +159,7 @@ function AutomlExperiments({ onExperimentsListStatus }: AutomlExperimentsProps):
158159
pageSize={pageSize}
159160
onPageChange={setPage}
160161
onPerPageChange={setPageSize}
162+
onRunActionComplete={refreshRuns}
161163
/>
162164
);
163165
}

0 commit comments

Comments
 (0)