From c7eff5f33daef8eaba58df7e987b44f09c7a8ff9 Mon Sep 17 00:00:00 2001 From: Karthik Jeeyar Date: Wed, 20 Dec 2023 11:26:16 +0530 Subject: [PATCH] feat(tekton): add view logs and view sbom actions in the pipelineRun list (#1003) * fix(plr-actions): add view logs and view sbom actions in the pipelinerun list page * fix disabled button in dark mode * add IconButton to handle onclick callback on svg icons --- .../src/types/pipeline/pipeline.ts | 1 + .../src/types/pipeline/taskRun.ts | 15 ++ .../tekton/src/__fixtures__/taskRunData.ts | 162 ++++++++++++++++++ .../src/components/Icons/LinkToSbomIcon.tsx | 36 ++++ .../src/components/Icons/ViewLogsIcon.tsx | 36 ++++ .../PipelineRunColumnHeader.ts | 4 + .../PipelineRunList/PipelineRunRow.tsx | 4 + .../PipelineRunList/PipelineRunRowActions.tsx | 77 +++++++++ .../PipelineRunList/PipelineRunSBOMLink.tsx | 44 +++++ .../PipelineRunList/PipelineTableHeader.tsx | 4 +- .../__tests__/PipelineRunSBOMLink.test.tsx | 51 ++++++ .../tekton/src/utils/taskRun-utils.test.ts | 68 +++++++- plugins/tekton/src/utils/taskRun-utils.ts | 35 +++- 13 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 plugins/tekton/src/__fixtures__/taskRunData.ts create mode 100644 plugins/tekton/src/components/Icons/LinkToSbomIcon.tsx create mode 100644 plugins/tekton/src/components/Icons/ViewLogsIcon.tsx create mode 100644 plugins/tekton/src/components/PipelineRunList/PipelineRunRowActions.tsx create mode 100644 plugins/tekton/src/components/PipelineRunList/PipelineRunSBOMLink.tsx create mode 100644 plugins/tekton/src/components/PipelineRunList/__tests__/PipelineRunSBOMLink.test.tsx diff --git a/plugins/shared-react/src/types/pipeline/pipeline.ts b/plugins/shared-react/src/types/pipeline/pipeline.ts index ff15cd14a6..a269c1a6a1 100644 --- a/plugins/shared-react/src/types/pipeline/pipeline.ts +++ b/plugins/shared-react/src/types/pipeline/pipeline.ts @@ -55,6 +55,7 @@ export type TektonTaskSpec = { export type TektonResultsRun = { name: string; + type?: string; value: string; }; diff --git a/plugins/shared-react/src/types/pipeline/taskRun.ts b/plugins/shared-react/src/types/pipeline/taskRun.ts index 62949aec80..28cb8776ac 100644 --- a/plugins/shared-react/src/types/pipeline/taskRun.ts +++ b/plugins/shared-react/src/types/pipeline/taskRun.ts @@ -36,6 +36,7 @@ export type TaskRunStatus = { startTime?: string; steps?: PLRTaskRunStep[]; taskResults?: TektonResultsRun[]; + results?: TektonResultsRun[]; }; export type TaskRunKind = { @@ -53,3 +54,17 @@ export type TaskRunKind = { }; status?: TaskRunStatus; }; + +export enum TaskRunResultsAnnotations { + KEY = 'task.results.key', + TYPE = 'task.results.type', +} + +export enum TaskRunResultsAnnotationValue { + EXTERNAL_LINK = 'external-link', +} + +export enum TaskRunResults { + SBOM = 'LINK_TO_SBOM', + SCAN_OUTPUT = 'SCAN_OUTPUT', +} diff --git a/plugins/tekton/src/__fixtures__/taskRunData.ts b/plugins/tekton/src/__fixtures__/taskRunData.ts new file mode 100644 index 0000000000..b5540d0def --- /dev/null +++ b/plugins/tekton/src/__fixtures__/taskRunData.ts @@ -0,0 +1,162 @@ +import { TaskRunKind } from '@janus-idp/shared-react'; + +import { TEKTON_PIPELINE_RUN } from '../consts/tekton-const'; + +export const taskRunWithResults: TaskRunKind = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'TaskRun', + metadata: { + name: 'test-tr', + namespace: 'test-ns', + labels: { + 'tekton.dev/pipelineRun': 'test-plr', + }, + }, + spec: { + params: [ + { + name: 'first', + value: '20', + }, + { + name: 'second', + value: '10', + }, + ], + serviceAccountName: 'pipeline', + taskRef: { + kind: 'Task', + name: 'add-task', + }, + timeout: '1h0m0s', + }, + status: { + completionTime: 'Mon Mar 27 2023 18:09:11', + startTime: 'Mon Mar 27 2023 18:08:19', + podName: 'sum-three-pipeline-run-second-add-al6kxl-deploy-pod', + conditions: [ + { + lastTransitionTime: '2021-02-09T09:57:03Z', + message: 'All Steps have completed executing', + reason: 'Succeeded', + status: 'True', + type: 'Succeeded', + }, + ], + taskResults: [ + { + name: 'sum', + value: '30', + }, + { + name: 'difference', + value: '10', + }, + { + name: 'multiply', + value: '200', + }, + { + name: 'divide', + value: '2', + }, + ], + }, +}; + +export const taskRunWithSBOMResult = { + apiVersion: 'tekton.dev/v1', + kind: 'TaskRun', + metadata: { + annotations: { + 'chains.tekton.dev/signed': 'true', + 'pipeline.openshift.io/preferredName': 'pipelinerun-with-sbom-task', + 'pipeline.openshift.io/started-by': 'kube:admin', + 'task.output.location': 'results', + 'task.results.format': 'application/text', + 'task.results.key': 'LINK_TO_SBOM', + }, + labels: { + [TEKTON_PIPELINE_RUN]: 'test-plr', + }, + name: 'pipelinerun-with-sbom-task-t237ev-sbom-task', + uid: '764d0a6c-a4f6-419c-a3c3-585c2a9eb67c', + }, + spec: { + serviceAccountName: 'pipeline', + taskRef: { + kind: 'Task', + name: 'sbom-task', + }, + timeout: '1h0m0s', + }, + status: { + completionTime: '2023-11-08T08:18:25Z', + conditions: [ + { + lastTransitionTime: '2023-11-08T08:18:25Z', + message: 'All Steps have completed executing', + reason: 'Succeeded', + status: 'True', + type: 'Succeeded', + }, + ], + podName: 'pipelinerun-with-sbom-task-t237ev-sbom-task-pod', + results: [ + { + name: 'LINK_TO_SBOM', + type: 'string', + value: 'quay.io/test/image:build-8e536-1692702836', + }, + ], + }, +}; + +export const taskRunWithSBOMResultExternalLink: TaskRunKind = { + apiVersion: 'tekton.dev/v1', + kind: 'TaskRun', + metadata: { + annotations: { + 'chains.tekton.dev/signed': 'true', + 'pipeline.openshift.io/preferredName': 'pipelinerun-with-sbom-task', + 'pipeline.openshift.io/started-by': 'kube:admin', + 'pipeline.tekton.dev/release': 'a2f17f6', + 'task.output.location': 'results', + 'task.results.format': 'application/text', + 'task.results.type': 'external-link', + 'task.results.key': 'LINK_TO_SBOM', + }, + resourceVersion: '197373', + name: 'pipelinerun-with-sbom-task-t237ev-sbom-task', + uid: '764d0a6c-a4f6-419c-a3c3-585c2a9eb67c', + generation: 1, + }, + spec: { + serviceAccountName: 'pipeline', + taskRef: { + kind: 'Task', + name: 'sbom-task', + }, + timeout: '1h0m0s', + }, + status: { + completionTime: '2023-11-08T08:18:25Z', + conditions: [ + { + lastTransitionTime: '2023-11-08T08:18:25Z', + message: 'All Steps have completed executing', + reason: 'Succeeded', + status: 'True', + type: 'Succeeded', + }, + ], + podName: 'pipelinerun-with-sbom-task-t237ev-sbom-task-pod', + results: [ + { + name: 'LINK_TO_SBOM', + type: 'string', + value: 'http://quay.io/test/image:build-8e536-1692702836', + }, + ], + }, +}; diff --git a/plugins/tekton/src/components/Icons/LinkToSbomIcon.tsx b/plugins/tekton/src/components/Icons/LinkToSbomIcon.tsx new file mode 100644 index 0000000000..9b0b350eaf --- /dev/null +++ b/plugins/tekton/src/components/Icons/LinkToSbomIcon.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { createStyles, makeStyles, Theme } from '@material-ui/core'; +import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon'; +import classNames from 'classnames'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + icon: { + fill: 'var(--pf-v5-global--Color--100)', + }, + disabledButton: { + fill: theme.palette.grey[600], + }, + }), +); + +const LinkToSBomIcon: React.FC = (props): React.ReactElement => { + const classes = useStyles(); + return ( + + + + ); +}; + +export default LinkToSBomIcon; diff --git a/plugins/tekton/src/components/Icons/ViewLogsIcon.tsx b/plugins/tekton/src/components/Icons/ViewLogsIcon.tsx new file mode 100644 index 0000000000..49de1c6796 --- /dev/null +++ b/plugins/tekton/src/components/Icons/ViewLogsIcon.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { createStyles, makeStyles, Theme } from '@material-ui/core'; +import { SVGIconProps } from '@patternfly/react-icons/dist/esm/createIcon'; +import classNames from 'classnames'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + icon: { + fill: 'var(--pf-v5-global--Color--100)', + }, + disabledButton: { + fill: theme.palette.grey[600], + }, + }), +); + +const ViewLogsIcon: React.FC = (props): React.ReactElement => { + const classes = useStyles(); + return ( + + + + ); +}; + +export default ViewLogsIcon; diff --git a/plugins/tekton/src/components/PipelineRunList/PipelineRunColumnHeader.ts b/plugins/tekton/src/components/PipelineRunList/PipelineRunColumnHeader.ts index 2d7b17b740..2578b67e93 100644 --- a/plugins/tekton/src/components/PipelineRunList/PipelineRunColumnHeader.ts +++ b/plugins/tekton/src/components/PipelineRunList/PipelineRunColumnHeader.ts @@ -28,4 +28,8 @@ export const PipelineRunColumnHeader: TableColumn[] = [ title: 'DURATION', field: 'status.completionTime', }, + { + id: 'actions', + title: 'ACTIONS', + }, ]; diff --git a/plugins/tekton/src/components/PipelineRunList/PipelineRunRow.tsx b/plugins/tekton/src/components/PipelineRunList/PipelineRunRow.tsx index 5767a24274..ba73fe9cef 100644 --- a/plugins/tekton/src/components/PipelineRunList/PipelineRunRow.tsx +++ b/plugins/tekton/src/components/PipelineRunList/PipelineRunRow.tsx @@ -18,6 +18,7 @@ import { PipelineRunKind } from '@janus-idp/shared-react'; import { OpenRowStatus, tektonGroupColor } from '../../types/types'; import { pipelineRunDuration } from '../../utils/tekton-utils'; import { PipelineRunVisualization } from '../pipeline-topology'; +import PipelineRunRowActions from './PipelineRunRowActions'; import PipelineRunTaskStatus from './PipelineRunTaskStatus'; import PlrStatus from './PlrStatus'; import ResourceBadge from './ResourceBadge'; @@ -121,6 +122,9 @@ export const PipelineRunRow = ({ )} {pipelineRunDuration(row)} + + + diff --git a/plugins/tekton/src/components/PipelineRunList/PipelineRunRowActions.tsx b/plugins/tekton/src/components/PipelineRunList/PipelineRunRowActions.tsx new file mode 100644 index 0000000000..69a941f9b1 --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunList/PipelineRunRowActions.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import { IconButton } from '@material-ui/core'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { Tooltip } from '@patternfly/react-core/dist/esm/components/Tooltip/Tooltip'; + +import { PipelineRunKind } from '@janus-idp/shared-react'; + +import { TektonResourcesContext } from '../../hooks/TektonResourcesContext'; +import { getSbomTaskRun, isSbomTaskRun } from '../../utils/taskRun-utils'; +import ViewLogsIcon from '../Icons/ViewLogsIcon'; +import PipelineRunLogDialog from '../PipelineRunLogs/PipelineRunLogDialog'; +import PipelineRunSBOMLink from './PipelineRunSBOMLink'; + +const PipelineRunRowActions: React.FC<{ pipelineRun: PipelineRunKind }> = ({ + pipelineRun, +}) => { + const { watchResourcesData } = React.useContext(TektonResourcesContext); + const [open, setOpen] = React.useState(false); + const [noActiveTask, setNoActiveTask] = React.useState(false); + const pods = watchResourcesData?.pods?.data || []; + const taskRuns = watchResourcesData?.taskruns?.data || []; + const sbomTaskRun = getSbomTaskRun(pipelineRun?.metadata?.name, taskRuns); + const activeTaskName = sbomTaskRun?.metadata?.name; + + const openDialog = (viewLogs?: boolean) => { + if (viewLogs) setNoActiveTask(true); + setOpen(true); + }; + + const closeDialog = () => { + setNoActiveTask(false); + setOpen(false); + }; + + return ( + <> + + + + + openDialog(true)}> + + + + + + + + openDialog()} + style={{ pointerEvents: 'auto', padding: 0 }} + > + + + + + + + ); +}; +export default React.memo(PipelineRunRowActions); diff --git a/plugins/tekton/src/components/PipelineRunList/PipelineRunSBOMLink.tsx b/plugins/tekton/src/components/PipelineRunList/PipelineRunSBOMLink.tsx new file mode 100644 index 0000000000..28c702c278 --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunList/PipelineRunSBOMLink.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { TaskRunKind } from '@janus-idp/shared-react'; + +import { + getSbomLink, + hasExternalLink, + isSbomTaskRun, +} from '../../utils/taskRun-utils'; +import LinkToSBomIcon from '../Icons/LinkToSbomIcon'; + +const PipelineRunSBOMLink: React.FC<{ + sbomTaskRun: TaskRunKind | undefined; +}> = ({ sbomTaskRun }): React.ReactElement | null => { + const isSBOMTask = isSbomTaskRun(sbomTaskRun); + const isExternalLink: boolean = hasExternalLink(sbomTaskRun); + const linkToSbom = getSbomLink(sbomTaskRun); + + if ( + isSBOMTask && + isExternalLink && + (linkToSbom?.startsWith('http://') || linkToSbom?.startsWith('https://')) + ) { + // Link to external page + return ( + + + + ); + } else if (isSBOMTask && linkToSbom) { + // Link to internal taskrun page + return ; + } + + return ( + + ); +}; + +export default PipelineRunSBOMLink; diff --git a/plugins/tekton/src/components/PipelineRunList/PipelineTableHeader.tsx b/plugins/tekton/src/components/PipelineRunList/PipelineTableHeader.tsx index e6efde7171..dac973c615 100644 --- a/plugins/tekton/src/components/PipelineRunList/PipelineTableHeader.tsx +++ b/plugins/tekton/src/components/PipelineRunList/PipelineTableHeader.tsx @@ -60,7 +60,7 @@ export const EnhancedTableHead = ({ orderBy === headCell.field ? headCell.defaultSort : false } > - {headCell.field && ( + {headCell.field ? ( {headCell.title} + ) : ( + <> {headCell.title} )} ); diff --git a/plugins/tekton/src/components/PipelineRunList/__tests__/PipelineRunSBOMLink.test.tsx b/plugins/tekton/src/components/PipelineRunList/__tests__/PipelineRunSBOMLink.test.tsx new file mode 100644 index 0000000000..0b04c3b56c --- /dev/null +++ b/plugins/tekton/src/components/PipelineRunList/__tests__/PipelineRunSBOMLink.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import { LinkProps } from '@backstage/core-components'; + +import { render, screen } from '@testing-library/react'; + +import { + taskRunWithResults, + taskRunWithSBOMResult, + taskRunWithSBOMResultExternalLink, +} from '../../../__fixtures__/taskRunData'; +import PipelineRunSBOMLink from '../PipelineRunSBOMLink'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + Link: (props: LinkProps) => ( + + {props.children} + + ), + }; +}); + +describe('PipelineRunSBOMLInk', () => { + it('should render the icon space holder', () => { + render(); + + expect(screen.queryByTestId('icon-space-holder')).toBeInTheDocument(); + }); + + it('should render the icon space holder if the taskrun passed is not a valid sbomTaskrun', () => { + render(); + + expect(screen.queryByTestId('icon-space-holder')).toBeInTheDocument(); + }); + + it('should render the internal logs link for a sbom Taskrun', () => { + render(); + expect(screen.queryByTestId('internal-sbom-link')).toBeInTheDocument(); + }); + + it('should render the external logs link for a sbom Taskrun', () => { + render( + , + ); + + expect(screen.queryByTestId('external-sbom-link')).toBeInTheDocument(); + }); +}); diff --git a/plugins/tekton/src/utils/taskRun-utils.test.ts b/plugins/tekton/src/utils/taskRun-utils.test.ts index a5a8b21d44..b5dbf1d496 100644 --- a/plugins/tekton/src/utils/taskRun-utils.test.ts +++ b/plugins/tekton/src/utils/taskRun-utils.test.ts @@ -1,5 +1,17 @@ import { mockKubernetesPlrResponse } from '../__fixtures__/1-pipelinesData'; -import { getActiveTaskRun, getSortedTaskRuns } from './taskRun-utils'; +import { + taskRunWithResults, + taskRunWithSBOMResult, + taskRunWithSBOMResultExternalLink, +} from '../__fixtures__/taskRunData'; +import { + getActiveTaskRun, + getSbomLink, + getSbomTaskRun, + getSortedTaskRuns, + hasExternalLink, + isSbomTaskRun, +} from './taskRun-utils'; describe('taskRun-utils', () => { it('should return sorted task runs', () => { @@ -9,6 +21,11 @@ describe('taskRun-utils', () => { expect(sortedTaskRuns[0].id).toEqual('ruby-ex-git-xf45fo-build'); }); + it('should return empty sorted task runs', () => { + const sortedTaskRuns = getSortedTaskRuns([]); + expect(sortedTaskRuns).toHaveLength(0); + }); + it('should return active taskrun as the latest taskrun when active task is not present', () => { const activeTaskRun = getActiveTaskRun( getSortedTaskRuns(mockKubernetesPlrResponse.taskruns), @@ -32,4 +49,53 @@ describe('taskRun-utils', () => { ); expect(activeTaskRun).toBe(undefined); }); + + it('should not return the taskrun related to SBOM', () => { + const taskrunsWithoutSBOM = [taskRunWithResults]; + expect(getSbomTaskRun('test-plr', [])).toBeUndefined(); + expect(getSbomTaskRun('test-plr', taskrunsWithoutSBOM)).toBeUndefined(); + }); + + it('should return the taskrun related to SBOM', () => { + expect(getSbomTaskRun('test-plr', [taskRunWithSBOMResult])).toBeDefined(); + }); + + it('should not return the SBOM link', () => { + expect(getSbomLink(taskRunWithResults)).toBeUndefined(); + }); + + it('should return the SBOM link', () => { + expect(getSbomLink(taskRunWithSBOMResult)).toBe( + 'quay.io/test/image:build-8e536-1692702836', + ); + }); + + it('should return false if taskrun is missing annotations', () => { + expect( + hasExternalLink({ + ...taskRunWithSBOMResultExternalLink, + metadata: { + ...taskRunWithSBOMResultExternalLink.metadata, + annotations: undefined, + }, + }), + ).toBe(false); + }); + + it('should return false if the taskrun is missing external-link type annotation', () => { + expect(hasExternalLink(taskRunWithSBOMResult)).toBe(false); + }); + + it('should return true if the taskrun has external-link type annotation', () => { + expect(hasExternalLink(taskRunWithSBOMResultExternalLink)).toBe(true); + }); + + it('should return true if the taskrun is a valid SBOM task', () => { + expect(isSbomTaskRun(taskRunWithSBOMResultExternalLink)).toBe(true); + expect(isSbomTaskRun(taskRunWithSBOMResult)).toBe(true); + }); + + it('should return false if the taskrun is not a valid SBOM task', () => { + expect(isSbomTaskRun(taskRunWithResults)).toBe(false); + }); }); diff --git a/plugins/tekton/src/utils/taskRun-utils.ts b/plugins/tekton/src/utils/taskRun-utils.ts index 0d6e504c68..7e87d633fb 100644 --- a/plugins/tekton/src/utils/taskRun-utils.ts +++ b/plugins/tekton/src/utils/taskRun-utils.ts @@ -2,9 +2,15 @@ import { ComputedStatus, pipelineRunFilterReducer, TaskRunKind, + TaskRunResults, + TaskRunResultsAnnotations, + TaskRunResultsAnnotationValue, } from '@janus-idp/shared-react'; -import { TEKTON_PIPELINE_TASK } from '../consts/tekton-const'; +import { + TEKTON_PIPELINE_RUN, + TEKTON_PIPELINE_TASK, +} from '../consts/tekton-const'; export type TaskStep = { id: string; @@ -49,3 +55,30 @@ export const getActiveTaskRun = ( activeTask ? taskRuns.find(taskRun => taskRun?.id === activeTask)?.id : taskRuns[taskRuns.length - 1]?.id; + +export const isSbomTaskRun = (tr: TaskRunKind | undefined): boolean => + tr?.metadata?.annotations?.[TaskRunResultsAnnotations.KEY] === + TaskRunResults.SBOM; + +export const getSbomTaskRun = ( + pipelineRunName: string | undefined, + taskruns: TaskRunKind[], +): TaskRunKind | undefined => + taskruns?.find( + (tr: any) => + tr?.metadata?.labels?.[TEKTON_PIPELINE_RUN] === pipelineRunName && + isSbomTaskRun(tr), + ); + +export const hasExternalLink = ( + sbomTaskRun: TaskRunKind | undefined, +): boolean => + sbomTaskRun?.metadata?.annotations?.[TaskRunResultsAnnotations.TYPE] === + TaskRunResultsAnnotationValue.EXTERNAL_LINK; + +export const getSbomLink = ( + sbomTaskRun: TaskRunKind | undefined, +): string | undefined => + (sbomTaskRun?.status?.results || sbomTaskRun?.status?.taskResults)?.find( + r => r.name === TaskRunResults.SBOM, + )?.value;