From 8f58dbb72dfdd72efb69b7d23b495c5973907ed0 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 01:33:38 +0530 Subject: [PATCH 01/13] Add delete workflow run functionality Add ability to delete workflow runs across CI and generic workflows plugins. Includes DELETE endpoints in backend routers, service methods for API integration, client methods in frontend APIs, and delete action buttons in the RunsTab and WorkflowRunsContent components with confirmation dialogs. --- plugins/openchoreo-ci-backend/src/router.ts | 21 ++++++++++ .../src/services/WorkflowService.ts | 38 ++++++++++++++++++ .../src/api/OpenChoreoCiClient.ts | 12 ++++++ .../src/api/OpenChoreoCiClientApi.ts | 8 ++++ .../src/components/RunsTab/RunsTab.tsx | 32 +++++++++++++++ .../src/router.ts | 19 +++++++++ .../src/services/GenericWorkflowService.ts | 39 +++++++++++++++++++ .../src/api/GenericWorkflowsClient.ts | 13 +++++++ .../src/api/GenericWorkflowsClientApi.ts | 3 ++ .../WorkflowRunsContent.tsx | 25 ++++++++++++ 10 files changed, 210 insertions(+) diff --git a/plugins/openchoreo-ci-backend/src/router.ts b/plugins/openchoreo-ci-backend/src/router.ts index 0b4377ae5..07a481864 100644 --- a/plugins/openchoreo-ci-backend/src/router.ts +++ b/plugins/openchoreo-ci-backend/src/router.ts @@ -71,6 +71,27 @@ export async function createRouter({ ); }); + router.delete('/workflow-run', requireAuth, async (req, res) => { + const { componentName, projectName, namespaceName, runName } = req.query; + + if (!componentName || !projectName || !namespaceName || !runName) { + throw new InputError( + 'componentName, projectName, namespaceName and runName are required query parameters', + ); + } + + const userToken = getUserTokenFromRequest(req); + + await workflowService.deleteWorkflowRun( + namespaceName as string, + projectName as string, + componentName as string, + runName as string, + userToken, + ); + res.status(204).end(); + }); + router.get('/workflow-run', async (req, res) => { const { componentName, projectName, namespaceName, runName } = req.query; diff --git a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts index 8d7328688..4ebb03173 100644 --- a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts +++ b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts @@ -766,6 +766,44 @@ export class WorkflowService { } } + async deleteWorkflowRun( + namespaceName: string, + projectName: string, + componentName: string, + runName: string, + token?: string, + ): Promise { + this.logger.info( + `Deleting workflow run: ${runName} for component: ${componentName} in project: ${projectName}, namespace: ${namespaceName}`, + ); + + try { + const client = createOpenChoreoApiClient({ + baseUrl: this.baseUrl, + token, + logger: this.logger, + }); + + const { data, error, response } = await client.DELETE( + '/api/v1/namespaces/{namespaceName}/workflowruns/{runName}', + { + params: { + path: { namespaceName, runName }, + }, + }, + ); + + assertApiResponse({ data, error, response }, 'delete workflow run'); + + this.logger.debug(`Successfully deleted workflow run: ${runName}`); + } catch (error) { + this.logger.error( + `Failed to delete workflow run ${runName} in namespace ${namespaceName}: ${error}`, + ); + throw error; + } + } + /** * Fetch JSONSchema for a specific component workflow */ diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts index 0534f1f11..ccdfd09b6 100644 --- a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts @@ -237,4 +237,16 @@ export class OpenChoreoCiClient implements OpenChoreoCiClientApi { const entries = (await response.json()) as WorkflowRunEventEntry[]; return entries; } + + async deleteWorkflowRun( + namespaceName: string, + projectName: string, + componentName: string, + runName: string, + ): Promise { + return this.apiFetch('/workflow-run', { + method: 'DELETE', + params: { namespaceName, projectName, componentName, runName }, + }); + } } diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts index d245bba39..9bf7b18de 100644 --- a/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts @@ -107,6 +107,14 @@ export interface OpenChoreoCiClientApi { step: string, hasLiveObservability: boolean, ): Promise; + + /** Delete a specific workflow run */ + deleteWorkflowRun( + namespaceName: string, + projectName: string, + componentName: string, + runName: string, + ): Promise; } // ============================================ diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx index 62f32db10..c8d4c03da 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx @@ -2,7 +2,10 @@ import { Table, TableColumn } from '@backstage/core-components'; import { Typography, Box, IconButton, Tooltip } from '@material-ui/core'; import Refresh from '@material-ui/icons/Refresh'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import DeleteIcon from '@material-ui/icons/Delete'; +import { useApi } from '@backstage/core-plugin-api'; import { BuildStatusChip } from '../BuildStatusChip'; +import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; import { formatRelativeTime } from '@openchoreo/backstage-plugin-react'; import { extractGitFieldValues } from '../../utils/schemaExtensions'; @@ -30,6 +33,7 @@ export const RunsTab = ({ retentionTtl, }: RunsTabProps) => { const classes = useStyles(); + const client = useApi(openChoreoCiClientApiRef); const columns: TableColumn[] = [ { @@ -122,6 +126,34 @@ export const RunsTab = ({ sorting: true, }} columns={columns} + actions={[ + { + icon: () => , + tooltip: 'Delete Run', + onClick: async (_event, rowData) => { + const run = rowData as ModelsBuild; + if ( + // eslint-disable-next-line no-alert + window.confirm( + `Are you sure you want to delete workflow run "${run.name}"?`, + ) + ) { + try { + await client.deleteWorkflowRun( + run.namespaceName!, + run.projectName!, + run.componentName!, + run.name!, + ); + onRefresh(); + } catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Failed to delete run: ${err}`); + } + } + }, + }, + ]} data={sortedBuilds} onRowClick={(_, rowData) => { onRowClick(rowData as ModelsBuild); diff --git a/plugins/openchoreo-workflows-backend/src/router.ts b/plugins/openchoreo-workflows-backend/src/router.ts index 17334a5f4..2eacb6d7f 100644 --- a/plugins/openchoreo-workflows-backend/src/router.ts +++ b/plugins/openchoreo-workflows-backend/src/router.ts @@ -127,6 +127,25 @@ export async function createRouter({ ); }); + // DELETE /workflow-runs/:runName - Delete a workflow run + router.delete('/workflow-runs/:runName', requireAuth, async (req, res) => { + const { runName } = req.params; + const { namespaceName } = req.query; + + if (!namespaceName) { + throw new InputError('namespaceName is required query parameter'); + } + + const userToken = getUserTokenFromRequest(req); + + await workflowService.deleteWorkflowRun( + namespaceName as string, + runName, + userToken, + ); + res.status(204).end(); + }); + // GET /workflow-runs/:runName/logs - Get workflow run logs router.get('/workflow-runs/:runName/logs', async (req, res) => { const { runName } = req.params; diff --git a/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts b/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts index 09d16ebb8..2acd6dfb0 100644 --- a/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts +++ b/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts @@ -722,4 +722,43 @@ export class GenericWorkflowService { throw error; } } + + /** + * Delete a specific workflow run + */ + async deleteWorkflowRun( + namespaceName: string, + runName: string, + token?: string, + ): Promise { + this.logger.info( + `Deleting workflow run: ${runName} in namespace: ${namespaceName}`, + ); + + try { + const client = createOpenChoreoApiClient({ + baseUrl: this.baseUrl, + token, + logger: this.logger, + }); + + const { data, error, response } = await client.DELETE( + '/api/v1/namespaces/{namespaceName}/workflowruns/{runName}', + { + params: { + path: { namespaceName, runName }, + }, + }, + ); + + assertApiResponse({ data, error, response }, 'delete workflow run'); + + this.logger.debug(`Successfully deleted workflow run: ${runName}`); + } catch (error) { + this.logger.error( + `Failed to delete workflow run ${runName} in namespace ${namespaceName}: ${error}`, + ); + throw error; + } + } } diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts index 2c1a1fb8c..d41f843d4 100644 --- a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts @@ -166,4 +166,17 @@ export class GenericWorkflowsClient implements GenericWorkflowsClientApi { { params }, ); } + + async deleteWorkflowRun( + namespaceName: string, + runName: string, + ): Promise { + return this.apiFetch( + `/workflow-runs/${encodeURIComponent(runName)}`, + { + method: 'DELETE', + params: { namespaceName }, + }, + ); + } } diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts index 86cacac40..d8d3b77c2 100644 --- a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts @@ -71,6 +71,9 @@ export interface GenericWorkflowsClientApi { runName: string, task?: string, ): Promise; + + /** Delete a specific workflow run */ + deleteWorkflowRun(namespaceName: string, runName: string): Promise; } /** diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx index 0a17814be..4476360ff 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx @@ -32,6 +32,7 @@ import { makeStyles } from '@material-ui/core/styles'; import RefreshIcon from '@material-ui/icons/Refresh'; import PlayArrowIcon from '@material-ui/icons/PlayArrow'; import CloseIcon from '@material-ui/icons/Close'; +import DeleteIcon from '@material-ui/icons/Delete'; import DescriptionOutlinedIcon from '@material-ui/icons/DescriptionOutlined'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import EventNoteOutlinedIcon from '@material-ui/icons/EventNoteOutlined'; @@ -728,6 +729,7 @@ export const WorkflowRunsContent = () => { const { entity } = useEntity(); const [searchParams, setSearchParams] = useSearchParams(); const [showTriggerForm, setShowTriggerForm] = useState(false); + const client = useApi(genericWorkflowsClientApiRef); const workflowName = entity.metadata.name; const workflowKind: 'Workflow' | 'ClusterWorkflow' = @@ -915,6 +917,29 @@ export const WorkflowRunsContent = () => { , + tooltip: 'Delete Run', + onClick: async (_event, rowData) => { + const run = rowData as WorkflowRun; + if ( + // eslint-disable-next-line no-alert + window.confirm( + `Are you sure you want to delete workflow run "${run.name}"?`, + ) + ) { + try { + await client.deleteWorkflowRun(runsNamespace, run.name); + refetch(); + } catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Failed to delete run: ${err}`); + } + } + }, + }, + ]} options={{ search: true, paging: true, From 21fa0edd95b8548774e60a3c584facf2365023e9 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 01:53:06 +0530 Subject: [PATCH 02/13] Mock CI client API in RunsTab tests Wrap RunsTab component with TestApiProvider to properly inject mocked openChoreoCiClientApiRef. This ensures the component has access to the required API when testing, including the mocked deleteWorkflowRun method. --- .../src/components/RunsTab/RunsTab.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx index 724e9a002..cbe3d6a2e 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { RunsTab } from './RunsTab'; import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; // ---- Mocks ---- @@ -78,6 +80,10 @@ const builds: ModelsBuild[] = [ }, ]; +const mockCiClient = { + deleteWorkflowRun: jest.fn(), +}; + function renderTab( overrides: Partial> = {}, ) { @@ -90,7 +96,11 @@ function renderTab( }; return { - ...render(), + ...render( + + + + ), props: { ...defaultProps, ...overrides }, }; } From 57ca827c0ae57ac5da277937a7df33cb95ac1dc0 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 13:35:25 +0530 Subject: [PATCH 03/13] Handle workflow run deletion responses Allow delete requests to accept 204 No Content responses in both OpenChoreo clients, and guard the Runs tab delete action against missing run identifiers before calling the API. The backend now verifies the run exists before deleting it. --- .../src/services/WorkflowService.ts | 8 +++++ .../src/api/OpenChoreoCiClient.ts | 4 +++ .../src/components/RunsTab/RunsTab.test.tsx | 35 ++++++++++++++++++- .../src/components/RunsTab/RunsTab.tsx | 16 ++++++--- .../src/api/GenericWorkflowsClient.ts | 4 +++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts index 4ebb03173..799903b7a 100644 --- a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts +++ b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts @@ -778,6 +778,14 @@ export class WorkflowService { ); try { + await this.getWorkflowRun( + namespaceName, + projectName, + componentName, + runName, + token, + ); + const client = createOpenChoreoApiClient({ baseUrl: this.baseUrl, token, diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts index ccdfd09b6..e3ec59a86 100644 --- a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts @@ -92,6 +92,10 @@ export class OpenChoreoCiClient implements OpenChoreoCiClientApi { throw new Error(`API request failed (${response.status}): ${errorText}`); } + if (response.status === 204) { + return undefined as unknown as T; + } + return response.json(); } diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx index cbe3d6a2e..ce1aaafa1 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -26,7 +26,7 @@ jest.mock('../../hooks', () => ({ })); jest.mock('@backstage/core-components', () => ({ - Table: ({ title, data, columns, emptyContent, onRowClick }: any) => ( + Table: ({ title, data, columns, emptyContent, onRowClick, actions }: any) => (
{title}
{data.length === 0 ? ( @@ -47,6 +47,19 @@ jest.mock('@backstage/core-components', () => ({ ) : null, )} + {actions?.map((action: any, k: number) => ( + + ))}
))} @@ -190,4 +203,24 @@ describe('RunsTab', () => { expect(onRefresh).toHaveBeenCalled(); }); + it('calls deleteWorkflowRun and onRefresh when delete action is confirmed', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + const confirmSpy = jest.spyOn(window, 'confirm').mockImplementation(() => true); + + renderTab({ onRefresh }); + + await user.click(screen.getByTestId('action-Delete Run-0')); + + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete workflow run "build-2"?'); + expect(mockCiClient.deleteWorkflowRun).toHaveBeenCalledWith( + 'dev-ns', + 'my-project', + 'api-service', + 'build-2' + ); + expect(onRefresh).toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); }); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx index c8d4c03da..8918c504d 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx @@ -138,12 +138,20 @@ export const RunsTab = ({ `Are you sure you want to delete workflow run "${run.name}"?`, ) ) { + if ( + !run.namespaceName || + !run.projectName || + !run.componentName || + !run.name + ) { + return; + } try { await client.deleteWorkflowRun( - run.namespaceName!, - run.projectName!, - run.componentName!, - run.name!, + run.namespaceName, + run.projectName, + run.componentName, + run.name, ); onRefresh(); } catch (err) { diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts index d41f843d4..4a5f0a1cf 100644 --- a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts @@ -52,6 +52,10 @@ export class GenericWorkflowsClient implements GenericWorkflowsClientApi { throw new Error(`API request failed (${response.status}): ${errorText}`); } + if (response.status === 204) { + return undefined as unknown as T; + } + return response.json(); } From fabc1098625cf0776c8c17f0ecdcad6e602f0ea5 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 13:59:48 +0530 Subject: [PATCH 04/13] chore: add empty changeset --- .changeset/calm-zebras-wave.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/calm-zebras-wave.md diff --git a/.changeset/calm-zebras-wave.md b/.changeset/calm-zebras-wave.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-zebras-wave.md @@ -0,0 +1,2 @@ +--- +--- From 462ef2daa256c5d3ce6c599924ca109feff53ee5 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 01:33:38 +0530 Subject: [PATCH 05/13] Add delete workflow run functionality Add ability to delete workflow runs across CI and generic workflows plugins. Includes DELETE endpoints in backend routers, service methods for API integration, client methods in frontend APIs, and delete action buttons in the RunsTab and WorkflowRunsContent components with confirmation dialogs. Signed-off-by: Eshwanth Karti T R --- plugins/openchoreo-ci-backend/src/router.ts | 21 ++++++++++ .../src/services/WorkflowService.ts | 38 ++++++++++++++++++ .../src/api/OpenChoreoCiClient.ts | 12 ++++++ .../src/api/OpenChoreoCiClientApi.ts | 8 ++++ .../src/components/RunsTab/RunsTab.tsx | 32 +++++++++++++++ .../src/router.ts | 19 +++++++++ .../src/services/GenericWorkflowService.ts | 39 +++++++++++++++++++ .../src/api/GenericWorkflowsClient.ts | 13 +++++++ .../src/api/GenericWorkflowsClientApi.ts | 3 ++ .../WorkflowRunsContent.tsx | 25 ++++++++++++ 10 files changed, 210 insertions(+) diff --git a/plugins/openchoreo-ci-backend/src/router.ts b/plugins/openchoreo-ci-backend/src/router.ts index 0b4377ae5..07a481864 100644 --- a/plugins/openchoreo-ci-backend/src/router.ts +++ b/plugins/openchoreo-ci-backend/src/router.ts @@ -71,6 +71,27 @@ export async function createRouter({ ); }); + router.delete('/workflow-run', requireAuth, async (req, res) => { + const { componentName, projectName, namespaceName, runName } = req.query; + + if (!componentName || !projectName || !namespaceName || !runName) { + throw new InputError( + 'componentName, projectName, namespaceName and runName are required query parameters', + ); + } + + const userToken = getUserTokenFromRequest(req); + + await workflowService.deleteWorkflowRun( + namespaceName as string, + projectName as string, + componentName as string, + runName as string, + userToken, + ); + res.status(204).end(); + }); + router.get('/workflow-run', async (req, res) => { const { componentName, projectName, namespaceName, runName } = req.query; diff --git a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts index 8d7328688..4ebb03173 100644 --- a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts +++ b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts @@ -766,6 +766,44 @@ export class WorkflowService { } } + async deleteWorkflowRun( + namespaceName: string, + projectName: string, + componentName: string, + runName: string, + token?: string, + ): Promise { + this.logger.info( + `Deleting workflow run: ${runName} for component: ${componentName} in project: ${projectName}, namespace: ${namespaceName}`, + ); + + try { + const client = createOpenChoreoApiClient({ + baseUrl: this.baseUrl, + token, + logger: this.logger, + }); + + const { data, error, response } = await client.DELETE( + '/api/v1/namespaces/{namespaceName}/workflowruns/{runName}', + { + params: { + path: { namespaceName, runName }, + }, + }, + ); + + assertApiResponse({ data, error, response }, 'delete workflow run'); + + this.logger.debug(`Successfully deleted workflow run: ${runName}`); + } catch (error) { + this.logger.error( + `Failed to delete workflow run ${runName} in namespace ${namespaceName}: ${error}`, + ); + throw error; + } + } + /** * Fetch JSONSchema for a specific component workflow */ diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts index 0534f1f11..ccdfd09b6 100644 --- a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts @@ -237,4 +237,16 @@ export class OpenChoreoCiClient implements OpenChoreoCiClientApi { const entries = (await response.json()) as WorkflowRunEventEntry[]; return entries; } + + async deleteWorkflowRun( + namespaceName: string, + projectName: string, + componentName: string, + runName: string, + ): Promise { + return this.apiFetch('/workflow-run', { + method: 'DELETE', + params: { namespaceName, projectName, componentName, runName }, + }); + } } diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts index d245bba39..9bf7b18de 100644 --- a/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClientApi.ts @@ -107,6 +107,14 @@ export interface OpenChoreoCiClientApi { step: string, hasLiveObservability: boolean, ): Promise; + + /** Delete a specific workflow run */ + deleteWorkflowRun( + namespaceName: string, + projectName: string, + componentName: string, + runName: string, + ): Promise; } // ============================================ diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx index 62f32db10..c8d4c03da 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx @@ -2,7 +2,10 @@ import { Table, TableColumn } from '@backstage/core-components'; import { Typography, Box, IconButton, Tooltip } from '@material-ui/core'; import Refresh from '@material-ui/icons/Refresh'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import DeleteIcon from '@material-ui/icons/Delete'; +import { useApi } from '@backstage/core-plugin-api'; import { BuildStatusChip } from '../BuildStatusChip'; +import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; import { formatRelativeTime } from '@openchoreo/backstage-plugin-react'; import { extractGitFieldValues } from '../../utils/schemaExtensions'; @@ -30,6 +33,7 @@ export const RunsTab = ({ retentionTtl, }: RunsTabProps) => { const classes = useStyles(); + const client = useApi(openChoreoCiClientApiRef); const columns: TableColumn[] = [ { @@ -122,6 +126,34 @@ export const RunsTab = ({ sorting: true, }} columns={columns} + actions={[ + { + icon: () => , + tooltip: 'Delete Run', + onClick: async (_event, rowData) => { + const run = rowData as ModelsBuild; + if ( + // eslint-disable-next-line no-alert + window.confirm( + `Are you sure you want to delete workflow run "${run.name}"?`, + ) + ) { + try { + await client.deleteWorkflowRun( + run.namespaceName!, + run.projectName!, + run.componentName!, + run.name!, + ); + onRefresh(); + } catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Failed to delete run: ${err}`); + } + } + }, + }, + ]} data={sortedBuilds} onRowClick={(_, rowData) => { onRowClick(rowData as ModelsBuild); diff --git a/plugins/openchoreo-workflows-backend/src/router.ts b/plugins/openchoreo-workflows-backend/src/router.ts index 17334a5f4..2eacb6d7f 100644 --- a/plugins/openchoreo-workflows-backend/src/router.ts +++ b/plugins/openchoreo-workflows-backend/src/router.ts @@ -127,6 +127,25 @@ export async function createRouter({ ); }); + // DELETE /workflow-runs/:runName - Delete a workflow run + router.delete('/workflow-runs/:runName', requireAuth, async (req, res) => { + const { runName } = req.params; + const { namespaceName } = req.query; + + if (!namespaceName) { + throw new InputError('namespaceName is required query parameter'); + } + + const userToken = getUserTokenFromRequest(req); + + await workflowService.deleteWorkflowRun( + namespaceName as string, + runName, + userToken, + ); + res.status(204).end(); + }); + // GET /workflow-runs/:runName/logs - Get workflow run logs router.get('/workflow-runs/:runName/logs', async (req, res) => { const { runName } = req.params; diff --git a/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts b/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts index 09d16ebb8..2acd6dfb0 100644 --- a/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts +++ b/plugins/openchoreo-workflows-backend/src/services/GenericWorkflowService.ts @@ -722,4 +722,43 @@ export class GenericWorkflowService { throw error; } } + + /** + * Delete a specific workflow run + */ + async deleteWorkflowRun( + namespaceName: string, + runName: string, + token?: string, + ): Promise { + this.logger.info( + `Deleting workflow run: ${runName} in namespace: ${namespaceName}`, + ); + + try { + const client = createOpenChoreoApiClient({ + baseUrl: this.baseUrl, + token, + logger: this.logger, + }); + + const { data, error, response } = await client.DELETE( + '/api/v1/namespaces/{namespaceName}/workflowruns/{runName}', + { + params: { + path: { namespaceName, runName }, + }, + }, + ); + + assertApiResponse({ data, error, response }, 'delete workflow run'); + + this.logger.debug(`Successfully deleted workflow run: ${runName}`); + } catch (error) { + this.logger.error( + `Failed to delete workflow run ${runName} in namespace ${namespaceName}: ${error}`, + ); + throw error; + } + } } diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts index 2c1a1fb8c..d41f843d4 100644 --- a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts @@ -166,4 +166,17 @@ export class GenericWorkflowsClient implements GenericWorkflowsClientApi { { params }, ); } + + async deleteWorkflowRun( + namespaceName: string, + runName: string, + ): Promise { + return this.apiFetch( + `/workflow-runs/${encodeURIComponent(runName)}`, + { + method: 'DELETE', + params: { namespaceName }, + }, + ); + } } diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts index 86cacac40..d8d3b77c2 100644 --- a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClientApi.ts @@ -71,6 +71,9 @@ export interface GenericWorkflowsClientApi { runName: string, task?: string, ): Promise; + + /** Delete a specific workflow run */ + deleteWorkflowRun(namespaceName: string, runName: string): Promise; } /** diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx index 0a17814be..4476360ff 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx @@ -32,6 +32,7 @@ import { makeStyles } from '@material-ui/core/styles'; import RefreshIcon from '@material-ui/icons/Refresh'; import PlayArrowIcon from '@material-ui/icons/PlayArrow'; import CloseIcon from '@material-ui/icons/Close'; +import DeleteIcon from '@material-ui/icons/Delete'; import DescriptionOutlinedIcon from '@material-ui/icons/DescriptionOutlined'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import EventNoteOutlinedIcon from '@material-ui/icons/EventNoteOutlined'; @@ -728,6 +729,7 @@ export const WorkflowRunsContent = () => { const { entity } = useEntity(); const [searchParams, setSearchParams] = useSearchParams(); const [showTriggerForm, setShowTriggerForm] = useState(false); + const client = useApi(genericWorkflowsClientApiRef); const workflowName = entity.metadata.name; const workflowKind: 'Workflow' | 'ClusterWorkflow' = @@ -915,6 +917,29 @@ export const WorkflowRunsContent = () => {
, + tooltip: 'Delete Run', + onClick: async (_event, rowData) => { + const run = rowData as WorkflowRun; + if ( + // eslint-disable-next-line no-alert + window.confirm( + `Are you sure you want to delete workflow run "${run.name}"?`, + ) + ) { + try { + await client.deleteWorkflowRun(runsNamespace, run.name); + refetch(); + } catch (err) { + // eslint-disable-next-line no-alert + window.alert(`Failed to delete run: ${err}`); + } + } + }, + }, + ]} options={{ search: true, paging: true, From df29c518b6bf335dca88f38447920f854001fc08 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 01:53:06 +0530 Subject: [PATCH 06/13] Mock CI client API in RunsTab tests Wrap RunsTab component with TestApiProvider to properly inject mocked openChoreoCiClientApiRef. This ensures the component has access to the required API when testing, including the mocked deleteWorkflowRun method. Signed-off-by: Eshwanth Karti T R --- .../src/components/RunsTab/RunsTab.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx index 724e9a002..cbe3d6a2e 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { RunsTab } from './RunsTab'; import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; // ---- Mocks ---- @@ -78,6 +80,10 @@ const builds: ModelsBuild[] = [ }, ]; +const mockCiClient = { + deleteWorkflowRun: jest.fn(), +}; + function renderTab( overrides: Partial> = {}, ) { @@ -90,7 +96,11 @@ function renderTab( }; return { - ...render(), + ...render( + + + + ), props: { ...defaultProps, ...overrides }, }; } From 314836e1e8d54f7cc511baff7b46294baa0d9de9 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 13:35:25 +0530 Subject: [PATCH 07/13] Handle workflow run deletion responses Allow delete requests to accept 204 No Content responses in both OpenChoreo clients, and guard the Runs tab delete action against missing run identifiers before calling the API. The backend now verifies the run exists before deleting it. Signed-off-by: Eshwanth Karti T R --- .../src/services/WorkflowService.ts | 8 +++++ .../src/api/OpenChoreoCiClient.ts | 4 +++ .../src/components/RunsTab/RunsTab.test.tsx | 35 ++++++++++++++++++- .../src/components/RunsTab/RunsTab.tsx | 16 ++++++--- .../src/api/GenericWorkflowsClient.ts | 4 +++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts index 4ebb03173..799903b7a 100644 --- a/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts +++ b/plugins/openchoreo-ci-backend/src/services/WorkflowService.ts @@ -778,6 +778,14 @@ export class WorkflowService { ); try { + await this.getWorkflowRun( + namespaceName, + projectName, + componentName, + runName, + token, + ); + const client = createOpenChoreoApiClient({ baseUrl: this.baseUrl, token, diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts index ccdfd09b6..e3ec59a86 100644 --- a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.ts @@ -92,6 +92,10 @@ export class OpenChoreoCiClient implements OpenChoreoCiClientApi { throw new Error(`API request failed (${response.status}): ${errorText}`); } + if (response.status === 204) { + return undefined as unknown as T; + } + return response.json(); } diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx index cbe3d6a2e..ce1aaafa1 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -26,7 +26,7 @@ jest.mock('../../hooks', () => ({ })); jest.mock('@backstage/core-components', () => ({ - Table: ({ title, data, columns, emptyContent, onRowClick }: any) => ( + Table: ({ title, data, columns, emptyContent, onRowClick, actions }: any) => (
{title}
{data.length === 0 ? ( @@ -47,6 +47,19 @@ jest.mock('@backstage/core-components', () => ({ ) : null, )} + {actions?.map((action: any, k: number) => ( + + ))}
))} @@ -190,4 +203,24 @@ describe('RunsTab', () => { expect(onRefresh).toHaveBeenCalled(); }); + it('calls deleteWorkflowRun and onRefresh when delete action is confirmed', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + const confirmSpy = jest.spyOn(window, 'confirm').mockImplementation(() => true); + + renderTab({ onRefresh }); + + await user.click(screen.getByTestId('action-Delete Run-0')); + + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete workflow run "build-2"?'); + expect(mockCiClient.deleteWorkflowRun).toHaveBeenCalledWith( + 'dev-ns', + 'my-project', + 'api-service', + 'build-2' + ); + expect(onRefresh).toHaveBeenCalled(); + + confirmSpy.mockRestore(); + }); }); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx index c8d4c03da..8918c504d 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx @@ -138,12 +138,20 @@ export const RunsTab = ({ `Are you sure you want to delete workflow run "${run.name}"?`, ) ) { + if ( + !run.namespaceName || + !run.projectName || + !run.componentName || + !run.name + ) { + return; + } try { await client.deleteWorkflowRun( - run.namespaceName!, - run.projectName!, - run.componentName!, - run.name!, + run.namespaceName, + run.projectName, + run.componentName, + run.name, ); onRefresh(); } catch (err) { diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts index d41f843d4..4a5f0a1cf 100644 --- a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.ts @@ -52,6 +52,10 @@ export class GenericWorkflowsClient implements GenericWorkflowsClientApi { throw new Error(`API request failed (${response.status}): ${errorText}`); } + if (response.status === 204) { + return undefined as unknown as T; + } + return response.json(); } From 194b1d41c016dd9dc644c3b408fcacb490c3b6fe Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 13:59:48 +0530 Subject: [PATCH 08/13] chore: add empty changeset Signed-off-by: Eshwanth Karti T R --- .changeset/calm-zebras-wave.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/calm-zebras-wave.md diff --git a/.changeset/calm-zebras-wave.md b/.changeset/calm-zebras-wave.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-zebras-wave.md @@ -0,0 +1,2 @@ +--- +--- From e4f4dbec8431a7879b072a0fab0f2feae389126f Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 16:20:55 +0530 Subject: [PATCH 09/13] DCO Remediation Commit for Eshwanth Karti T R I, Eshwanth Karti T R , hereby add my Signed-off-by to this commit: 8f58dbb72dfdd72efb69b7d23b495c5973907ed0 I, Eshwanth Karti T R , hereby add my Signed-off-by to this commit: 21fa0edd95b8548774e60a3c584facf2365023e9 I, Eshwanth Karti T R , hereby add my Signed-off-by to this commit: 57ca827c0ae57ac5da277937a7df33cb95ac1dc0 I, Eshwanth Karti T R , hereby add my Signed-off-by to this commit: fabc1098625cf0776c8c17f0ecdcad6e602f0ea5 Signed-off-by: Eshwanth Karti T R --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 66b8a7667..d22375a9a 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,4 @@ site e2e-test-report/ -.backstage-db/ \ No newline at end of file +.backstage-db/ From 35cc289072f41c5052c26bce9b493046c2b2be38 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Sun, 12 Jul 2026 16:23:54 +0530 Subject: [PATCH 10/13] chore: add changeset metadata Signed-off-by: Eshwanth Karti T R --- .changeset/calm-zebras-wave.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.changeset/calm-zebras-wave.md b/.changeset/calm-zebras-wave.md index a845151cc..403eff61e 100644 --- a/.changeset/calm-zebras-wave.md +++ b/.changeset/calm-zebras-wave.md @@ -1,2 +1,7 @@ --- +'@openchoreo/backstage-plugin-openchoreo-ci': patch +'@openchoreo/backstage-plugin-openchoreo-ci-backend': patch +'@openchoreo/backstage-plugin-openchoreo-workflows': patch --- + +Fix workflow run deletion to properly validate ownership and handle empty 204 responses. From 983d032cebf057cb55cab958ea716f38a2f85e80 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Mon, 13 Jul 2026 15:42:46 +0530 Subject: [PATCH 11/13] Replace run delete alerts with dialogs Switched workflow run deletion in both openchoreo-ci and openchoreo-workflows from browser confirm/alert flows to Material-UI dialogs plus Backstage alertApi error reporting. Added component tests to cover confirm/delete success and failure paths, and new API client tests to verify 204 No Content returns undefined while 200 responses still parse JSON. --- .../src/api/OpenChoreoCiClient.test.ts | 50 ++++++ .../src/components/RunsTab/RunsTab.test.tsx | 39 +++- .../src/components/RunsTab/RunsTab.tsx | 111 ++++++++---- .../src/api/GenericWorkflowsClient.test.ts | 48 +++++ .../WorkflowRunsContent.test.tsx | 167 ++++++++++++++++++ .../WorkflowRunsContent.tsx | 75 ++++++-- 6 files changed, 440 insertions(+), 50 deletions(-) create mode 100644 plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts create mode 100644 plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts create mode 100644 plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts new file mode 100644 index 000000000..94bfdf60e --- /dev/null +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts @@ -0,0 +1,50 @@ +import { OpenChoreoCiClient } from './OpenChoreoCiClient'; + +describe('OpenChoreoCiClient', () => { + let client: OpenChoreoCiClient; + let mockFetchApi: any; + let mockDiscoveryApi: any; + + beforeEach(() => { + mockFetchApi = { + fetch: jest.fn(), + }; + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/openchoreo'), + }; + + client = new OpenChoreoCiClient(mockDiscoveryApi, mockFetchApi); + }); + + describe('apiFetch', () => { + it('should return undefined for 204 No Content', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 204, + json: jest.fn().mockImplementation(() => Promise.reject(new Error('Should not be called'))), + }); + + // We access the private apiFetch method indirectly through a public method + // that calls it, like deleteWorkflowRun + const result = await client.deleteWorkflowRun('dev-ns', 'my-project', 'api-service', 'build-2'); + + expect(result).toBeUndefined(); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/openchoreo/workflow-run?namespaceName=dev-ns&projectName=my-project&componentName=api-service&runName=build-2', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('should parse json for 200 OK', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ name: 'build-2' }), + }); + + const result = await client.fetchWorkflowSchema('dev-ns', 'my-workflow'); + + expect(result).toEqual({ name: 'build-2' }); + }); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx index ce1aaafa1..143de05b3 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -97,6 +97,12 @@ const mockCiClient = { deleteWorkflowRun: jest.fn(), }; +import { alertApiRef } from '@backstage/core-plugin-api'; + +const mockAlertApi = { + post: jest.fn(), +}; + function renderTab( overrides: Partial> = {}, ) { @@ -110,7 +116,12 @@ function renderTab( return { ...render( - + ), @@ -206,13 +217,18 @@ describe('RunsTab', () => { it('calls deleteWorkflowRun and onRefresh when delete action is confirmed', async () => { const user = userEvent.setup(); const onRefresh = jest.fn(); - const confirmSpy = jest.spyOn(window, 'confirm').mockImplementation(() => true); + mockCiClient.deleteWorkflowRun.mockResolvedValue(undefined); renderTab({ onRefresh }); + // Open dialog await user.click(screen.getByTestId('action-Delete Run-0')); - expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete workflow run "build-2"?'); + expect(screen.getByText('Are you sure you want to delete workflow run "build-2"?')).toBeInTheDocument(); + + // Click confirm in the dialog + await user.click(screen.getByRole('button', { name: 'Delete' })); + expect(mockCiClient.deleteWorkflowRun).toHaveBeenCalledWith( 'dev-ns', 'my-project', @@ -220,7 +236,22 @@ describe('RunsTab', () => { 'build-2' ); expect(onRefresh).toHaveBeenCalled(); + }); + + it('shows error alert when delete action fails', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + mockCiClient.deleteWorkflowRun.mockRejectedValue(new Error('Network error')); + + renderTab({ onRefresh }); + + await user.click(screen.getByTestId('action-Delete Run-0')); + await user.click(screen.getByRole('button', { name: 'Delete' })); - confirmSpy.mockRestore(); + expect(mockAlertApi.post).toHaveBeenCalledWith({ + message: 'Failed to delete run: Error: Network error', + severity: 'error', + }); + expect(onRefresh).not.toHaveBeenCalled(); }); }); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx index 8918c504d..921ca75ee 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx @@ -1,9 +1,21 @@ +import { useState } from 'react'; import { Table, TableColumn } from '@backstage/core-components'; -import { Typography, Box, IconButton, Tooltip } from '@material-ui/core'; +import { + Typography, + Box, + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, +} from '@material-ui/core'; import Refresh from '@material-ui/icons/Refresh'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import DeleteIcon from '@material-ui/icons/Delete'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, alertApiRef } from '@backstage/core-plugin-api'; import { BuildStatusChip } from '../BuildStatusChip'; import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; @@ -34,6 +46,43 @@ export const RunsTab = ({ }: RunsTabProps) => { const classes = useStyles(); const client = useApi(openChoreoCiClientApiRef); + const alertApi = useApi(alertApiRef); + + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + if ( + !deleteTarget.namespaceName || + !deleteTarget.projectName || + !deleteTarget.componentName || + !deleteTarget.name + ) { + setDeleteTarget(null); + return; + } + + setIsDeleting(true); + try { + await client.deleteWorkflowRun( + deleteTarget.namespaceName, + deleteTarget.projectName, + deleteTarget.componentName, + deleteTarget.name, + ); + onRefresh(); + } catch (err) { + alertApi.post({ + message: `Failed to delete run: ${err}`, + severity: 'error', + }); + } finally { + setIsDeleting(false); + setDeleteTarget(null); + } + }; const columns: TableColumn[] = [ { @@ -131,34 +180,7 @@ export const RunsTab = ({ icon: () => , tooltip: 'Delete Run', onClick: async (_event, rowData) => { - const run = rowData as ModelsBuild; - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Are you sure you want to delete workflow run "${run.name}"?`, - ) - ) { - if ( - !run.namespaceName || - !run.projectName || - !run.componentName || - !run.name - ) { - return; - } - try { - await client.deleteWorkflowRun( - run.namespaceName, - run.projectName, - run.componentName, - run.name, - ); - onRefresh(); - } catch (err) { - // eslint-disable-next-line no-alert - window.alert(`Failed to delete run: ${err}`); - } - } + setDeleteTarget(rowData as ModelsBuild); }, }, ]} @@ -194,6 +216,35 @@ export const RunsTab = ({ } /> + + setDeleteTarget(null)} + > + Delete Workflow Run + + + Are you sure you want to delete workflow run "{deleteTarget?.name}"? + + + + + + + ); }; + diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts new file mode 100644 index 000000000..3dd01f1c0 --- /dev/null +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts @@ -0,0 +1,48 @@ +import { GenericWorkflowsClient } from './GenericWorkflowsClient'; + +describe('GenericWorkflowsClient', () => { + let client: GenericWorkflowsClient; + let mockFetchApi: any; + let mockDiscoveryApi: any; + + beforeEach(() => { + mockFetchApi = { + fetch: jest.fn(), + }; + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/openchoreo'), + }; + + client = new GenericWorkflowsClient(mockDiscoveryApi, mockFetchApi); + }); + + describe('apiFetch', () => { + it('should return undefined for 204 No Content', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 204, + json: jest.fn().mockImplementation(() => Promise.reject(new Error('Should not be called'))), + }); + + const result = await client.deleteWorkflowRun('dev-ns', 'run-1'); + + expect(result).toBeUndefined(); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/openchoreo/workflow-runs/run-1?namespaceName=dev-ns', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('should parse json for 200 OK', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ name: 'run-1' }), + }); + + const result = await client.getWorkflowRun('dev-ns', 'run-1'); + + expect(result).toEqual({ name: 'run-1' }); + }); + }); +}); diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx new file mode 100644 index 000000000..f976fdfae --- /dev/null +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { WorkflowRunsContent } from './WorkflowRunsContent'; +import { TestApiProvider } from '@backstage/test-utils'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { genericWorkflowsClientApiRef } from '../../api'; + +// Mocks +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => ({ + entity: { + metadata: { name: 'test-workflow' }, + kind: 'Workflow', + }, + }), +})); + +const mockSearchParams = new URLSearchParams(); +const setSearchParams = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: () => [mockSearchParams, setSearchParams], +})); + +const mockRefetch = jest.fn(); +jest.mock('../../hooks/useWorkflowRuns', () => ({ + useWorkflowRuns: () => ({ + runs: [ + { name: 'run-1', phase: 'Succeeded', namespaceName: 'default' }, + { name: 'run-2', phase: 'Failed', namespaceName: 'default' }, + ], + loading: false, + error: null, + refetch: mockRefetch, + }), +})); + +jest.mock('../../hooks/useWorkflowRunDetails', () => ({ + useWorkflowRunDetails: () => ({ run: null, loading: false }), +})); + +jest.mock('../../hooks/useWorkflowSchema', () => ({ + useWorkflowSchema: () => ({ schema: null, loading: false }), +})); + +jest.mock('../../context', () => ({ + useSelectedNamespace: () => 'default', +})); + +jest.mock('../../hooks/useNamespaces', () => ({ + useNamespaces: () => ({ namespaces: ['default'], loading: false }), +})); + +jest.mock('../WorkflowRunStatusChip', () => ({ + WorkflowRunStatusChip: ({ status }: any) => Status: {status}, +})); + +jest.mock('@backstage/core-components', () => ({ + Table: ({ data, actions }: any) => ( +
+ {data.map((row: any, i: number) => ( +
+ {row.name} + {actions?.map((action: any, k: number) => ( + + ))} +
+ ))} +
+ ), + Progress: () =>
Progress
, + Content: ({ children }: any) =>
{children}
, + InfoCard: ({ children }: any) =>
{children}
, + StructuredMetadataTable: () =>
StructuredMetadataTable
, +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + formatRelativeTime: (time: string) => time, + formatDate: (time: string) => time, + DetailPageLayout: ({ children }: any) =>
{children}
, + YamlEditor: () =>
YamlEditor
, + useYamlEditor: () => ({}), +})); + +jest.mock('../WorkflowRunStepLogs', () => ({ + WorkflowRunStepLogs: () =>
WorkflowRunStepLogs
, +})); + +const mockWorkflowsClient = { + deleteWorkflowRun: jest.fn(), +}; + +const mockAlertApi = { + post: jest.fn(), +}; + +function renderComponent() { + return render( + + + + ); +} + +describe('WorkflowRunsContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a list of workflow runs', () => { + renderComponent(); + expect(screen.getByTestId('row-run-1')).toBeInTheDocument(); + expect(screen.getByTestId('row-run-2')).toBeInTheDocument(); + }); + + it('shows delete dialog and calls deleteWorkflowRun on confirm', async () => { + const user = userEvent.setup(); + mockWorkflowsClient.deleteWorkflowRun.mockResolvedValue(undefined); + + renderComponent(); + + // Click delete action on run-1 + await user.click(screen.getByTestId('action-Delete Run-0')); + + expect(screen.getByText('Are you sure you want to delete workflow run "run-1"?')).toBeInTheDocument(); + + // Confirm deletion + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(mockWorkflowsClient.deleteWorkflowRun).toHaveBeenCalledWith('default', 'run-1'); + expect(mockRefetch).toHaveBeenCalled(); + }); + + it('shows error alert if deletion fails', async () => { + const user = userEvent.setup(); + mockWorkflowsClient.deleteWorkflowRun.mockRejectedValue(new Error('Deletion failed')); + + renderComponent(); + + // Click delete action on run-2 + await user.click(screen.getByTestId('action-Delete Run-1')); + + expect(screen.getByText('Are you sure you want to delete workflow run "run-2"?')).toBeInTheDocument(); + + // Confirm deletion + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(mockWorkflowsClient.deleteWorkflowRun).toHaveBeenCalledWith('default', 'run-2'); + expect(mockAlertApi.post).toHaveBeenCalledWith({ + message: 'Failed to delete run: Error: Deletion failed', + severity: 'error', + }); + expect(mockRefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx index 4476360ff..b61e8c07d 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx @@ -8,7 +8,7 @@ import { InfoCard, StructuredMetadataTable, } from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, alertApiRef } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Alert, @@ -27,6 +27,11 @@ import { Paper, Button, Collapse, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import RefreshIcon from '@material-ui/icons/Refresh'; @@ -730,6 +735,12 @@ export const WorkflowRunsContent = () => { const [searchParams, setSearchParams] = useSearchParams(); const [showTriggerForm, setShowTriggerForm] = useState(false); const client = useApi(genericWorkflowsClientApiRef); + const alertApi = useApi(alertApiRef); + + const [deleteTargetRun, setDeleteTargetRun] = useState( + null, + ); + const [isDeleting, setIsDeleting] = useState(false); const workflowName = entity.metadata.name; const workflowKind: 'Workflow' | 'ClusterWorkflow' = @@ -772,6 +783,24 @@ export const WorkflowRunsContent = () => { refetch, } = useWorkflowRuns(workflowName, runsNamespace); + const handleDeleteConfirm = async () => { + if (!deleteTargetRun) return; + + setIsDeleting(true); + try { + await client.deleteWorkflowRun(runsNamespace, deleteTargetRun.name); + refetch(); + } catch (err) { + alertApi.post({ + message: `Failed to delete run: ${err}`, + severity: 'error', + }); + } finally { + setIsDeleting(false); + setDeleteTargetRun(null); + } + }; + const handleRunClick = (runName: string) => { setSearchParams({ run: runName }); }; @@ -922,21 +951,7 @@ export const WorkflowRunsContent = () => { icon: () => , tooltip: 'Delete Run', onClick: async (_event, rowData) => { - const run = rowData as WorkflowRun; - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Are you sure you want to delete workflow run "${run.name}"?`, - ) - ) { - try { - await client.deleteWorkflowRun(runsNamespace, run.name); - refetch(); - } catch (err) { - // eslint-disable-next-line no-alert - window.alert(`Failed to delete run: ${err}`); - } - } + setDeleteTargetRun(rowData as WorkflowRun); }, }, ]} @@ -953,6 +968,34 @@ export const WorkflowRunsContent = () => { }} /> )} + + setDeleteTargetRun(null)} + > + Delete Workflow Run + + + Are you sure you want to delete workflow run "{deleteTargetRun?.name}"? + + + + + + + ); }; From a8d61b469d7fe5b6b12215289f79b3ecc80bb384 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Mon, 13 Jul 2026 15:42:46 +0530 Subject: [PATCH 12/13] Replace run delete alerts with dialogs Switched workflow run deletion in both openchoreo-ci and openchoreo-workflows from browser confirm/alert flows to Material-UI dialogs plus Backstage alertApi error reporting. Added component tests to cover confirm/delete success and failure paths, and new API client tests to verify 204 No Content returns undefined while 200 responses still parse JSON. Signed-off-by: Eshwanth Karti T R --- .../src/api/OpenChoreoCiClient.test.ts | 50 ++++++ .../src/components/RunsTab/RunsTab.test.tsx | 39 +++- .../src/components/RunsTab/RunsTab.tsx | 111 ++++++++---- .../src/api/GenericWorkflowsClient.test.ts | 48 +++++ .../WorkflowRunsContent.test.tsx | 167 ++++++++++++++++++ .../WorkflowRunsContent.tsx | 75 ++++++-- 6 files changed, 440 insertions(+), 50 deletions(-) create mode 100644 plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts create mode 100644 plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts create mode 100644 plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx diff --git a/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts new file mode 100644 index 000000000..94bfdf60e --- /dev/null +++ b/plugins/openchoreo-ci/src/api/OpenChoreoCiClient.test.ts @@ -0,0 +1,50 @@ +import { OpenChoreoCiClient } from './OpenChoreoCiClient'; + +describe('OpenChoreoCiClient', () => { + let client: OpenChoreoCiClient; + let mockFetchApi: any; + let mockDiscoveryApi: any; + + beforeEach(() => { + mockFetchApi = { + fetch: jest.fn(), + }; + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/openchoreo'), + }; + + client = new OpenChoreoCiClient(mockDiscoveryApi, mockFetchApi); + }); + + describe('apiFetch', () => { + it('should return undefined for 204 No Content', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 204, + json: jest.fn().mockImplementation(() => Promise.reject(new Error('Should not be called'))), + }); + + // We access the private apiFetch method indirectly through a public method + // that calls it, like deleteWorkflowRun + const result = await client.deleteWorkflowRun('dev-ns', 'my-project', 'api-service', 'build-2'); + + expect(result).toBeUndefined(); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/openchoreo/workflow-run?namespaceName=dev-ns&projectName=my-project&componentName=api-service&runName=build-2', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('should parse json for 200 OK', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ name: 'build-2' }), + }); + + const result = await client.fetchWorkflowSchema('dev-ns', 'my-workflow'); + + expect(result).toEqual({ name: 'build-2' }); + }); + }); +}); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx index ce1aaafa1..143de05b3 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.test.tsx @@ -97,6 +97,12 @@ const mockCiClient = { deleteWorkflowRun: jest.fn(), }; +import { alertApiRef } from '@backstage/core-plugin-api'; + +const mockAlertApi = { + post: jest.fn(), +}; + function renderTab( overrides: Partial> = {}, ) { @@ -110,7 +116,12 @@ function renderTab( return { ...render( - + ), @@ -206,13 +217,18 @@ describe('RunsTab', () => { it('calls deleteWorkflowRun and onRefresh when delete action is confirmed', async () => { const user = userEvent.setup(); const onRefresh = jest.fn(); - const confirmSpy = jest.spyOn(window, 'confirm').mockImplementation(() => true); + mockCiClient.deleteWorkflowRun.mockResolvedValue(undefined); renderTab({ onRefresh }); + // Open dialog await user.click(screen.getByTestId('action-Delete Run-0')); - expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to delete workflow run "build-2"?'); + expect(screen.getByText('Are you sure you want to delete workflow run "build-2"?')).toBeInTheDocument(); + + // Click confirm in the dialog + await user.click(screen.getByRole('button', { name: 'Delete' })); + expect(mockCiClient.deleteWorkflowRun).toHaveBeenCalledWith( 'dev-ns', 'my-project', @@ -220,7 +236,22 @@ describe('RunsTab', () => { 'build-2' ); expect(onRefresh).toHaveBeenCalled(); + }); + + it('shows error alert when delete action fails', async () => { + const user = userEvent.setup(); + const onRefresh = jest.fn(); + mockCiClient.deleteWorkflowRun.mockRejectedValue(new Error('Network error')); + + renderTab({ onRefresh }); + + await user.click(screen.getByTestId('action-Delete Run-0')); + await user.click(screen.getByRole('button', { name: 'Delete' })); - confirmSpy.mockRestore(); + expect(mockAlertApi.post).toHaveBeenCalledWith({ + message: 'Failed to delete run: Error: Network error', + severity: 'error', + }); + expect(onRefresh).not.toHaveBeenCalled(); }); }); diff --git a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx index 8918c504d..921ca75ee 100644 --- a/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx +++ b/plugins/openchoreo-ci/src/components/RunsTab/RunsTab.tsx @@ -1,9 +1,21 @@ +import { useState } from 'react'; import { Table, TableColumn } from '@backstage/core-components'; -import { Typography, Box, IconButton, Tooltip } from '@material-ui/core'; +import { + Typography, + Box, + IconButton, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, +} from '@material-ui/core'; import Refresh from '@material-ui/icons/Refresh'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import DeleteIcon from '@material-ui/icons/Delete'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, alertApiRef } from '@backstage/core-plugin-api'; import { BuildStatusChip } from '../BuildStatusChip'; import { openChoreoCiClientApiRef } from '../../api/OpenChoreoCiClientApi'; import type { ModelsBuild } from '@openchoreo/backstage-plugin-common'; @@ -34,6 +46,43 @@ export const RunsTab = ({ }: RunsTabProps) => { const classes = useStyles(); const client = useApi(openChoreoCiClientApiRef); + const alertApi = useApi(alertApiRef); + + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + if ( + !deleteTarget.namespaceName || + !deleteTarget.projectName || + !deleteTarget.componentName || + !deleteTarget.name + ) { + setDeleteTarget(null); + return; + } + + setIsDeleting(true); + try { + await client.deleteWorkflowRun( + deleteTarget.namespaceName, + deleteTarget.projectName, + deleteTarget.componentName, + deleteTarget.name, + ); + onRefresh(); + } catch (err) { + alertApi.post({ + message: `Failed to delete run: ${err}`, + severity: 'error', + }); + } finally { + setIsDeleting(false); + setDeleteTarget(null); + } + }; const columns: TableColumn[] = [ { @@ -131,34 +180,7 @@ export const RunsTab = ({ icon: () => , tooltip: 'Delete Run', onClick: async (_event, rowData) => { - const run = rowData as ModelsBuild; - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Are you sure you want to delete workflow run "${run.name}"?`, - ) - ) { - if ( - !run.namespaceName || - !run.projectName || - !run.componentName || - !run.name - ) { - return; - } - try { - await client.deleteWorkflowRun( - run.namespaceName, - run.projectName, - run.componentName, - run.name, - ); - onRefresh(); - } catch (err) { - // eslint-disable-next-line no-alert - window.alert(`Failed to delete run: ${err}`); - } - } + setDeleteTarget(rowData as ModelsBuild); }, }, ]} @@ -194,6 +216,35 @@ export const RunsTab = ({ } /> + + setDeleteTarget(null)} + > + Delete Workflow Run + + + Are you sure you want to delete workflow run "{deleteTarget?.name}"? + + + + + + + ); }; + diff --git a/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts new file mode 100644 index 000000000..3dd01f1c0 --- /dev/null +++ b/plugins/openchoreo-workflows/src/api/GenericWorkflowsClient.test.ts @@ -0,0 +1,48 @@ +import { GenericWorkflowsClient } from './GenericWorkflowsClient'; + +describe('GenericWorkflowsClient', () => { + let client: GenericWorkflowsClient; + let mockFetchApi: any; + let mockDiscoveryApi: any; + + beforeEach(() => { + mockFetchApi = { + fetch: jest.fn(), + }; + mockDiscoveryApi = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/openchoreo'), + }; + + client = new GenericWorkflowsClient(mockDiscoveryApi, mockFetchApi); + }); + + describe('apiFetch', () => { + it('should return undefined for 204 No Content', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 204, + json: jest.fn().mockImplementation(() => Promise.reject(new Error('Should not be called'))), + }); + + const result = await client.deleteWorkflowRun('dev-ns', 'run-1'); + + expect(result).toBeUndefined(); + expect(mockFetchApi.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/openchoreo/workflow-runs/run-1?namespaceName=dev-ns', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('should parse json for 200 OK', async () => { + mockFetchApi.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ name: 'run-1' }), + }); + + const result = await client.getWorkflowRun('dev-ns', 'run-1'); + + expect(result).toEqual({ name: 'run-1' }); + }); + }); +}); diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx new file mode 100644 index 000000000..f976fdfae --- /dev/null +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.test.tsx @@ -0,0 +1,167 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { WorkflowRunsContent } from './WorkflowRunsContent'; +import { TestApiProvider } from '@backstage/test-utils'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { genericWorkflowsClientApiRef } from '../../api'; + +// Mocks +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => ({ + entity: { + metadata: { name: 'test-workflow' }, + kind: 'Workflow', + }, + }), +})); + +const mockSearchParams = new URLSearchParams(); +const setSearchParams = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: () => [mockSearchParams, setSearchParams], +})); + +const mockRefetch = jest.fn(); +jest.mock('../../hooks/useWorkflowRuns', () => ({ + useWorkflowRuns: () => ({ + runs: [ + { name: 'run-1', phase: 'Succeeded', namespaceName: 'default' }, + { name: 'run-2', phase: 'Failed', namespaceName: 'default' }, + ], + loading: false, + error: null, + refetch: mockRefetch, + }), +})); + +jest.mock('../../hooks/useWorkflowRunDetails', () => ({ + useWorkflowRunDetails: () => ({ run: null, loading: false }), +})); + +jest.mock('../../hooks/useWorkflowSchema', () => ({ + useWorkflowSchema: () => ({ schema: null, loading: false }), +})); + +jest.mock('../../context', () => ({ + useSelectedNamespace: () => 'default', +})); + +jest.mock('../../hooks/useNamespaces', () => ({ + useNamespaces: () => ({ namespaces: ['default'], loading: false }), +})); + +jest.mock('../WorkflowRunStatusChip', () => ({ + WorkflowRunStatusChip: ({ status }: any) => Status: {status}, +})); + +jest.mock('@backstage/core-components', () => ({ + Table: ({ data, actions }: any) => ( +
+ {data.map((row: any, i: number) => ( +
+ {row.name} + {actions?.map((action: any, k: number) => ( + + ))} +
+ ))} +
+ ), + Progress: () =>
Progress
, + Content: ({ children }: any) =>
{children}
, + InfoCard: ({ children }: any) =>
{children}
, + StructuredMetadataTable: () =>
StructuredMetadataTable
, +})); + +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + formatRelativeTime: (time: string) => time, + formatDate: (time: string) => time, + DetailPageLayout: ({ children }: any) =>
{children}
, + YamlEditor: () =>
YamlEditor
, + useYamlEditor: () => ({}), +})); + +jest.mock('../WorkflowRunStepLogs', () => ({ + WorkflowRunStepLogs: () =>
WorkflowRunStepLogs
, +})); + +const mockWorkflowsClient = { + deleteWorkflowRun: jest.fn(), +}; + +const mockAlertApi = { + post: jest.fn(), +}; + +function renderComponent() { + return render( + + + + ); +} + +describe('WorkflowRunsContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a list of workflow runs', () => { + renderComponent(); + expect(screen.getByTestId('row-run-1')).toBeInTheDocument(); + expect(screen.getByTestId('row-run-2')).toBeInTheDocument(); + }); + + it('shows delete dialog and calls deleteWorkflowRun on confirm', async () => { + const user = userEvent.setup(); + mockWorkflowsClient.deleteWorkflowRun.mockResolvedValue(undefined); + + renderComponent(); + + // Click delete action on run-1 + await user.click(screen.getByTestId('action-Delete Run-0')); + + expect(screen.getByText('Are you sure you want to delete workflow run "run-1"?')).toBeInTheDocument(); + + // Confirm deletion + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(mockWorkflowsClient.deleteWorkflowRun).toHaveBeenCalledWith('default', 'run-1'); + expect(mockRefetch).toHaveBeenCalled(); + }); + + it('shows error alert if deletion fails', async () => { + const user = userEvent.setup(); + mockWorkflowsClient.deleteWorkflowRun.mockRejectedValue(new Error('Deletion failed')); + + renderComponent(); + + // Click delete action on run-2 + await user.click(screen.getByTestId('action-Delete Run-1')); + + expect(screen.getByText('Are you sure you want to delete workflow run "run-2"?')).toBeInTheDocument(); + + // Confirm deletion + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(mockWorkflowsClient.deleteWorkflowRun).toHaveBeenCalledWith('default', 'run-2'); + expect(mockAlertApi.post).toHaveBeenCalledWith({ + message: 'Failed to delete run: Error: Deletion failed', + severity: 'error', + }); + expect(mockRefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx index 4476360ff..b61e8c07d 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx @@ -8,7 +8,7 @@ import { InfoCard, StructuredMetadataTable, } from '@backstage/core-components'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, alertApiRef } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Alert, @@ -27,6 +27,11 @@ import { Paper, Button, Collapse, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import RefreshIcon from '@material-ui/icons/Refresh'; @@ -730,6 +735,12 @@ export const WorkflowRunsContent = () => { const [searchParams, setSearchParams] = useSearchParams(); const [showTriggerForm, setShowTriggerForm] = useState(false); const client = useApi(genericWorkflowsClientApiRef); + const alertApi = useApi(alertApiRef); + + const [deleteTargetRun, setDeleteTargetRun] = useState( + null, + ); + const [isDeleting, setIsDeleting] = useState(false); const workflowName = entity.metadata.name; const workflowKind: 'Workflow' | 'ClusterWorkflow' = @@ -772,6 +783,24 @@ export const WorkflowRunsContent = () => { refetch, } = useWorkflowRuns(workflowName, runsNamespace); + const handleDeleteConfirm = async () => { + if (!deleteTargetRun) return; + + setIsDeleting(true); + try { + await client.deleteWorkflowRun(runsNamespace, deleteTargetRun.name); + refetch(); + } catch (err) { + alertApi.post({ + message: `Failed to delete run: ${err}`, + severity: 'error', + }); + } finally { + setIsDeleting(false); + setDeleteTargetRun(null); + } + }; + const handleRunClick = (runName: string) => { setSearchParams({ run: runName }); }; @@ -922,21 +951,7 @@ export const WorkflowRunsContent = () => { icon: () => , tooltip: 'Delete Run', onClick: async (_event, rowData) => { - const run = rowData as WorkflowRun; - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Are you sure you want to delete workflow run "${run.name}"?`, - ) - ) { - try { - await client.deleteWorkflowRun(runsNamespace, run.name); - refetch(); - } catch (err) { - // eslint-disable-next-line no-alert - window.alert(`Failed to delete run: ${err}`); - } - } + setDeleteTargetRun(rowData as WorkflowRun); }, }, ]} @@ -953,6 +968,34 @@ export const WorkflowRunsContent = () => { }} /> )} + + setDeleteTargetRun(null)} + > + Delete Workflow Run + + + Are you sure you want to delete workflow run "{deleteTargetRun?.name}"? + + + + + + + ); }; From 2cc03f4283bb8f4dcd2eed1caa56e02b63c93161 Mon Sep 17 00:00:00 2001 From: Eshwanth Karti T R Date: Mon, 13 Jul 2026 15:50:26 +0530 Subject: [PATCH 13/13] DCO Remediation Commit for Eshwanth Karti T R I, Eshwanth Karti T R , hereby add my Signed-off-by to this commit: 983d032cebf057cb55cab958ea716f38a2f85e80 Signed-off-by: Eshwanth Karti T R