diff --git a/src/libs/ajax/teaspoons/teaspoons-models.ts b/src/libs/ajax/teaspoons/teaspoons-models.ts index 04a86e7f1b..cee6eb15b1 100644 --- a/src/libs/ajax/teaspoons/teaspoons-models.ts +++ b/src/libs/ajax/teaspoons/teaspoons-models.ts @@ -5,9 +5,11 @@ export interface Pipeline { description: string; } +export type PipelineIOType = 'FILE' | 'STRING' | 'FLOAT' | 'BOOLEAN'; + export interface PipelineInput { name: string; - type: 'FILE' | 'STRING' | 'FLOAT' | 'BOOLEAN'; + type: PipelineIOType | string; // Prefer a defined type, but allow for future types without breaking isRequired: boolean; displayName?: string; description?: string; @@ -19,7 +21,7 @@ export interface PipelineInput { export interface PipelineOutput { name: string; - type: string; + type: PipelineIOType | string; // Prefer a defined type, but allow for future types without breaking displayName?: string; description?: string; } @@ -124,7 +126,11 @@ export interface PipelineRunReport { pipelineVersion: number; toolVersion: string; outputs?: Record; + userInputs?: Record; outputExpirationDate?: string; + inputSize?: number; + inputSizeUnits?: string; + quotaConsumed?: number; } export type PipelineRunStatus = 'PREPARING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED'; diff --git a/src/pages/scientificServices/NavPaths.tsx b/src/pages/scientificServices/NavPaths.tsx index 07ec55ee93..fb57268fce 100644 --- a/src/pages/scientificServices/NavPaths.tsx +++ b/src/pages/scientificServices/NavPaths.tsx @@ -1,5 +1,6 @@ import { CliAuth } from 'src/pages/scientificServices/cli-auth/CliAuth'; import { About } from 'src/pages/scientificServices/pipelines/tabs/about/About'; +import { JobDetails } from 'src/pages/scientificServices/pipelines/tabs/history/details/JobDetails'; import { JobHistory } from 'src/pages/scientificServices/pipelines/tabs/history/JobHistory'; import { RunJob } from 'src/pages/scientificServices/pipelines/tabs/run/RunJob'; @@ -29,6 +30,12 @@ export const navPaths = [ component: JobHistory, title: 'Job History', }, + { + name: 'pipelines-job-detail', + path: '/pipelines/imputation/history/:jobId', + component: JobDetails, + title: 'Job Details', + }, { name: 'cli-auth', path: '/pipelines/cli-auth', diff --git a/src/pages/scientificServices/pipelines/common/PipelineErrorMessage.tsx b/src/pages/scientificServices/pipelines/common/PipelineErrorMessage.tsx new file mode 100644 index 0000000000..acd27e1334 --- /dev/null +++ b/src/pages/scientificServices/pipelines/common/PipelineErrorMessage.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react'; + +interface PipelineErrorMessageProps { + title: string; + message: ReactNode; +} + +export const PipelineErrorMessage = ({ title, message }: PipelineErrorMessageProps) => { + return ( +
+
{title}
+
{message}
+
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/hooks/usePipelineDetails.ts b/src/pages/scientificServices/pipelines/hooks/usePipelineDetails.ts new file mode 100644 index 0000000000..0065eff07c --- /dev/null +++ b/src/pages/scientificServices/pipelines/hooks/usePipelineDetails.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { PipelineWithDetails } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { notify } from 'src/libs/notifications'; +import { useCancellation } from 'src/libs/react-utils'; + +export interface UsePipelineDetailsResult { + pipelineDetails: PipelineWithDetails | null; + isLoading: boolean; + error: Error | undefined; +} + +export const usePipelineDetails = (pipelineName: string, pipelineVersion: number): UsePipelineDetailsResult => { + const signal = useCancellation(); + const [pipelineDetails, setPipelineDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(undefined); + + const fetchPipelineDetails = async () => { + setIsLoading(true); + setError(undefined); + + try { + const response = await Teaspoons(signal).getPipelineDetails(pipelineName, pipelineVersion); + setPipelineDetails(response); + } catch (err) { + const error = err instanceof Error ? err : new Error('Failed to fetch pipeline details'); + setError(error); + notify('error', error.message); + setPipelineDetails(null); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPipelineDetails(); + }, [pipelineName, pipelineVersion, signal]); // eslint-disable-line react-hooks/exhaustive-deps + + return { + pipelineDetails, + isLoading, + error, + }; +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/JobHistory.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/JobHistory.test.tsx index a2d35e963f..35be924ce4 100644 --- a/src/pages/scientificServices/pipelines/tabs/history/JobHistory.test.tsx +++ b/src/pages/scientificServices/pipelines/tabs/history/JobHistory.test.tsx @@ -3,7 +3,7 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Teaspoons, TeaspoonsContract } from 'src/libs/ajax/teaspoons/Teaspoons'; -import { PipelineRun } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { PipelineRun, PipelineRunStatus } from 'src/libs/ajax/teaspoons/teaspoons-models'; import { usePipelinesList } from 'src/pages/scientificServices/pipelines/hooks/usePipelinesList'; import { mockPipeline, mockPipelineRun } from 'src/pages/scientificServices/pipelines/utils/mock-utils'; import { asMockedFn, partial, renderWithAppContexts as render } from 'src/testing/test-utils'; @@ -552,4 +552,48 @@ describe('job history table', () => { expect(style.color).toBe('rgb(219, 50, 20)'); }); }); + + describe('Job ID column', () => { + it.each([ + { status: 'SUCCEEDED' as PipelineRunStatus, shouldHaveLink: true, linkText: 'a link' }, + { status: 'FAILED' as PipelineRunStatus, shouldHaveLink: true, linkText: 'a link' }, + { status: 'RUNNING' as PipelineRunStatus, shouldHaveLink: true, linkText: 'a link' }, + { status: 'PREPARING' as PipelineRunStatus, shouldHaveLink: false, linkText: 'plain text' }, + ])('renders job ID as $linkText for $status jobs', async ({ status, shouldHaveLink }) => { + const elevenHoursAgo = new Date(Date.now() - 60 * 60 * 1000 * (PREPARING_JOB_CUTOFF_HOURS - 1)).toISOString(); + const pipelineRun = { + ...mockPipelineRun(status), + // For PREPARING status, set the timeSubmitted to less than PREPARING_JOB_CUTOFF_HOURS ago + ...(status === 'PREPARING' && { timeSubmitted: elevenHoursAgo }), + }; + const pipelineRuns = [pipelineRun]; + + const mockPipelineRunResponse = { + pageToken: null, + results: pipelineRuns, + totalResults: 1, + }; + + asMockedFn(Teaspoons).mockReturnValue( + partial({ + getAllPipelineRuns: jest.fn().mockReturnValue(mockPipelineRunResponse), + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getAllByText(pipelineRun.jobId)).toHaveLength(2); + }); + + if (shouldHaveLink) { + // look for aria-label on the link + const jobIdLink = screen.getByLabelText(`View details for job ${pipelineRun.jobId}`); + expect(jobIdLink).toBeInTheDocument(); + } else { + // For PREPARING status, the job ID should not have an aria-label (i.e. no link) + expect(screen.queryByLabelText(`View details for job ${pipelineRun.jobId}`)).not.toBeInTheDocument(); + } + }); + }); }); diff --git a/src/pages/scientificServices/pipelines/tabs/history/JobHistory.tsx b/src/pages/scientificServices/pipelines/tabs/history/JobHistory.tsx index e6f3574c1a..301405fb5f 100644 --- a/src/pages/scientificServices/pipelines/tabs/history/JobHistory.tsx +++ b/src/pages/scientificServices/pipelines/tabs/history/JobHistory.tsx @@ -1,4 +1,4 @@ -import { ButtonPrimary, Icon, Spinner, TooltipTrigger, useModalHandler } from '@terra-ui-packages/components'; +import { ButtonPrimary, Icon, Link, Spinner, TooltipTrigger, useModalHandler } from '@terra-ui-packages/components'; import { formatDate, formatDatetime } from '@terra-ui-packages/core-utils'; import _, { capitalize } from 'lodash'; import pluralize from 'pluralize'; @@ -11,6 +11,7 @@ import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; import { GetPipelineRunsResponse, PipelineRun } from 'src/libs/ajax/teaspoons/teaspoons-models'; import colors from 'src/libs/colors'; import Events from 'src/libs/events'; +import * as Nav from 'src/libs/nav'; import { useCancellation } from 'src/libs/react-utils'; import { pipelinesTopBar, @@ -21,6 +22,10 @@ import { usePipelinesList } from 'src/pages/scientificServices/pipelines/hooks/u import { FilterValues, TableFilters } from 'src/pages/scientificServices/pipelines/tabs/history/controls/TableFilters'; import { ViewErrorModal } from 'src/pages/scientificServices/pipelines/tabs/history/modals/ViewErrorModal'; import { ViewOutputsModal } from 'src/pages/scientificServices/pipelines/tabs/history/modals/ViewOutputsModal'; +import { + getPipelineColor, + getPipelineStatusColor, +} from 'src/pages/scientificServices/pipelines/utils/pipeline-style-utils'; // If a job is still in "Preparing" state after this many hours, we consider it a failure. export const PREPARING_JOB_CUTOFF_HOURS = 12; @@ -204,7 +209,7 @@ const getColumns = (paginatedRuns: PipelineRun[], sort: SortProperties, onSort: cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 140 }, + size: { basis: 250 }, }, { field: 'description', @@ -212,7 +217,7 @@ const getColumns = (paginatedRuns: PipelineRun[], sort: SortProperties, onSort: cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 150 }, + size: { basis: 160 }, }, { field: 'status', @@ -220,7 +225,7 @@ const getColumns = (paginatedRuns: PipelineRun[], sort: SortProperties, onSort: cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 40 }, + size: { basis: 50 }, }, { field: 'submitted', @@ -232,7 +237,7 @@ const getColumns = (paginatedRuns: PipelineRun[], sort: SortProperties, onSort: cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 80 }, + size: { basis: 60 }, }, { field: 'completed', @@ -245,7 +250,7 @@ const getColumns = (paginatedRuns: PipelineRun[], sort: SortProperties, onSort: cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 80 }, + size: { basis: 60 }, }, { field: 'dataDeletionDate', @@ -253,7 +258,7 @@ const getColumns = (paginatedRuns: PipelineRun[], sort: SortProperties, onSort: cellRenderer: ({ rowIndex }) => { return ; }, - size: { basis: 80 }, + size: { basis: 60 }, }, { field: 'quotaUsed', @@ -285,26 +290,60 @@ interface CellProps { } const JobIdCell = ({ pipelineRun }: CellProps): ReactNode => { + // note that you cannot view details for jobs that are in PREPARING status, so we only link to details for other statuses + const canViewDetails = ['FAILED', 'RUNNING', 'SUCCEEDED'].includes(pipelineRun.status); + return (
- - {pipelineRun.jobId} - + {canViewDetails ? ( + + + + {pipelineRun.jobId} + + + + ) : ( + + + {pipelineRun.jobId} + + + )}
{ switch (pipelineRun.status) { case 'SUCCEEDED': return ( -
+
Done
); @@ -520,7 +566,14 @@ const getRunStatusIcon = (pipelineRun: PipelineRun): ReactNode => { if (hoursElapsed > PREPARING_JOB_CUTOFF_HOURS) { return ( -
+
Failed
); @@ -538,7 +591,14 @@ const getRunStatusIcon = (pipelineRun: PipelineRun): ReactNode => { } case 'FAILED': return ( -
+
Failed
); @@ -547,15 +607,6 @@ const getRunStatusIcon = (pipelineRun: PipelineRun): ReactNode => { } }; -const pipelineNameToColor = (pipelineRun: PipelineRun): string => { - switch (pipelineRun.pipelineName) { - case 'array_imputation': - return '#4D72AA4D'; - default: - return '#AA4D8B4D'; - } -}; - const hoursElapsedSinceSubmission = (pipelineRun: PipelineRun): number => { const submittedTime = new Date(pipelineRun.timeSubmitted); const currentTime = new Date(); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/JobDetails.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/JobDetails.test.tsx new file mode 100644 index 0000000000..a0ca382db3 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/JobDetails.test.tsx @@ -0,0 +1,78 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; +import * as Nav from 'src/libs/nav'; +import { getOutputFileSize } from 'src/pages/scientificServices/pipelines/utils/download-utils'; +import { mockPipelineRunResponse } from 'src/pages/scientificServices/pipelines/utils/mock-utils'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +import { JobDetails } from './JobDetails'; + +jest.mock('src/libs/ajax/teaspoons/Teaspoons'); +jest.mock('src/libs/nav'); +jest.mock('src/libs/notifications'); +jest.mock('src/pages/scientificServices/pipelines/utils/download-utils'); + +describe('JobDetails', () => { + const mockGetPipelineRunResult = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (Teaspoons as jest.Mock).mockReturnValue({ + getPipelineRunResult: mockGetPipelineRunResult, + }); + (getOutputFileSize as jest.Mock).mockResolvedValue('10.5 MB'); + }); + + it('renders all job details sections after successful load', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + mockGetPipelineRunResult.mockResolvedValue(mockResult); + + render(); + + await waitFor(() => { + // Back button + expect(screen.getByRole('button', { name: /View All/i })).toBeInTheDocument(); + + // JobDetailsHeader elements + expect(screen.getByText('Job ID')).toBeInTheDocument(); + expect(screen.getByText('job-123-456-789')).toBeInTheDocument(); + + // JobIOView elements + expect(screen.getByText('Inputs')).toBeInTheDocument(); + expect(screen.getByText('Outputs')).toBeInTheDocument(); + + // Timeline element + expect(screen.getByText('Timeline')).toBeInTheDocument(); + }); + }); + + it('fetches job details on render', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + mockGetPipelineRunResult.mockResolvedValue(mockResult); + + render(); + + await waitFor(() => { + expect(mockGetPipelineRunResult).toHaveBeenCalledWith('job-123'); + }); + }); + + it('navigates to pipelines history when back button is clicked', async () => { + const user = userEvent.setup(); + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + mockGetPipelineRunResult.mockResolvedValue(mockResult); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /View All/i })).toBeInTheDocument(); + }); + + const backButton = screen.getByRole('button', { name: /View All/i }); + await user.click(backButton); + + expect(Nav.goToPath).toHaveBeenCalledWith('pipelines-history'); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/JobDetails.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/JobDetails.tsx new file mode 100644 index 0000000000..018ac8c501 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/JobDetails.tsx @@ -0,0 +1,78 @@ +import { ButtonSecondary, Icon, Spinner } from '@terra-ui-packages/components'; +import React, { useEffect, useState } from 'react'; +import FooterWrapper from 'src/components/FooterWrapper'; +import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import * as Nav from 'src/libs/nav'; +import { notify } from 'src/libs/notifications'; +import { pipelinesTopBar } from 'src/pages/scientificServices/pipelines/common/scientific-services-common'; +import { JobDetailsHeader } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader'; +import { JobIOView } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView'; +import { PipelineRunTimeline } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline'; + +export interface JobDetailsProps { + jobId: string; +} + +export const JobDetails = ({ jobId }: JobDetailsProps) => { + const [pipelineRunResult, setPipelineRunResult] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchJobDetails() { + setIsLoading(true); + try { + const response = await Teaspoons().getPipelineRunResult(jobId); + + setPipelineRunResult(response); + } catch (err) { + notify('error', 'Failed to load job details'); + } finally { + setIsLoading(false); + } + } + fetchJobDetails(); + }, [jobId]); + + return ( + + {pipelinesTopBar('job history')} +
+
+ Nav.goToPath('pipelines-history')} + style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }} + > + + View All + +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && pipelineRunResult && ( +
+ + +
+
+ +
+
+ +
+
+
+ )} +
+
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader.test.tsx new file mode 100644 index 0000000000..75b34f38f3 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader.test.tsx @@ -0,0 +1,102 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { usePipelineDetails } from 'src/pages/scientificServices/pipelines/hooks/usePipelineDetails'; +import { + mockPipelineRunResponse, + mockPipelineWithDetails, +} from 'src/pages/scientificServices/pipelines/utils/mock-utils'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +import { JobDetailsHeader } from './JobDetailsHeader'; + +jest.mock('src/pages/scientificServices/pipelines/hooks/usePipelineDetails'); + +describe('JobDetailsHeader', () => { + const mockUsePipelineDetails = usePipelineDetails as jest.MockedFunction; + const mockPipelineDetails = mockPipelineWithDetails('array_imputation'); + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePipelineDetails.mockReturnValue({ + pipelineDetails: mockPipelineDetails, + isLoading: false, + error: undefined, + }); + }); + + it('renders pipeline display name', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + expect(screen.getByText(mockPipelineDetails.displayName)).toBeInTheDocument(); + }); + }); + + it('renders pipeline version', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + expect(screen.getByText('Version 1')).toBeInTheDocument(); + }); + }); + + it('renders pipeline description when available', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + expect(screen.getByText(mockPipelineDetails.description)).toBeInTheDocument(); + }); + }); + + it('renders job status badge with correct text', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + expect(screen.getByText('succeeded')).toBeInTheDocument(); + }); + }); + + it('renders job ID', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + expect(screen.getByText('Job ID')).toBeInTheDocument(); + expect(screen.getByText('job-123-456-789')).toBeInTheDocument(); + }); + }); + + it('renders copy button for job ID', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + const copyButton = screen.getByRole('button'); + expect(copyButton).toBeInTheDocument(); + }); + }); + + it('renders description for a pipeline run when available', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED', 'Test job description'); + render(); + + await waitFor(() => { + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Test job description')).toBeInTheDocument(); + }); + }); + + it('renders "No description" when description is not provided for a pipeline run', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + await waitFor(() => { + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('No description')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader.tsx new file mode 100644 index 0000000000..7e1a37b111 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobDetailsHeader.tsx @@ -0,0 +1,115 @@ +import { Spinner } from '@terra-ui-packages/components'; +import React, { ReactNode } from 'react'; +import { ClipboardButton } from 'src/components/ClipboardButton'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; +import { usePipelineDetails } from 'src/pages/scientificServices/pipelines/hooks/usePipelineDetails'; +import { AoUStylizedString } from 'src/pages/scientificServices/pipelines/utils/AoUStylizedString'; +import { + getPipelineStatusColor, + getPipelineStatusIcon, +} from 'src/pages/scientificServices/pipelines/utils/pipeline-style-utils'; + +interface JobDetailsHeaderProps { + pipelineRunResult: PipelineRunResponse; +} + +const HeaderItem = ({ label, value }: { label: string; value: ReactNode }) => ( +
+
{label}
+
{value}
+
+); + +export const JobDetailsHeader = ({ pipelineRunResult }: JobDetailsHeaderProps) => { + const { pipelineDetails, isLoading: isLoadingPipelineDetails } = usePipelineDetails( + pipelineRunResult.pipelineRunReport.pipelineName, + pipelineRunResult.pipelineRunReport.pipelineVersion + ); + + return isLoadingPipelineDetails ? ( + + ) : ( +
+
+
+
+

+ {pipelineDetails && ( +
+ + + Version {pipelineRunResult.pipelineRunReport.pipelineVersion} + +
+ )} +

+ {pipelineDetails && pipelineDetails.description && ( +
{pipelineDetails.description}
+ )} +
+ +
+ {getPipelineStatusIcon(pipelineRunResult.jobReport.status)} + {pipelineRunResult.jobReport.status.toLowerCase()} +
+
+
+
+ + {pipelineRunResult.jobReport.id} + + + } + /> + + +
+
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView.test.tsx new file mode 100644 index 0000000000..f983e50150 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView.test.tsx @@ -0,0 +1,62 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { usePipelineDetails } from 'src/pages/scientificServices/pipelines/hooks/usePipelineDetails'; +import { getOutputFileSize } from 'src/pages/scientificServices/pipelines/utils/download-utils'; +import { + mockPipelineRunResponse, + mockPipelineWithDetails, +} from 'src/pages/scientificServices/pipelines/utils/mock-utils'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +import { JobIOView } from './JobIOView'; + +jest.mock('src/pages/scientificServices/pipelines/hooks/usePipelineDetails'); +jest.mock('src/pages/scientificServices/pipelines/utils/download-utils'); + +describe('JobIOView', () => { + const mockUsePipelineDetails = usePipelineDetails as jest.MockedFunction; + + const mockPipelineDetails = mockPipelineWithDetails('array_imputation'); + + beforeEach(() => { + jest.clearAllMocks(); + mockUsePipelineDetails.mockReturnValue({ + pipelineDetails: mockPipelineDetails, + isLoading: false, + error: undefined, + }); + (getOutputFileSize as jest.Mock).mockResolvedValue('10.5 MB'); + }); + + it('renders JobInputsView component with Inputs and Outputs side by side', async () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + render(); + + expect(mockUsePipelineDetails).toHaveBeenCalledWith('array_imputation', 1); + + await waitFor(() => { + expect(screen.getByText('Inputs & Outputs')).toBeInTheDocument(); + expect(screen.getByText('Inputs')).toBeInTheDocument(); + expect(screen.getByText('Outputs')).toBeInTheDocument(); + }); + }); + + it('renders error message instead of outputs when errorReport is present', async () => { + const mockResult: PipelineRunResponse = { + ...mockPipelineRunResponse('FAILED'), + errorReport: { + message: 'Pipeline failed because your vcf was terrrrrrrrible', + errorCode: 500, + causes: [], + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('No outputs were generated due to the following error:')).toBeInTheDocument(); + expect(screen.getByText('Pipeline failed because your vcf was terrrrrrrrible')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView.tsx new file mode 100644 index 0000000000..90bb3d9217 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/JobIOView.tsx @@ -0,0 +1,60 @@ +import { Icon } from '@terra-ui-packages/components'; +import React from 'react'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; +import { PipelineErrorMessage } from 'src/pages/scientificServices/pipelines/common/PipelineErrorMessage'; +import { usePipelineDetails } from 'src/pages/scientificServices/pipelines/hooks/usePipelineDetails'; +import { JobInputsView } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView'; +import { JobOutputsView } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView'; + +interface PipelineRunIOViewProps { + pipelineRunResult: PipelineRunResponse; +} + +export const JobIOView = ({ pipelineRunResult }: PipelineRunIOViewProps) => { + const { pipelineDetails, isLoading } = usePipelineDetails( + pipelineRunResult.pipelineRunReport.pipelineName, + pipelineRunResult.pipelineRunReport.pipelineVersion + ); + + const inputDefinitions = pipelineDetails?.inputs || []; + const outputDefinitions = pipelineDetails?.outputs || []; + + return ( +
+

Inputs & Outputs

+ {isLoading ? ( +
Loading...
+ ) : ( +
+ + +
+ +
+ +
+ + {pipelineRunResult.errorReport && ( + + )} +
+
+ )} +
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView.test.tsx new file mode 100644 index 0000000000..f7d3bd8bf1 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView.test.tsx @@ -0,0 +1,83 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; +import { PipelineInput } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import { mockPipelineWithDetails } from 'src/pages/scientificServices/pipelines/utils/mock-utils'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +import { JobInputsView } from './JobInputsView'; + +describe('JobInputsView', () => { + const mockInputDefinitions: PipelineInput[] = mockPipelineWithDetails('array_imputation').inputs; + + const mockInputs = { + minDr2ForInclusion: 0.8, + allowChunkFailures: 'true', + multiSampleVcf: 'gs://bucket/path/to/file.vcf', + outputBasename: 'outputs', + }; + + it('renders all input items when inputs are provided', () => { + render(); + + expect(screen.getByText('minimum imputation quality for inclusion')).toBeInTheDocument(); + expect(screen.getByText('Allow chunk failures')).toBeInTheDocument(); + expect(screen.getByText('multi-sample VCF file')).toBeInTheDocument(); + expect(screen.getByText('output basename')).toBeInTheDocument(); + }); + + it('renders input values', () => { + render(); + + expect(screen.getByText('gs://bucket/path/to/file.vcf')).toBeInTheDocument(); + expect(screen.getByText('0.8')).toBeInTheDocument(); + expect(screen.getByText('true')).toBeInTheDocument(); + expect(screen.getByText('outputs')).toBeInTheDocument(); + }); + + it('renders input type badges', () => { + render(); + + expect(screen.getByText('file')).toBeInTheDocument(); + expect(screen.getByText('float')).toBeInTheDocument(); + expect(screen.getByText('boolean')).toBeInTheDocument(); + expect(screen.getByText('string')).toBeInTheDocument(); + }); + + it('uses display name when available', () => { + render(); + + expect(screen.getByText('output basename')).toBeInTheDocument(); + expect(screen.queryByText('outputBasename')).not.toBeInTheDocument(); + }); + + it('falls back to input key when display name is not available', () => { + const inputsWithUnknownKey = { + unknownInput: 'some value', + }; + + render(); + + expect(screen.getByText('unknownInput')).toBeInTheDocument(); + }); + + it('renders "There was an error." when inputs object is empty', () => { + render(); + + expect(screen.getByText('There was an error.')).toBeInTheDocument(); + }); + + it('renders "There was an error." when inputs is undefined', () => { + render(); + + expect(screen.getByText('There was an error.')).toBeInTheDocument(); + }); + + it('does not render input items when inputs object is empty', () => { + render(); + + expect(screen.queryByText('minimum imputation quality for inclusion')).not.toBeInTheDocument(); + expect(screen.queryByText('Allow chunk failures')).not.toBeInTheDocument(); + expect(screen.queryByText('multi-sample VCF file')).not.toBeInTheDocument(); + expect(screen.queryByText('output basename')).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView.tsx new file mode 100644 index 0000000000..caadce55e0 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobInputsView.tsx @@ -0,0 +1,93 @@ +import { Icon, TooltipTrigger } from '@terra-ui-packages/components'; +import React, { ReactNode } from 'react'; +import { PipelineInput } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; +import { PipelineErrorMessage } from 'src/pages/scientificServices/pipelines/common/PipelineErrorMessage'; +import { SCIENTIFIC_SERVICES_SUPPORT_EMAIL } from 'src/pages/scientificServices/pipelines/common/scientific-services-common'; +import { PipelineIOTypeBadge } from 'src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineIOTypeBadge'; + +const InputItem = ({ + label, + value, + tooltip, + inputType, +}: { + label: string; + value: ReactNode; + tooltip: string; + inputType: string; +}) => ( +
+
+ + {label} + + +
+
{value}
+
+); + +interface JobInputsProps { + inputDefinitions: PipelineInput[]; + inputs: Record; +} + +export const JobInputsView = ({ inputDefinitions, inputs }: JobInputsProps) => { + const hasInputs = inputs && Object.keys(inputs).length > 0; + + return ( +
+
+

Inputs

+ + + +
+ {hasInputs ? ( +
+ {Object.entries(inputs).map(([key, value]) => { + const inputDef = inputDefinitions.find((input) => input.name === key); + return ( +
+ + {value} +
+ } + tooltip={inputDef?.description || 'No description available for this input'} + inputType={inputDef?.type || 'STRING'} + /> +
+ ); + })} +
+ ) : ( + + This run does not have any inputs to display. There was either an issue running the pipeline or retrieving + the inputs. Please reload the page, or contact{' '} + + {SCIENTIFIC_SERVICES_SUPPORT_EMAIL} + {' '} + for further assistance. + + } + /> + )} +
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView.test.tsx new file mode 100644 index 0000000000..93e7284b6d --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView.test.tsx @@ -0,0 +1,333 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Metrics } from 'src/libs/ajax/Metrics'; +import { PipelineOutput } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import Events from 'src/libs/events'; +import { getOutputFileSize } from 'src/pages/scientificServices/pipelines/utils/download-utils'; +import { + mockPipelineRunResponse, + mockPipelineWithDetails, +} from 'src/pages/scientificServices/pipelines/utils/mock-utils'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +import { JobOutputsView } from './JobOutputsView'; + +jest.mock('src/libs/ajax/Metrics'); +jest.mock('src/pages/scientificServices/pipelines/utils/download-utils'); + +describe('JobOutputsView', () => { + const mockCaptureEvent = jest.fn(); + const mockWindowOpen = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (Metrics as jest.Mock).mockReturnValue({ + captureEvent: mockCaptureEvent, + }); + (getOutputFileSize as jest.Mock).mockResolvedValue('10.5 MB'); + window.open = mockWindowOpen; + }); + + const mockOutputDefinitions: PipelineOutput[] = mockPipelineWithDetails('array_imputation').outputs; + + it('renders output items when outputs are available', async () => { + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/imputedMultiSampleVcf.vcf', + imputedMultiSampleVcfIndex: 'gs://bucket/imputedMultiSampleVcfIndex.vcf', + chunksInfo: 'gs://bucket/chunksInfo.tsv', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('imputed multi-sample VCF')).toBeInTheDocument(); + expect(screen.getByText('imputed multi-sample VCF index')).toBeInTheDocument(); + expect(screen.getByText('imputation chunks QC tsv')).toBeInTheDocument(); + }); + }); + + it('renders output type badges', async () => { + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('file')).toBeInTheDocument(); + }); + }); + + it('falls back to output key when display name is not available', async () => { + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + unknownOutput: 'gs://bucket/unknown.txt', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('unknownOutput')).toBeInTheDocument(); + }); + }); + + it('shows download button for succeeded jobs', async () => { + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument(); + }); + }); + + it('does not show download button for failed jobs', async () => { + const mockResult = { + ...mockPipelineRunResponse('FAILED'), + pipelineRunReport: { + ...mockPipelineRunResponse('FAILED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument(); + }); + }); + + it('does not show download button for running jobs', async () => { + const mockResult = { + ...mockPipelineRunResponse('RUNNING'), + pipelineRunReport: { + ...mockPipelineRunResponse('RUNNING').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument(); + }); + }); + + it('opens URL in new tab when download button is clicked', async () => { + const user = userEvent.setup(); + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument(); + }); + + const downloadButton = screen.getByRole('button', { name: /download/i }); + await user.click(downloadButton); + + expect(mockWindowOpen).toHaveBeenCalledWith('gs://bucket/output.vcf', '_blank'); + }); + + it('captures mixpanel event when download button is clicked', async () => { + const user = userEvent.setup(); + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + pipelineName: 'test-pipeline', + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument(); + }); + + const downloadButton = screen.getByRole('button', { name: /download/i }); + await user.click(downloadButton); + + await waitFor(() => { + expect(mockCaptureEvent).toHaveBeenCalledWith(Events.teaspoons.downloadJobOutputFile, { + pipelineName: 'test-pipeline', + pipelineVersion: 1, + outputName: 'imputed multi-sample VCF', + fileSize: '10.5 MB', + }); + }); + }); + + it('displays file size when loaded', async () => { + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('10.5 MB')).toBeInTheDocument(); + }); + }); + + it('displays "Loading file size..." initially', async () => { + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + expect(screen.getByText('Loading file size...')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText('Loading file size...')).not.toBeInTheDocument(); + }); + }); + + it('displays "Unknown size" when file size fetch fails', async () => { + (getOutputFileSize as jest.Mock).mockRejectedValue(new Error('Failed to fetch size')); + + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('Unknown size')).toBeInTheDocument(); + }); + }); + + it('displays "Not available" for file size when job is not succeeded', async () => { + const mockResult = { + ...mockPipelineRunResponse('FAILED'), + pipelineRunReport: { + ...mockPipelineRunResponse('FAILED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.getByText('Not available')).toBeInTheDocument(); + }); + }); + + it('shows message when no outputs are available for succeeded job', () => { + const mockResult = mockPipelineRunResponse('SUCCEEDED'); + + render(); + + expect(screen.getByText('There was an error.')).toBeInTheDocument(); + }); + + it('shows message when job is still running', () => { + const mockResult = mockPipelineRunResponse('RUNNING'); + + render(); + + expect( + screen.getByText('The job is still in progress. Outputs will be available after the job completes.') + ).toBeInTheDocument(); + }); + + it('shows expired message when outputs have expired', () => { + // Set expiration date to a past date + const pastDate = new Date('2020-01-01').toISOString(); + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputExpirationDate: pastDate, + }, + }; + + render(); + + expect(screen.getByText(/The outputs for this job expired on/)).toBeInTheDocument(); + expect(screen.getByText(/and are no longer available/)).toBeInTheDocument(); + }); + + it('does not show expired message when outputs have not expired', async () => { + // Set expiration date to a future date + const futureDate = new Date('2030-01-01').toISOString(); + const mockResult = { + ...mockPipelineRunResponse('SUCCEEDED'), + pipelineRunReport: { + ...mockPipelineRunResponse('SUCCEEDED').pipelineRunReport, + outputs: { + imputedMultiSampleVcf: 'gs://bucket/output.vcf', + }, + outputExpirationDate: futureDate, + }, + }; + + render(); + + await waitFor(() => { + expect(screen.queryByText(/The outputs for this job expired on/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView.tsx new file mode 100644 index 0000000000..9c96187285 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/io/JobOutputsView.tsx @@ -0,0 +1,193 @@ +import { Icon, TooltipTrigger } from '@terra-ui-packages/components'; +import React, { useEffect, useState } from 'react'; +import { Metrics } from 'src/libs/ajax/Metrics'; +import { PipelineOutput, PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; +import Events from 'src/libs/events'; +import { useCancellation } from 'src/libs/react-utils'; +import { PipelineErrorMessage } from 'src/pages/scientificServices/pipelines/common/PipelineErrorMessage'; +import { SCIENTIFIC_SERVICES_SUPPORT_EMAIL } from 'src/pages/scientificServices/pipelines/common/scientific-services-common'; +import { PipelineIOTypeBadge } from 'src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineIOTypeBadge'; +import { getOutputFileSize } from 'src/pages/scientificServices/pipelines/utils/download-utils'; + +interface JobOutputsViewProps { + outputDefinitions: PipelineOutput[]; + pipelineRunResult: PipelineRunResponse; +} + +export const JobOutputsView = ({ outputDefinitions, pipelineRunResult }: JobOutputsViewProps) => { + const [loading, setLoading] = useState(true); + const [fileSizes, setFileSizes] = useState>({}); + const signal = useCancellation(); + + const isSucceeded = pipelineRunResult.jobReport.status === 'SUCCEEDED'; + const isFailed = pipelineRunResult.jobReport.status === 'FAILED'; + const outputs = pipelineRunResult.pipelineRunReport.outputs; + const hasOutputs = outputs && Object.keys(outputs).length > 0; + + useEffect(() => { + const fetchOutputFileSizes = async () => { + try { + setLoading(true); + if (pipelineRunResult.pipelineRunReport.outputs) { + const outputs = Object.entries(pipelineRunResult.pipelineRunReport.outputs); + const initialState = outputs.reduce((acc, [key]) => ({ ...acc, [key]: null }), {}); + setFileSizes(initialState); + + for (const [key, url] of outputs) { + try { + const size = await getOutputFileSize(url); + setFileSizes((prev) => ({ ...prev, [key]: size })); + } catch { + setFileSizes((prev) => ({ ...prev, [key]: 'Unknown size' })); + } + } + } + } finally { + setLoading(false); + } + }; + + fetchOutputFileSizes(); + }, [pipelineRunResult.pipelineRunReport.outputs, signal]); + + const getEmptyMessage = () => { + const now = new Date(); + if ( + isSucceeded && + pipelineRunResult.pipelineRunReport.outputExpirationDate && + now > new Date(pipelineRunResult.pipelineRunReport.outputExpirationDate) + ) { + return `The outputs for this job expired on ${new Date( + pipelineRunResult.pipelineRunReport.outputExpirationDate + ).toLocaleDateString()} and are no longer available.`; + } + if (isSucceeded) { + return ( + + This run does not have any outputs to display. There was either an issue running the pipeline or + retrieving the outputs. Please reload the page, or contact{' '} + + {SCIENTIFIC_SERVICES_SUPPORT_EMAIL} + {' '} + for further assistance. + + } + /> + ); + } + if (pipelineRunResult.jobReport.status === 'RUNNING') { + return 'The job is still in progress. Outputs will be available after the job completes.'; + } + }; + + return ( +
+

Outputs

+ {hasOutputs ? ( +
+ {Object.entries(outputs).map(([key, value]) => { + const outputDefinition = outputDefinitions.find((input) => input.name === key); + + return ( +
+ +
+ ); + })} +
+ ) : ( +
+ {getEmptyMessage()} +
+ )} +
+ ); +}; + +const OutputItem = ({ + label, + url, + tooltip, + outputType, + fileSize, + disabled, + pipelineRunResult, +}: { + label: string; + url: string; + tooltip: string; + outputType: string; + fileSize?: string; + disabled?: boolean; + pipelineRunResult: PipelineRunResponse; +}) => { + return ( +
+
+ + {label} + + +
+
+ {!disabled && ( + + )} + {fileSize && ( + + {disabled ? 'Not available' : fileSize} + + )} +
+
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline.test.tsx new file mode 100644 index 0000000000..06f9edc805 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline.test.tsx @@ -0,0 +1,67 @@ +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; + +import { calculatePipelineRunDuration } from './PipelineRunTimeline'; + +describe('PipelineRunTimeline', () => { + describe('calculatePipelineRunDuration', () => { + it('should return N/A when completed timestamp is missing', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'RUNNING', + submitted: '2024-01-01T10:00:00Z', + completed: undefined, + }, + } as PipelineRunResponse; + + const duration = calculatePipelineRunDuration(mockPipelineRun); + + expect(duration).toBe('N/A'); + }); + + it('should format duration with hours, minutes, and seconds when duration is over 1 hour', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'SUCCEEDED', + submitted: '2024-01-01T10:00:00Z', + completed: '2024-01-01T11:30:45Z', + }, + } as PipelineRunResponse; + + const duration = calculatePipelineRunDuration(mockPipelineRun); + + expect(duration).toBe('1h 30m 45s'); + }); + + it('should format duration with only minutes and seconds when duration is under 1 hour', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'SUCCEEDED', + submitted: '2024-01-01T10:00:00Z', + completed: '2024-01-01T10:15:30Z', + }, + } as PipelineRunResponse; + + const duration = calculatePipelineRunDuration(mockPipelineRun); + + expect(duration).toBe('15m 30s'); + }); + + it('should handle duration less than 1 minute', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'SUCCEEDED', + submitted: '2024-01-01T10:00:00Z', + completed: '2024-01-01T10:00:45Z', + }, + } as PipelineRunResponse; + + const duration = calculatePipelineRunDuration(mockPipelineRun); + + expect(duration).toBe('0m 45s'); + }); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline.tsx new file mode 100644 index 0000000000..397e85051b --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimeline.tsx @@ -0,0 +1,76 @@ +import { Icon } from '@terra-ui-packages/components'; +import React from 'react'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; +import { calculateTimelineEvents } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils'; + +import { PipelineRunTimelineEvent } from './PipelineRunTimelineEvent'; + +interface PipelineRunTimelineProps { + pipelineRunResult: PipelineRunResponse; +} + +export const PipelineRunTimeline = ({ pipelineRunResult }: PipelineRunTimelineProps) => { + const timelineEvents = calculateTimelineEvents(pipelineRunResult); + + return ( +
+
+

Timeline

+
+ + {calculatePipelineRunDuration(pipelineRunResult)} +
+
+ {timelineEvents.map((event, index) => ( + + ))} +
+ ); +}; + +export const calculatePipelineRunDuration = (pipelineRunResult: PipelineRunResponse): string => { + if (!pipelineRunResult.jobReport.submitted || !pipelineRunResult.jobReport.completed) { + return 'N/A'; + } + + const durationMs = + new Date(pipelineRunResult.jobReport.completed).getTime() - + new Date(pipelineRunResult.jobReport.submitted).getTime(); + + const totalSeconds = Math.floor(durationMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + return `${minutes}m ${seconds}s`; +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimelineEvent.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimelineEvent.test.tsx new file mode 100644 index 0000000000..345d038731 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimelineEvent.test.tsx @@ -0,0 +1,45 @@ +import { expect } from '@storybook/test'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { PipelineTimelineEvent } from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils'; +import { renderWithAppContexts as render } from 'src/testing/test-utils'; + +import { PipelineRunTimelineEvent } from './PipelineRunTimelineEvent'; + +describe('PipelineRunTimelineEvent', () => { + it('should render event label', () => { + const event: PipelineTimelineEvent = { + label: 'Quality Checks', + status: 'SUCCEEDED', + moreInfo: 'Input data passed QC checks', + }; + + render(); + + expect(screen.getByText('Quality Checks')).toBeInTheDocument(); + }); + + it('should render event moreInfo when provided', () => { + const event: PipelineTimelineEvent = { + label: 'Quota Charged', + status: 'SUCCEEDED', + moreInfo: '250 samples', + }; + + render(); + + expect(screen.getByText('250 samples')).toBeInTheDocument(); + }); + + it('should render timestamp when provided', () => { + const event: PipelineTimelineEvent = { + label: 'Submitted', + status: 'SUCCEEDED', + timestamp: '2024-01-01T10:00:00Z', + }; + + render(); + + expect(screen.getByText(/1\/1\/2024/)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimelineEvent.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimelineEvent.tsx new file mode 100644 index 0000000000..b74aa89e60 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/PipelineRunTimelineEvent.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import colors from 'src/libs/colors'; +import { + getTimelineEventStatusIcon, + PipelineTimelineEvent, +} from 'src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils'; + +interface PipelineRunTimelineEventProps { + event: PipelineTimelineEvent; + isLast: boolean; +} + +export const PipelineRunTimelineEvent = ({ event, isLast }: PipelineRunTimelineEventProps) => { + return ( +
+
+ {getTimelineEventStatusIcon(event.status)} +
+ +
+
+
+ + {!isLast && ( +
+ )} + +
+
{event.label}
+ {event.timestamp && ( +
+ {new Date(event.timestamp).toLocaleString()} +
+ )} + {event.moreInfo &&
{event.moreInfo}
} +
+
+
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils.test.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils.test.tsx new file mode 100644 index 0000000000..021c7d0fef --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils.test.tsx @@ -0,0 +1,154 @@ +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; + +import { getQuotaEvent, getTerminalEvent } from './pipeline-timeline-utils'; + +describe('pipeline-timeline-utils', () => { + describe('getQuotaEvent', () => { + it('should return Quota event with SUCCEEDED status and amount when quota is charged', () => { + const mockPipelineRun = { + pipelineRunReport: { + quotaConsumed: 250, + inputSizeUnits: 'samples', + }, + jobReport: { + status: 'SUCCEEDED', + }, + } as PipelineRunResponse; + + const quotaEvent = getQuotaEvent(mockPipelineRun); + + expect(quotaEvent).toBeDefined(); + expect(quotaEvent?.status).toBe('SUCCEEDED'); + expect(quotaEvent?.moreInfo).toBe('250 samples'); + }); + + it('should return Quota event with CANCELLED status when pipeline fails', () => { + const mockPipelineRun = { + jobReport: { + status: 'FAILED', + }, + pipelineRunReport: { + quotaConsumed: undefined, + inputSizeUnits: undefined, + }, + } as PipelineRunResponse; + + const quotaEvent = getQuotaEvent(mockPipelineRun); + + expect(quotaEvent).toBeDefined(); + expect(quotaEvent?.status).toBe('CANCELLED'); + expect(quotaEvent?.moreInfo).toBe('No quota charged'); + }); + + it('should return Quota event with PENDING status when pipeline is running without quota', () => { + const mockPipelineRun = { + jobReport: { + status: 'RUNNING', + }, + pipelineRunReport: { + quotaConsumed: undefined, + inputSizeUnits: undefined, + }, + } as PipelineRunResponse; + + const quotaEvent = getQuotaEvent(mockPipelineRun); + + expect(quotaEvent).toBeDefined(); + expect(quotaEvent?.status).toBe('PENDING'); + expect(quotaEvent?.moreInfo).toBe('Quota charges pending'); + }); + + it('should return undefined when expected quota fields arent present', () => { + const mockPipelineRun = { + jobReport: { + status: 'SUCCEEDED', + }, + pipelineRunReport: { + quotaConsumed: undefined, + inputSizeUnits: undefined, + }, + } as PipelineRunResponse; + + const quotaEvent = getQuotaEvent(mockPipelineRun); + + expect(quotaEvent).toBeUndefined(); + }); + + it('should handle different quota units correctly', () => { + const mockPipelineRun = { + pipelineRunReport: { + quotaConsumed: 5, + inputSizeUnits: 'other things', + }, + jobReport: { + status: 'SUCCEEDED', + }, + } as PipelineRunResponse; + + const quotaEvent = getQuotaEvent(mockPipelineRun); + + expect(quotaEvent).toBeDefined(); + expect(quotaEvent?.status).toBe('SUCCEEDED'); + expect(quotaEvent?.moreInfo).toBe('5 other things'); + }); + }); + + describe('getTerminalEvent', () => { + it('should return terminal event with SUCCEEDED status and label when pipeline succeeds', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'SUCCEEDED', + submitted: '2024-01-01T10:00:00Z', + completed: '2024-01-01T11:30:45Z', + }, + } as PipelineRunResponse; + + const terminalEvent = getTerminalEvent(mockPipelineRun); + + expect(terminalEvent).toBeDefined(); + expect(terminalEvent.label).toBe('Pipeline Succeeded'); + expect(terminalEvent.status).toBe('SUCCEEDED'); + expect(terminalEvent.timestamp).toBe('2024-01-01T11:30:45Z'); + expect(terminalEvent.moreInfo).toBeUndefined(); + }); + + it('should return terminal event with FAILED status and label when pipeline fails', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'FAILED', + submitted: '2024-01-01T10:00:00Z', + completed: '2024-01-01T10:05:00Z', + }, + } as PipelineRunResponse; + + const terminalEvent = getTerminalEvent(mockPipelineRun); + + expect(terminalEvent).toBeDefined(); + expect(terminalEvent.label).toBe('Pipeline Failed'); + expect(terminalEvent.status).toBe('FAILED'); + expect(terminalEvent.timestamp).toBe('2024-01-01T10:05:00Z'); + expect(terminalEvent.moreInfo).toBeUndefined(); + }); + + it('should return terminal event with RUNNING status and label when pipeline is running', () => { + const mockPipelineRun = { + jobReport: { + id: 'test-id', + status: 'RUNNING', + submitted: '2024-01-01T10:00:00Z', + completed: undefined, + }, + } as PipelineRunResponse; + + const terminalEvent = getTerminalEvent(mockPipelineRun); + + expect(terminalEvent).toBeDefined(); + expect(terminalEvent.label).toBe('Pipeline Running'); + expect(terminalEvent.status).toBe('RUNNING'); + expect(terminalEvent.timestamp).toBeUndefined(); + expect(terminalEvent.moreInfo).toBe('This pipeline is currently running'); + }); + }); +}); diff --git a/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils.tsx b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils.tsx new file mode 100644 index 0000000000..e005dfa9cc --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/history/details/sections/timeline/pipeline-timeline-utils.tsx @@ -0,0 +1,90 @@ +import { Icon } from '@terra-ui-packages/components'; +import React from 'react'; +import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; +import { + getPipelineStatusColor, + getPipelineStatusIcon, +} from 'src/pages/scientificServices/pipelines/utils/pipeline-style-utils'; + +export interface PipelineTimelineEvent { + label: string; + moreInfo?: string; + status: string; + timestamp?: string; +} + +export const getQuotaEvent = (pipelineRunResult: PipelineRunResponse): PipelineTimelineEvent | undefined => { + const quotaCharged = !!pipelineRunResult.pipelineRunReport.quotaConsumed; + const pipelineFailed = pipelineRunResult.jobReport.status === 'FAILED'; + const pipelineRunning = pipelineRunResult.jobReport.status === 'RUNNING'; + + const label = 'Quota Charged'; + + if (quotaCharged) { + return { + label, + status: 'SUCCEEDED', + moreInfo: `${pipelineRunResult.pipelineRunReport.quotaConsumed} ${pipelineRunResult.pipelineRunReport.inputSizeUnits}`, + }; + } + if (pipelineFailed) { + return { label, status: 'CANCELLED', moreInfo: 'No quota charged' }; + } + if (pipelineRunning) { + return { label, status: 'PENDING', moreInfo: 'Quota charges pending' }; + } +}; + +export const getTerminalEvent = (pipelineRunResult: PipelineRunResponse): PipelineTimelineEvent => { + const pipelineStatus = pipelineRunResult.jobReport.status; + + let label = 'Pipeline Running'; + if (pipelineStatus === 'SUCCEEDED') { + label = 'Pipeline Succeeded'; + } else if (pipelineStatus === 'FAILED') { + label = 'Pipeline Failed'; + } + + return { + label, + timestamp: pipelineRunResult.jobReport.completed, + status: pipelineRunResult.jobReport.status, + moreInfo: pipelineStatus === 'RUNNING' ? 'This pipeline is currently running' : undefined, + }; +}; + +export const calculateTimelineEvents = (pipelineRunResult: PipelineRunResponse): PipelineTimelineEvent[] => { + const events: PipelineTimelineEvent[] = []; + + // Always push a Submitted event + events.push({ + label: 'Submitted', + status: 'SUCCEEDED', + timestamp: pipelineRunResult.jobReport.submitted, + }); + + // Push a quota event, if we have one + const quotaEvent = getQuotaEvent(pipelineRunResult); + if (quotaEvent) { + events.push(quotaEvent); + } + + // Always push a terminal event + events.push(getTerminalEvent(pipelineRunResult)); + + return events; +}; + +export const getTimelineEventStatusIcon = (status: string) => { + if (status === 'CANCELLED') { + return ; + } + if (status === 'PENDING') { + return ; + } + if (status === 'FAILED') { + return ; + } + return getPipelineStatusIcon(status, 20); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/history/modals/ViewOutputsModal.tsx b/src/pages/scientificServices/pipelines/tabs/history/modals/ViewOutputsModal.tsx index 392a1c6219..17cf1d0d1e 100644 --- a/src/pages/scientificServices/pipelines/tabs/history/modals/ViewOutputsModal.tsx +++ b/src/pages/scientificServices/pipelines/tabs/history/modals/ViewOutputsModal.tsx @@ -1,5 +1,5 @@ import { ButtonPrimary, Icon, Modal, Spinner } from '@terra-ui-packages/components'; -import { formatBytes, formatDate } from '@terra-ui-packages/core-utils'; +import { formatDate } from '@terra-ui-packages/core-utils'; import React, { ReactNode, useEffect, useState } from 'react'; import { Metrics } from 'src/libs/ajax/Metrics'; import { Teaspoons } from 'src/libs/ajax/teaspoons/Teaspoons'; @@ -7,6 +7,7 @@ import { PipelineRunResponse } from 'src/libs/ajax/teaspoons/teaspoons-models'; import Events from 'src/libs/events'; import { useCancellation } from 'src/libs/react-utils'; import { TEASPOONS_FILE_OUTPUT_TTL_DAYS } from 'src/pages/scientificServices/pipelines/common/teaspoons-service-constants'; +import { getOutputFileSize } from 'src/pages/scientificServices/pipelines/utils/download-utils'; /** * Modal component for displaying pipeline outputs @@ -16,16 +17,6 @@ interface OutputsModalProps { onDismiss: () => void; } -const getFileSize = async (url: string): Promise => { - try { - const response = await fetch(url, { method: 'HEAD' }); - const size = response.headers.get('content-length'); - return size ? formatBytes(Number.parseInt(size)) : 'Unknown size'; - } catch { - return 'Unknown size'; - } -}; - export const ViewOutputsModal = ({ jobId, onDismiss }: OutputsModalProps): ReactNode => { const [result, setResult] = useState(); const [loading, setLoading] = useState(true); @@ -46,7 +37,7 @@ export const ViewOutputsModal = ({ jobId, onDismiss }: OutputsModalProps): React for (const [key, url] of outputs) { try { - const size = await getFileSize(url); + const size = await getOutputFileSize(url); setFileSizes((prev) => ({ ...prev, [key]: size })); } catch { setFileSizes((prev) => ({ ...prev, [key]: 'Unknown size' })); diff --git a/src/pages/scientificServices/pipelines/tabs/run/RunJob.tsx b/src/pages/scientificServices/pipelines/tabs/run/RunJob.tsx index 8d6ec2cf09..3543114667 100644 --- a/src/pages/scientificServices/pipelines/tabs/run/RunJob.tsx +++ b/src/pages/scientificServices/pipelines/tabs/run/RunJob.tsx @@ -401,10 +401,13 @@ export const RunJob = () => {
Your job has been submitted. You can check the status of that job by going to the{' '} - - Job History + + Job Details {' '} - tab. + page.
Job ID: {submittedJobId} diff --git a/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineIOTypeBadge.tsx b/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineIOTypeBadge.tsx new file mode 100644 index 0000000000..20bea26c97 --- /dev/null +++ b/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineIOTypeBadge.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { PipelineIOType } from 'src/libs/ajax/teaspoons/teaspoons-models'; + +interface PipelineIOTypeBadgeProps { + type: PipelineIOType | string; +} + +const getTypeColor = (type: PipelineIOType | string): string => { + switch (type.toUpperCase()) { + case 'FILE': + return '#e7f3fb'; + case 'STRING': + return '#f0e7fb'; + case 'FLOAT': + return '#fff3cd'; + case 'BOOLEAN': + return '#d4edda'; + default: + return '#e4e5e6'; + } +}; + +export const PipelineIOTypeBadge = ({ type }: PipelineIOTypeBadgeProps) => { + return ( +
+ + {type.toLowerCase()} + +
+ ); +}; diff --git a/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineOutputsWidget.tsx b/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineOutputsWidget.tsx index 8c0cdee86f..2255d353e0 100644 --- a/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineOutputsWidget.tsx +++ b/src/pages/scientificServices/pipelines/tabs/run/widgets/PipelineOutputsWidget.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { PipelineOutput, PipelineWithDetails } from 'src/libs/ajax/teaspoons/teaspoons-models'; import { cond, DEFAULT } from 'src/libs/utils'; +import { PipelineIOTypeBadge } from './PipelineIOTypeBadge'; import { PipelineWidgetContainer } from './PipelineWidgetContainer'; export const PipelineOutputsWidget = ({ @@ -47,26 +48,7 @@ const OutputDetails = ({ output }: { output: PipelineOutput }) => {
{displayName || name}
-
- - {type.toLowerCase()} - -
+
{description || 'No description available'}
diff --git a/src/pages/scientificServices/pipelines/utils/AoUStylizedString.tsx b/src/pages/scientificServices/pipelines/utils/AoUStylizedString.tsx index 63a730f570..8739fef6cd 100644 --- a/src/pages/scientificServices/pipelines/utils/AoUStylizedString.tsx +++ b/src/pages/scientificServices/pipelines/utils/AoUStylizedString.tsx @@ -27,7 +27,11 @@ export const AoUStylizedString: React.FC = ({ text }) => { ); } - return {part}; + return ( + + {part} + + ); })} ); diff --git a/src/pages/scientificServices/pipelines/utils/download-utils.ts b/src/pages/scientificServices/pipelines/utils/download-utils.ts new file mode 100644 index 0000000000..ea477054b1 --- /dev/null +++ b/src/pages/scientificServices/pipelines/utils/download-utils.ts @@ -0,0 +1,11 @@ +import { formatBytes } from '@terra-ui-packages/core-utils'; + +export const getOutputFileSize = async (url: string): Promise => { + try { + const response = await fetch(url, { method: 'HEAD' }); + const size = response.headers.get('content-length'); + return size ? formatBytes(Number.parseInt(size)) : 'Unknown size'; + } catch { + return 'Unknown size'; + } +}; diff --git a/src/pages/scientificServices/pipelines/utils/mock-utils.ts b/src/pages/scientificServices/pipelines/utils/mock-utils.ts index 9dfc3eef40..137a2e8888 100644 --- a/src/pages/scientificServices/pipelines/utils/mock-utils.ts +++ b/src/pages/scientificServices/pipelines/utils/mock-utils.ts @@ -3,6 +3,7 @@ import { PipelineInput, PipelineOutput, PipelineRun, + PipelineRunResponse, PipelineRunStatus, PipelineWithDetails, UserPipelineQuotaDetails, @@ -109,3 +110,19 @@ export function mockPipelineRun(status: PipelineRunStatus): PipelineRun { status === 'SUCCEEDED' ? new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString() : undefined, // job outputs expire in 14 days }; } + +export const mockPipelineRunResponse = (status: PipelineRunStatus, description?: string): PipelineRunResponse => ({ + jobReport: { + id: 'job-123-456-789', + status, + submitted: '2024-01-01T00:00:00Z', + completed: '2024-01-01T01:00:00Z', + description: description || undefined, + }, + pipelineRunReport: { + pipelineName: 'array_imputation', + pipelineVersion: 1, + toolVersion: '1.0.0', + outputs: {}, + }, +}); diff --git a/src/pages/scientificServices/pipelines/utils/pipeline-style-utils.tsx b/src/pages/scientificServices/pipelines/utils/pipeline-style-utils.tsx new file mode 100644 index 0000000000..5ff85e2b97 --- /dev/null +++ b/src/pages/scientificServices/pipelines/utils/pipeline-style-utils.tsx @@ -0,0 +1,41 @@ +import { Icon } from '@terra-ui-packages/components'; +import React, { ReactNode } from 'react'; +import { PipelineRun, PipelineRunStatus } from 'src/libs/ajax/teaspoons/teaspoons-models'; +import colors from 'src/libs/colors'; + +export const getPipelineStatusColor = (status: PipelineRunStatus): string => { + switch (status) { + case 'SUCCEEDED': + return colors.success(); + case 'RUNNING': + return colors.accent(); + case 'FAILED': + return colors.danger(); + default: + return colors.accent(); + } +}; + +export const getPipelineStatusIcon = (status: PipelineRunStatus | string, size = 16): ReactNode => { + switch (status) { + case 'SUCCEEDED': + return ; + case 'RUNNING': + return ; + case 'PREPARING': + return ; + case 'FAILED': + return ; + default: + return null; + } +}; + +export const getPipelineColor = (pipelineRun: PipelineRun): string => { + switch (pipelineRun.pipelineName) { + case 'array_imputation': + return '#4D72AA4D'; + default: + return '#AA4D8B4D'; + } +};