Skip to content

Commit

Permalink
feat(app, app-shell): add download progress to robot update flows (#1…
Browse files Browse the repository at this point in the history
…3915)

* feat(app): add download progress to robot update flows

If the robot update cache files are still downloading, no longer is an error thrown. Instead, the
update modal renders and a downloading stage of the update flow begins. Additionally, release files
are downloaded and returned to the browser layer separately from system files, so users may see
release notes as soon as they are downloaded.
  • Loading branch information
mjhuff committed Nov 8, 2023
1 parent d826bfb commit 8a565c0
Show file tree
Hide file tree
Showing 14 changed files with 470 additions and 158 deletions.
23 changes: 15 additions & 8 deletions app-shell/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,22 @@ export function postFile(
init?: RequestInit,
progress?: (progress: number) => void
): Promise<Response> {
return createReadStream(source, progress ?? null).then(readStream => {
const body = new FormData()
body.append(name, readStream)
return fetch(input, { ...init, body, method: 'POST' })
return new Promise<Response>((resolve, reject) => {
createReadStream(source, progress ?? null, reject).then(readStream => {
return new Promise<Response>(resolve => {
const body = new FormData()
body.append(name, readStream)
resolve(fetch(input, { ...init, body, method: 'POST' }))
}).then(resolve)
})
})
}

function createReadStreamWithSize(
source: string,
size: number,
progress: ((progress: number) => void) | null
progress: ((progress: number) => void) | null,
onError: (error: unknown) => unknown
): Promise<Readable> {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(source)
Expand All @@ -125,6 +130,7 @@ function createReadStreamWithSize(
}

readStream.once('error', handleError)
readStream.once('error', onError)

function handleSuccess(): void {
resolve(readStream)
Expand All @@ -142,12 +148,13 @@ function createReadStreamWithSize(
// create a read stream, handling errors that `fetch` is unable to catch
function createReadStream(
source: string,
progress: ((progress: number) => void) | null
progress: ((progress: number) => void) | null,
onError: (error: unknown) => unknown
): Promise<Readable> {
return fsPromises
.stat(source)
.then(filestats =>
createReadStreamWithSize(source, filestats.size, progress)
createReadStreamWithSize(source, filestats.size, progress, onError)
)
.catch(() => createReadStreamWithSize(source, Infinity, progress))
.catch(() => createReadStreamWithSize(source, Infinity, progress, onError))
}
34 changes: 25 additions & 9 deletions app-shell/src/robot-update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,28 @@ export function registerRobotUpdate(dispatch: Dispatch): Dispatch {

case 'robotUpdate:READ_USER_FILE': {
const { systemFile } = action.payload as { systemFile: string }
readFileAndDispatchInfo(dispatch, systemFile, true)
break
return readFileAndDispatchInfo(dispatch, systemFile, true)
}

case 'robotUpdate:READ_SYSTEM_FILE': {
const { target } = action.payload
const filename = updateSet[target]?.system

if (filename == null) {
return dispatch({
type: 'robotUpdate:UNEXPECTED_ERROR',
payload: { message: 'Robot update file not downloaded' },
})
if (checkingForUpdates) {
dispatch({
type: 'robotUpdate:CHECKING_FOR_UPDATE',
payload: target,
})
} else {
// If the file was downloaded but deleted from robot-update-cache.
dispatch({
type: 'robotUpdate:UNEXPECTED_ERROR',
payload: { message: 'Robot update file not downloaded' },
})
}
} else {
readFileAndDispatchInfo(dispatch, filename)
return readFileAndDispatchInfo(dispatch, filename)
}
}
}
Expand Down Expand Up @@ -213,7 +221,7 @@ export function checkForRobotUpdate(
const handleProgress = (progress: DownloadProgress): void => {
const { downloaded, size } = progress
if (size !== null) {
const percentDone = Math.round(downloaded / size) * 100
const percentDone = Math.round((downloaded / size) * 100)
if (percentDone - prevPercentDone > 0) {
dispatch({
type: 'robotUpdate:DOWNLOAD_PROGRESS',
Expand All @@ -227,7 +235,15 @@ export function checkForRobotUpdate(
const targetDownloadDir = cacheDirForMachineFiles(target)

return ensureDir(targetDownloadDir)
.then(() => getReleaseFiles(urls, targetDownloadDir, handleProgress))
.then(() =>
getReleaseFiles(
urls,
targetDownloadDir,
dispatch,
target,
handleProgress
)
)
.then(filepaths => cacheUpdateSet(filepaths, target))
.then(updateInfo =>
dispatch({ type: 'robotUpdate:UPDATE_INFO', payload: updateInfo })
Expand Down
79 changes: 55 additions & 24 deletions app-shell/src/robot-update/release-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import assert from 'assert'
import path from 'path'
import { promisify } from 'util'
import tempy from 'tempy'
import { move, readdir, remove } from 'fs-extra'
import { move, readdir, remove, readFile } from 'fs-extra'
import StreamZip from 'node-stream-zip'
import getStream from 'get-stream'

import { RobotUpdateTarget } from '@opentrons/app/src/redux/robot-update/types'

import { createLogger } from '../log'
import { fetchToFile } from '../http'
import { Dispatch } from '../types'
import { CURRENT_VERSION } from '../update'

import type { DownloadProgress } from '../http'
import type { ReleaseSetUrls, ReleaseSetFilepaths, UserFileInfo } from './types'

Expand All @@ -23,6 +28,8 @@ const outPath = (dir: string, url: string): string =>
export function getReleaseFiles(
urls: ReleaseSetUrls,
directory: string,
dispatch: Dispatch,
target: RobotUpdateTarget,
onProgress: (progress: DownloadProgress) => unknown
): Promise<ReleaseSetFilepaths> {
return readdir(directory)
Expand All @@ -44,41 +51,65 @@ export function getReleaseFiles(
return { system, releaseNotes }
}

return downloadReleaseFiles(urls, directory, onProgress)
return Promise.all([
downloadAndNotify(true, urls.releaseNotes, directory, dispatch, target),
downloadAndNotify(
false,
urls.system,
directory,
dispatch,
target,
onProgress
),
]).then(([releaseNotes, system]) => ({ releaseNotes, system }))
})
}

// downloads the entire release set to a temporary directory, and once they're
// all successfully downloaded, renames the directory to `directory`
// downloads robot update files to a temporary directory, and once
// successfully downloaded, renames the directory to `directory`
// TODO(mc, 2019-07-09): DRY this up if/when more than 2 files are required
export function downloadReleaseFiles(
urls: ReleaseSetUrls,
export function downloadAndNotify(
isReleaseNotesDownload: boolean,
url: ReleaseSetUrls['releaseNotes' | 'system'],
directory: string,
dispatch: Dispatch,
target: RobotUpdateTarget,
// `onProgress` will be called with download progress as the files are read
onProgress: (progress: DownloadProgress) => unknown
): Promise<ReleaseSetFilepaths> {
onProgress?: (progress: DownloadProgress) => unknown
): Promise<string> {
const tempDir: string = tempy.directory()
const tempSystemPath = outPath(tempDir, urls.system)
const tempNotesPath = outPath(tempDir, urls.releaseNotes)
const tempPath = outPath(tempDir, url)
const path = outPath(directory, tempPath)
const logMessage = isReleaseNotesDownload ? 'release notes' : 'system files'

log.debug('directory created for robot update downloads', { tempDir })
log.debug('directory created for ' + logMessage, { tempDir })

// downloads are streamed directly to the filesystem to avoid loading them
// all into memory simultaneously
const systemReq = fetchToFile(urls.system, tempSystemPath, { onProgress })
const notesReq = fetchToFile(urls.releaseNotes, tempNotesPath)

return Promise.all([systemReq, notesReq]).then(results => {
const [systemTemp, releaseNotesTemp] = results
const systemPath = outPath(directory, systemTemp)
const notesPath = outPath(directory, releaseNotesTemp)

log.debug('renaming directory', { from: tempDir, to: directory })
const req = fetchToFile(url, tempPath, {
onProgress,
})

return move(tempDir, directory, { overwrite: true }).then(() => ({
system: systemPath,
releaseNotes: notesPath,
}))
return req.then(() => {
return move(tempPath, path, { overwrite: true })
.then(() => {
if (isReleaseNotesDownload) {
return readFile(path, 'utf8').then(releaseNotes =>
dispatch({
type: 'robotUpdate:UPDATE_INFO',
payload: { releaseNotes, target, version: CURRENT_VERSION },
})
)
}
// This action will only have an effect if the user is actively waiting for the download to complete.
else {
return dispatch({
type: 'robotUpdate:DOWNLOAD_DONE',
payload: target,
})
}
})
.then(() => path)
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { css } from 'styled-components'

import {
Expand All @@ -23,11 +23,13 @@ import { FOOTER_BUTTON_STYLE } from './UpdateRobotModal'
import {
startRobotUpdate,
clearRobotUpdateSession,
getRobotUpdateDownloadError,
} from '../../../../redux/robot-update'
import { useRobotUpdateInfo } from './useRobotUpdateInfo'
import successIcon from '../../../../assets/images/icon_success.png'

import type { SetStatusBarCreateCommand } from '@opentrons/shared-data'
import type { State } from '../../../../redux/types'
import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol'
import type { RobotUpdateSession } from '../../../../redux/robot-update/types'
import type { UpdateStep } from './useRobotUpdateInfo'

Expand Down Expand Up @@ -66,8 +68,15 @@ export function RobotUpdateProgressModal({
const completeRobotUpdateHandler = (): void => {
if (closeUpdateBuildroot != null) closeUpdateBuildroot()
}
const { error } = session || { error: null }
const { updateStep, progressPercent } = useRobotUpdateInfo(session)

const { updateStep, progressPercent } = useRobotUpdateInfo(robotName, session)

let { error } = session || { error: null }
const downloadError = useSelector((state: State) =>
getRobotUpdateDownloadError(state, robotName)
)
if (error == null && downloadError != null) error = downloadError

useStatusBarAnimation(error != null)
useCleanupRobotUpdateSessionOnDismount()

Expand All @@ -89,11 +98,27 @@ export function RobotUpdateProgressModal({
progressPercent
)

let modalBodyText = t('installing_update')
let modalBodyText = ''
let subProgressBarText = t('do_not_turn_off')
if (updateStep === 'restart') modalBodyText = t('restarting_robot')
if (updateStep === 'restart' && letUserExitUpdate) {
subProgressBarText = t('restart_taking_too_long', { robotName })
switch (updateStep) {
case 'initial':
case 'error':
modalBodyText = ''
break
case 'download':
modalBodyText = t('downloading_update')
break
case 'install':
modalBodyText = t('installing_update')
break
case 'restart':
modalBodyText = t('restarting_robot')
if (letUserExitUpdate) {
subProgressBarText = t('restart_taking_too_long', { robotName })
}
break
default:
modalBodyText = t('installing_update')
}

return (
Expand Down Expand Up @@ -209,7 +234,7 @@ function SuccessOrError({ errorMessage }: SuccessOrErrorProps): JSX.Element {
export const TIME_BEFORE_ALLOWING_EXIT_MS = 600000 // 10 mins

function useAllowExitIfUpdateStalled(
updateStep: UpdateStep,
updateStep: UpdateStep | null,
progressPercent: number
): boolean {
const [letUserExitUpdate, setLetUserExitUpdate] = React.useState<boolean>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
TIME_BEFORE_ALLOWING_EXIT_MS,
} from '../RobotUpdateProgressModal'
import { useRobotUpdateInfo } from '../useRobotUpdateInfo'
import { getRobotSessionIsManualFile } from '../../../../../redux/robot-update'
import {
getRobotSessionIsManualFile,
getRobotUpdateDownloadError,
} from '../../../../../redux/robot-update'
import { useDispatchStartRobotUpdate } from '../../../../../redux/robot-update/hooks'

import type { SetStatusBarCreateCommand } from '@opentrons/shared-data'
Expand All @@ -31,6 +34,9 @@ const mockGetRobotSessionIsManualFile = getRobotSessionIsManualFile as jest.Mock
const mockUseDispatchStartRobotUpdate = useDispatchStartRobotUpdate as jest.MockedFunction<
typeof useDispatchStartRobotUpdate
>
const mockGetRobotUpdateDownloadError = getRobotUpdateDownloadError as jest.MockedFunction<
typeof getRobotUpdateDownloadError
>

const render = (
props: React.ComponentProps<typeof RobotUpdateProgressModal>
Expand Down Expand Up @@ -71,12 +77,20 @@ describe('DownloadUpdateModal', () => {
})
mockGetRobotSessionIsManualFile.mockReturnValue(false)
mockUseDispatchStartRobotUpdate.mockReturnValue(jest.fn)
mockGetRobotUpdateDownloadError.mockReturnValue(null)
})

afterEach(() => {
jest.resetAllMocks()
})

it('renders robot update download errors', () => {
mockGetRobotUpdateDownloadError.mockReturnValue('test download error')

const [{ getByText }] = render(props)
getByText('test download error')
})

it('renders the robot name as a part of the header', () => {
const [{ getByText }] = render(props)

Expand Down
Loading

0 comments on commit 8a565c0

Please sign in to comment.