diff --git a/api-client/src/runs/commands/getRunCommandErrors.ts b/api-client/src/runs/commands/getRunCommandErrors.ts new file mode 100644 index 00000000000..0f961e1a892 --- /dev/null +++ b/api-client/src/runs/commands/getRunCommandErrors.ts @@ -0,0 +1,19 @@ +import { GET, request } from '../../request' + +import type { ResponsePromise } from '../../request' +import type { HostConfig } from '../../types' +import type { GetCommandsParams, RunCommandErrors } from '../types' + +export function getRunCommandErrors( + config: HostConfig, + runId: string, + params: GetCommandsParams +): ResponsePromise { + return request( + GET, + `/runs/${runId}/commandErrors`, + null, + config, + params + ) +} diff --git a/api-client/src/runs/commands/types.ts b/api-client/src/runs/commands/types.ts index 1bcdadcc15f..cd18924201c 100644 --- a/api-client/src/runs/commands/types.ts +++ b/api-client/src/runs/commands/types.ts @@ -1,10 +1,15 @@ -import type { RunTimeCommand } from '@opentrons/shared-data' +import type { RunTimeCommand, RunCommandError } from '@opentrons/shared-data' export interface GetCommandsParams { cursor: number | null // the index of the command at the center of the window pageLength: number // the number of items to include } +export interface RunCommandErrors { + data: RunCommandError[] + meta: GetCommandsParams & { totalLength: number } +} + // NOTE: this incantation allows us to omit a key from each item in a union distributively // this means we can, for example, maintain the associated commandType and params after the Omit is applied type DistributiveOmit = T extends any ? Omit : never diff --git a/api-client/src/runs/index.ts b/api-client/src/runs/index.ts index 02bf0c0e036..9f314f4b025 100644 --- a/api-client/src/runs/index.ts +++ b/api-client/src/runs/index.ts @@ -9,6 +9,7 @@ export { getCommand } from './commands/getCommand' export { getCommands } from './commands/getCommands' export { getCommandsAsPreSerializedList } from './commands/getCommandsAsPreSerializedList' export { createRunAction } from './createRunAction' +export { getRunCommandErrors } from './commands/getRunCommandErrors' export * from './createLabwareOffset' export * from './createLabwareDefinition' export * from './constants' diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 5d9821cc5a6..809e1253620 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -23,6 +23,7 @@ import { useDoorQuery, useHost, useInstrumentsQuery, + useRunCommandErrors, } from '@opentrons/react-api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { @@ -112,7 +113,12 @@ import { ProtocolDropTipModal, } from './ProtocolDropTipModal' -import type { Run, RunError, RunStatus } from '@opentrons/api-client' +import type { + Run, + RunCommandErrors, + RunError, + RunStatus, +} from '@opentrons/api-client' import type { IconName } from '@opentrons/components' import type { State } from '../../../redux/types' import type { HeaterShakerModule } from '../../../redux/modules/types' @@ -166,6 +172,13 @@ export function ProtocolRunHeader({ const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) const [showRunFailedModal, setShowRunFailedModal] = React.useState(false) + const { data: commandErrorList } = useRunCommandErrors(runId, null, { + enabled: + runStatus != null && + // @ts-expect-error runStatus expected to possibly not be terminal + RUN_STATUSES_TERMINAL.includes(runStatus) && + isRunCurrent, + }) const [showDropTipBanner, setShowDropTipBanner] = React.useState(true) const isResetRunLoadingRef = React.useRef(false) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) @@ -258,7 +271,6 @@ export function ProtocolRunHeader({ // Side effects dependent on the current run state. React.useEffect(() => { - // After a user-initiated stopped run, close the run current run automatically. if (runStatus === RUN_STATUS_STOPPED && isRunCurrent && runId != null) { trackProtocolRunEvent({ name: ANALYTICS_PROTOCOL_RUN_ACTION.FINISH, @@ -266,9 +278,8 @@ export function ProtocolRunHeader({ ...robotAnalyticsData, }, }) - closeCurrentRun() } - }, [runStatus, isRunCurrent, runId, closeCurrentRun]) + }, [runStatus, isRunCurrent, runId]) const startedAtTimestamp = startedAt != null ? formatTimestamp(startedAt) : EMPTY_TIMESTAMP @@ -333,6 +344,7 @@ export function ProtocolRunHeader({ runId={runId} setShowRunFailedModal={setShowRunFailedModal} highestPriorityError={highestPriorityError} + commandErrorList={commandErrorList} /> ) : null} void isClosingCurrentRun: boolean setShowRunFailedModal: (showRunFailedModal: boolean) => void + commandErrorList?: RunCommandErrors isResetRunLoading: boolean isRunCurrent: boolean highestPriorityError?: RunError | null @@ -879,6 +893,7 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { handleClearClick, isClosingCurrentRun, setShowRunFailedModal, + commandErrorList, highestPriorityError, isResetRunLoading, isRunCurrent, @@ -914,10 +929,12 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { - {t('error_info', { - errorType: highestPriorityError?.errorType, - errorCode: highestPriorityError?.errorCode, - })} + {highestPriorityError != null + ? t('error_info', { + errorType: highestPriorityError?.errorType, + errorCode: highestPriorityError?.errorCode, + }) + : 'Run completed with errors.'} 0 && + !isResetRunLoading) + ) { return buildErrorBanner() } else { return null diff --git a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index cabf46391be..d193623aaa8 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -23,8 +23,9 @@ import { import { LegacyModal } from '../../../molecules/LegacyModal' import { useDownloadRunLog } from '../hooks' -import type { RunError } from '@opentrons/api-client' +import type { RunError, RunCommandErrors } from '@opentrons/api-client' import type { LegacyModalProps } from '../../../molecules/LegacyModal' +import type { RunCommandError } from '@opentrons/shared-data' /** * This modal is for Desktop app @@ -43,6 +44,7 @@ interface RunFailedModalProps { runId: string setShowRunFailedModal: (showRunFailedModal: boolean) => void highestPriorityError?: RunError | null + commandErrorList?: RunCommandErrors | null } export function RunFailedModal({ @@ -50,6 +52,7 @@ export function RunFailedModal({ runId, setShowRunFailedModal, highestPriorityError, + commandErrorList, }: RunFailedModalProps): JSX.Element | null { const { i18n, t } = useTranslation(['run_details', 'shared', 'branded']) const modalProps: LegacyModalProps = { @@ -64,7 +67,7 @@ export function RunFailedModal({ } const { downloadRunLog } = useDownloadRunLog(robotName, runId) - if (highestPriorityError == null) return null + if (highestPriorityError == null && commandErrorList == null) return null const handleClick = (): void => { setShowRunFailedModal(false) @@ -76,20 +79,56 @@ export function RunFailedModal({ downloadRunLog() } - return ( - - + interface ErrorContentProps { + errors: RunCommandError[] + isSingleError: boolean + } + const ErrorContent = ({ + errors, + isSingleError, + }: ErrorContentProps): JSX.Element => { + return ( + <> - {t('error_info', { - errorType: highestPriorityError.errorType, - errorCode: highestPriorityError.errorCode, - })} + {isSingleError + ? t('error_info', { + errorType: errors[0].errorType, + errorCode: errors[0].errorCode, + }) + : `${errors.length} errors`} - - {highestPriorityError.detail} - + {' '} + {errors.map((error, index) => ( + + {' '} + {isSingleError + ? error.detail + : `${error.errorCode}: ${error.detail}`} + + ))} + + ) + } + + return ( + + + 0 + ? commandErrorList?.data + : [] + } + isSingleError={!!highestPriorityError} + /> {t('branded:run_failed_modal_description_desktop')} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 872dff5771f..45f84024b49 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -23,6 +23,7 @@ import { useEstopQuery, useDoorQuery, useInstrumentsQuery, + useRunCommandErrors, } from '@opentrons/react-api-client' import { getPipetteModelSpecs, @@ -183,6 +184,25 @@ const PROTOCOL_DETAILS = { robotType: 'OT-2 Standard' as const, } +const RUN_COMMAND_ERRORS = { + data: { + data: [ + { + errorCode: '4000', + errorType: 'test', + isDefined: false, + createdAt: '9-9-9', + detail: 'blah blah', + id: '123', + }, + ], + meta: { + cursor: 0, + pageLength: 1, + }, + }, +} as any + const mockMovingHeaterShaker = { id: 'heatershaker_id', moduleModel: 'heaterShakerModuleV1', @@ -364,6 +384,7 @@ describe('ProtocolRunHeader', () => { ...noModulesProtocol, ...MOCK_ROTOCOL_LIQUID_KEY, } as any) + vi.mocked(useRunCommandErrors).mockReturnValue(RUN_COMMAND_ERRORS) vi.mocked(useDeckConfigurationCompatibility).mockReturnValue([]) vi.mocked(getIsFixtureMismatch).mockReturnValue(false) vi.mocked(useMostRecentRunId).mockReturnValue(RUN_ID) @@ -480,7 +501,6 @@ describe('ProtocolRunHeader', () => { data: { data: { ...mockIdleUnstartedRun, current: true } }, } as UseQueryResult) render() - expect(mockCloseCurrentRun).toBeCalled() expect(mockTrackProtocolRunEvent).toBeCalled() expect(mockTrackProtocolRunEvent).toBeCalledWith({ name: ANALYTICS_PROTOCOL_RUN_ACTION.FINISH, @@ -852,7 +872,6 @@ describe('ProtocolRunHeader', () => { render() fireEvent.click(screen.queryAllByTestId('Banner_close-button')[0]) - expect(mockCloseCurrentRun).toBeCalled() }) it('does not display the "run successful" banner if the successful run is not current', async () => { diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx index c4db8a35360..636cf850ffd 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx @@ -20,30 +20,38 @@ import { SmallButton } from '../../../atoms/buttons' import { Modal } from '../../../molecules/Modal' import type { ModalHeaderBaseProps } from '../../../molecules/Modal/types' -import type { RunError } from '@opentrons/api-client' +import type { RunCommandErrors, RunError } from '@opentrons/api-client' + +import type { RunCommandError } from '@opentrons/shared-data' interface RunFailedModalProps { runId: string setShowRunFailedModal: (showRunFailedModal: boolean) => void errors?: RunError[] + commandErrorList?: RunCommandErrors } export function RunFailedModal({ runId, setShowRunFailedModal, errors, + commandErrorList, }: RunFailedModalProps): JSX.Element | null { const { t, i18n } = useTranslation(['run_details', 'shared', 'branded']) const navigate = useNavigate() const { stopRun } = useStopRunMutation() const [isCanceling, setIsCanceling] = React.useState(false) - if (errors == null || errors.length === 0) return null + if ( + (errors == null || errors.length === 0) && + (commandErrorList == null || commandErrorList.data.length === 0) + ) + return null const modalHeader: ModalHeaderBaseProps = { title: t('run_failed_modal_title'), } - const highestPriorityError = getHighestPriorityError(errors) + const highestPriorityError = getHighestPriorityError(errors ?? []) const handleClose = (): void => { setIsCanceling(true) @@ -60,6 +68,54 @@ export function RunFailedModal({ }, }) } + + interface ErrorContentProps { + errors: RunCommandError[] + isSingleError: boolean + } + const ErrorContent = ({ + errors, + isSingleError, + }: ErrorContentProps): JSX.Element => { + return ( + <> + + {isSingleError + ? t('error_info', { + errorType: errors[0].errorType, + errorCode: errors[0].errorCode, + }) + : `${errors.length} errors`} + + + + {' '} + {errors.map((error, index) => ( + + {' '} + {isSingleError + ? error.detail + : `${error.errorCode}: ${error.detail}`} + + ))} + + + + ) + } + return ( - - {t('error_info', { - errorType: highestPriorityError.errorType, - errorCode: highestPriorityError.errorCode, - })} - - - - - {highestPriorityError.detail} - - - - - {t('branded:contact_information')} - + 0 + ? commandErrorList?.data + : [] + } + isSingleError={!!highestPriorityError} + /> + + {t('branded:contact_information')} + ) : null} ( + runId: string | null, + params?: GetCommandsParams | null, + options: UseQueryOptions = {} +): UseQueryResult { + const host = useHost() + const nullCheckedParams = params ?? DEFAULT_PARAMS + + const allOptions: UseQueryOptions = { + ...options, + enabled: host !== null && runId != null && options.enabled !== false, + } + const { cursor, pageLength } = nullCheckedParams + const query = useQuery( + [host, 'runs', runId, 'commandErrors', cursor, pageLength], + () => { + return getRunCommandErrors( + host as HostConfig, + runId as string, + nullCheckedParams + ).then(response => response.data) + }, + allOptions + ) + + return query +}