Skip to content

Commit bfb33b7

Browse files
chrjones-rhclaude
andcommitted
feat(automl,autorag): add archive confirmation modal, tests, and docs
Add ArchiveRunModal with type-to-confirm pattern, link to Pipelines archived runs view, and comprehensive test coverage including unit tests, contract tests, OpenAPI specs, and API documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 859f968 commit bfb33b7

18 files changed

Lines changed: 838 additions & 22 deletions

File tree

packages/automl/api/openapi/automl.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,48 @@ paths:
979979
is in a retryable state (FAILED or CANCELED) before retrying it. This prevents users
980980
from retrying runs from other pipelines in the same namespace.
981981
982+
/api/v1/pipeline-runs/{runId}/archive:
983+
summary: Archive a completed pipeline run
984+
description: >-
985+
Archives a pipeline run that is in a terminal state (SUCCEEDED, FAILED, or CANCELED).
986+
The run must belong to one of the discovered AutoML pipelines in the namespace.
987+
Archiving changes the run's storage_state from AVAILABLE to ARCHIVED, hiding it
988+
from default list views. Archived runs can be restored from the Pipelines archived runs view.
989+
post:
990+
tags:
991+
- PipelineOperation
992+
security:
993+
- Bearer: []
994+
parameters:
995+
- $ref: "#/components/parameters/namespace"
996+
- name: runId
997+
in: path
998+
required: true
999+
schema:
1000+
type: string
1001+
description: Unique identifier of the pipeline run to archive
1002+
example: "abc123-def456-ghi789"
1003+
responses:
1004+
"200":
1005+
description: Run archived successfully
1006+
"400":
1007+
$ref: "#/components/responses/BadRequest"
1008+
"401":
1009+
$ref: "#/components/responses/Unauthorized"
1010+
"404":
1011+
$ref: "#/components/responses/NotFound"
1012+
"500":
1013+
$ref: "#/components/responses/InternalServerError"
1014+
"503":
1015+
$ref: "#/components/responses/ServiceUnavailable"
1016+
operationId: archivePipelineRun
1017+
summary: Archive Pipeline Run
1018+
description: >-
1019+
Archives a completed, failed, or canceled AutoML pipeline run. The BFF validates that
1020+
the run belongs to one of the discovered AutoML managed pipelines in the namespace and
1021+
that it is in an archivable state (SUCCEEDED, FAILED, or CANCELED) before archiving it.
1022+
This prevents users from archiving runs from other pipelines in the same namespace.
1023+
9821024
components:
9831025
schemas:
9841026
Config:

packages/automl/contract-tests/__tests__/testAutomlContract.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,5 +1244,47 @@ describe('AutoML API Contract Tests', () => {
12441244
}
12451245
});
12461246
});
1247+
1248+
describe('Archive Pipeline Run', () => {
1249+
it('should archive a succeeded pipeline run', async () => {
1250+
const result = await apiClient.post(
1251+
'/api/v1/pipeline-runs/run-abc123-def456/archive?namespace=test-namespace',
1252+
);
1253+
expect(result.success).toBe(true);
1254+
if (result.success) {
1255+
expect(result.response.status).toBe(200);
1256+
}
1257+
});
1258+
1259+
it('should archive a failed pipeline run', async () => {
1260+
const result = await apiClient.post(
1261+
'/api/v1/pipeline-runs/run-mno345-pqr678/archive?namespace=test-namespace',
1262+
);
1263+
expect(result.success).toBe(true);
1264+
if (result.success) {
1265+
expect(result.response.status).toBe(200);
1266+
}
1267+
});
1268+
1269+
it('should return 400 when attempting to archive an active (RUNNING) run', async () => {
1270+
const result = await apiClient.post(
1271+
'/api/v1/pipeline-runs/run-ghi789-jkl012/archive?namespace=test-namespace',
1272+
);
1273+
expect(result.success).toBe(false);
1274+
if (!result.success) {
1275+
expect(result.error.status).toBe(400);
1276+
}
1277+
});
1278+
1279+
it('should return 404 for non-existent run ID', async () => {
1280+
const result = await apiClient.post(
1281+
'/api/v1/pipeline-runs/non-existent-run-id/archive?namespace=test-namespace',
1282+
);
1283+
expect(result.success).toBe(false);
1284+
if (!result.success) {
1285+
expect(result.error.status).toBe(404);
1286+
}
1287+
});
1288+
});
12471289
});
12481290
});

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,55 @@ Returns `200 OK` with an empty body on success.
669669
| `500 Internal Server Error` | Pipeline Server error or internal error |
670670
| `503 Service Unavailable` | Pipeline Server exists but is not ready |
671671

672+
## Archive Pipeline Run
673+
674+
### Endpoint
675+
676+
```http
677+
POST /api/v1/pipeline-runs/{runId}/archive
678+
```
679+
680+
Archives a pipeline run that is in a terminal state (SUCCEEDED, FAILED, or CANCELED). The run must belong to one of the discovered AutoML pipelines (timeseries or tabular) in the namespace. Archiving changes the run's `storage_state` from `AVAILABLE` to `ARCHIVED`, hiding it from the default list view. Archived runs can be restored from the Pipelines archived runs view.
681+
682+
### Parameters
683+
684+
| Parameter | Type | Required | Description |
685+
|-----------|------|----------|-------------|
686+
| `namespace` | query string | Yes | Kubernetes namespace where the Pipeline Server is deployed |
687+
| `runId` | path parameter | Yes | Unique identifier of the pipeline run to archive |
688+
689+
### Security & Filtering
690+
691+
This endpoint enforces the same ownership validation as the Terminate Run endpoint:
692+
693+
- Fetches the run and validates it belongs to one of the discovered AutoML pipelines before archiving
694+
- Validates the run is in SUCCEEDED, FAILED, or CANCELED state before archiving
695+
- Returns `404 Not Found` if the run does not exist or belongs to a different pipeline
696+
- Returns `400 Bad Request` if the run is not in an archivable state
697+
- Prevents users from archiving runs from other pipelines in the same namespace
698+
699+
### Request Example
700+
701+
```bash
702+
curl -X POST "http://localhost:4003/api/v1/pipeline-runs/abc123-def456-ghi789/archive?namespace=my-namespace" \
703+
-H "Authorization: Bearer <your-token>"
704+
```
705+
706+
### Response Format
707+
708+
Returns `200 OK` with an empty body on success.
709+
710+
### Error Responses
711+
712+
| Status | Condition |
713+
|--------|-----------|
714+
| `400 Bad Request` | Missing `runId` parameter, or run is not in SUCCEEDED, FAILED, or CANCELED state |
715+
| `401 Unauthorized` | Missing or invalid authentication |
716+
| `403 Forbidden` | User lacks permission to access pipeline servers in the namespace |
717+
| `404 Not Found` | Run not found, or run belongs to a different pipeline |
718+
| `500 Internal Server Error` | Pipeline Server error or internal error |
719+
| `503 Service Unavailable` | Pipeline Server exists but is not ready |
720+
672721
## Pipeline Discovery
673722

674723
The API automatically discovers all managed AutoML pipelines (time-series and tabular) in the namespace and returns a merged view of their runs:

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

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ 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 ArchiveRunModal from '~/app/components/run-results/ArchiveRunModal';
78
import StopRunModal from '~/app/components/run-results/StopRunModal';
89
import { useAutomlRunActions } from '~/app/hooks/useAutomlRunActions';
910
import { TASK_TYPE_LABELS } from '~/app/utilities/const';
@@ -67,6 +68,7 @@ const AutomlRunsTableRow: React.FC<AutomlRunsTableRowProps> = ({
6768
const taskType = getTaskType(run);
6869
const predictionTypeLabel = taskType ? (TASK_TYPE_LABELS[taskType] ?? taskType) : '—';
6970
const [isStopModalOpen, setIsStopModalOpen] = React.useState(false);
71+
const [isArchiveModalOpen, setIsArchiveModalOpen] = React.useState(false);
7072
const { handleRetry, handleConfirmStop, handleArchive, isRetrying, isTerminating, isArchiving } =
7173
useAutomlRunActions(namespace, run.run_id, onActionComplete);
7274

@@ -79,6 +81,11 @@ const AutomlRunsTableRow: React.FC<AutomlRunsTableRowProps> = ({
7981
setIsStopModalOpen(false);
8082
}, [handleConfirmStop]);
8183

84+
const handleConfirmArchive = React.useCallback(async () => {
85+
await handleArchive();
86+
setIsArchiveModalOpen(false);
87+
}, [handleArchive]);
88+
8289
const actions = React.useMemo(() => {
8390
const items: React.ComponentProps<typeof ActionsColumn>['items'] = [];
8491

@@ -103,21 +110,13 @@ const AutomlRunsTableRow: React.FC<AutomlRunsTableRowProps> = ({
103110
}
104111
items.push({
105112
title: <span data-testid="archive-run-action">Archive</span>,
106-
onClick: () => void handleArchive(),
113+
onClick: () => setIsArchiveModalOpen(true),
107114
isDisabled: isArchiving,
108115
});
109116
}
110117

111118
return items;
112-
}, [
113-
runTerminatable,
114-
runRetryable,
115-
runArchivable,
116-
handleRetry,
117-
handleArchive,
118-
isRetrying,
119-
isArchiving,
120-
]);
119+
}, [runTerminatable, runRetryable, runArchivable, handleRetry, isRetrying, isArchiving]);
121120

122121
return (
123122
<>
@@ -153,6 +152,14 @@ const AutomlRunsTableRow: React.FC<AutomlRunsTableRowProps> = ({
153152
isTerminating={isTerminating}
154153
runName={run.display_name}
155154
/>
155+
<ArchiveRunModal
156+
isOpen={isArchiveModalOpen}
157+
onClose={() => setIsArchiveModalOpen(false)}
158+
onConfirm={handleConfirmArchive}
159+
isArchiving={isArchiving}
160+
runName={run.display_name}
161+
namespace={namespace}
162+
/>
156163
</>
157164
);
158165
};

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,23 @@ describe('AutomlRunsTableRow', () => {
237237
jest.clearAllMocks();
238238
});
239239

240-
it('should not show kebab menu for succeeded runs', () => {
240+
it('should show archive action for succeeded runs', async () => {
241241
render(
242242
<MemoryRouter>
243243
<AutomlRunsTableRow run={{ ...mockRun, state: 'SUCCEEDED' }} namespace={mockNamespace} />
244244
</MemoryRouter>,
245245
);
246+
const kebab = screen.getByRole('button', { name: 'Kebab toggle' });
247+
await userEvent.click(kebab);
248+
expect(screen.getByTestId('archive-run-action')).toBeInTheDocument();
249+
});
250+
251+
it('should not show kebab menu for canceling runs', () => {
252+
render(
253+
<MemoryRouter>
254+
<AutomlRunsTableRow run={{ ...mockRun, state: 'CANCELING' }} namespace={mockNamespace} />
255+
</MemoryRouter>,
256+
);
246257
expect(screen.queryByRole('button', { name: 'Kebab toggle' })).not.toBeInTheDocument();
247258
});
248259

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { Link } from 'react-router-dom';
3+
import {
4+
Button,
5+
Flex,
6+
FlexItem,
7+
Modal,
8+
ModalBody,
9+
ModalFooter,
10+
ModalHeader,
11+
Stack,
12+
StackItem,
13+
TextInput,
14+
} from '@patternfly/react-core';
15+
16+
type ArchiveRunModalProps = {
17+
isOpen: boolean;
18+
onClose: () => void;
19+
onConfirm: () => void;
20+
isArchiving: boolean;
21+
runName?: string;
22+
namespace: string;
23+
};
24+
25+
const ArchiveRunModal: React.FC<ArchiveRunModalProps> = ({
26+
isOpen,
27+
onClose,
28+
onConfirm,
29+
isArchiving,
30+
runName,
31+
namespace,
32+
}) => {
33+
const [confirmInputValue, setConfirmInputValue] = React.useState('');
34+
const confirmMessage = runName ?? '';
35+
const isDisabled = confirmInputValue.trim() !== confirmMessage || isArchiving;
36+
37+
const handleClose = React.useCallback(() => {
38+
setConfirmInputValue('');
39+
onClose();
40+
}, [onClose]);
41+
42+
return (
43+
<Modal variant="small" isOpen={isOpen} onClose={handleClose} data-testid="archive-run-modal">
44+
<ModalHeader title="Archive AutoML optimization run?" titleIconVariant="warning" />
45+
<ModalBody>
46+
<Stack hasGutter>
47+
<StackItem>
48+
The run will be archived. It can be restored from the Pipelines{' '}
49+
<Link to={`/develop-train/pipelines/runs/${namespace}/runs/archived`}>
50+
archived runs
51+
</Link>{' '}
52+
view.
53+
</StackItem>
54+
<StackItem>
55+
<Flex direction={{ default: 'column' }} spaceItems={{ default: 'spaceItemsSm' }}>
56+
<FlexItem>
57+
Type <strong>{confirmMessage}</strong> to confirm archiving:
58+
</FlexItem>
59+
<TextInput
60+
id="confirm-archive-input"
61+
data-testid="confirm-archive-input"
62+
aria-label="confirm archive input"
63+
value={confirmInputValue}
64+
onChange={(_e, newValue) => setConfirmInputValue(newValue)}
65+
onKeyDown={(event) => {
66+
if (event.key === 'Enter' && !isDisabled) {
67+
onConfirm();
68+
}
69+
}}
70+
/>
71+
</Flex>
72+
</StackItem>
73+
</Stack>
74+
</ModalBody>
75+
<ModalFooter>
76+
<Button
77+
variant="primary"
78+
onClick={onConfirm}
79+
isDisabled={isDisabled}
80+
isLoading={isArchiving}
81+
spinnerAriaValueText="Archiving run"
82+
data-testid="confirm-archive-run-button"
83+
>
84+
Archive
85+
</Button>
86+
<Button variant="link" onClick={handleClose} isDisabled={isArchiving}>
87+
Cancel
88+
</Button>
89+
</ModalFooter>
90+
</Modal>
91+
);
92+
};
93+
94+
export default ArchiveRunModal;

0 commit comments

Comments
 (0)