Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(app, app-shell): add download progress to robot update flows #13915

Merged
merged 3 commits into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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) => {

Check warning on line 93 in app-shell/src/http.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/http.ts#L93

Added line #L93 was not covered by tests
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' }))

Check warning on line 98 in app-shell/src/http.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/http.ts#L95-L98

Added lines #L95 - L98 were not covered by tests
}).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 @@
}

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

Check warning on line 133 in app-shell/src/http.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/http.ts#L133

Added line #L133 was not covered by tests

function handleSuccess(): void {
resolve(readStream)
Expand All @@ -142,12 +148,13 @@
// 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)

Check warning on line 157 in app-shell/src/http.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/http.ts#L157

Added line #L157 was not covered by tests
)
.catch(() => createReadStreamWithSize(source, Infinity, progress))
.catch(() => createReadStreamWithSize(source, Infinity, progress, onError))

Check warning on line 159 in app-shell/src/http.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/http.ts#L159

Added line #L159 was not covered by tests
}
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 @@

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

Check warning on line 140 in app-shell/src/robot-update/index.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/index.ts#L140

Added line #L140 was not covered by tests
}

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({

Check warning on line 149 in app-shell/src/robot-update/index.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/index.ts#L149

Added line #L149 was not covered by tests
type: 'robotUpdate:CHECKING_FOR_UPDATE',
payload: target,
})
} else {
Comment on lines +148 to +153
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition is hit if a user actively starts a robot update, but the file has not yet been downloaded. The CHECKING_FOR_UPDATE action creates an update session for the robot with a downloadFile stage. The UI layer uses this session to alert the user that the file is downloading.

// If the file was downloaded but deleted from robot-update-cache.
dispatch({

Check warning on line 155 in app-shell/src/robot-update/index.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/index.ts#L155

Added line #L155 was not covered by tests
type: 'robotUpdate:UNEXPECTED_ERROR',
payload: { message: 'Robot update file not downloaded' },
})
}
} else {
readFileAndDispatchInfo(dispatch, filename)
return readFileAndDispatchInfo(dispatch, filename)

Check warning on line 161 in app-shell/src/robot-update/index.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/index.ts#L161

Added line #L161 was not covered by tests
}
}
}
Expand Down Expand Up @@ -213,7 +221,7 @@
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)

Check warning on line 224 in app-shell/src/robot-update/index.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/index.ts#L224

Added line #L224 was not covered by tests
if (percentDone - prevPercentDone > 0) {
dispatch({
type: 'robotUpdate:DOWNLOAD_PROGRESS',
Expand All @@ -227,7 +235,15 @@
const targetDownloadDir = cacheDirForMachineFiles(target)

return ensureDir(targetDownloadDir)
.then(() => getReleaseFiles(urls, targetDownloadDir, handleProgress))
.then(() =>
getReleaseFiles(

Check warning on line 239 in app-shell/src/robot-update/index.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/index.ts#L239

Added line #L239 was not covered by tests
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 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 @@
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 @@
return { system, releaseNotes }
}

return downloadReleaseFiles(urls, directory, onProgress)
return Promise.all([

Check warning on line 54 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L54

Added line #L54 was not covered by tests
downloadAndNotify(true, urls.releaseNotes, directory, dispatch, target),
downloadAndNotify(
false,
urls.system,
directory,
dispatch,
target,
onProgress
),
]).then(([releaseNotes, system]) => ({ releaseNotes, system }))

Check warning on line 64 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L64

Added line #L64 was not covered by tests
Comment on lines +54 to +64
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user clicks an the robot update banner while the file is still downloading, there will not be release notes (as seen in the before vide). Let's separate the release notes from the system file download.

If a user somehow navigates to the robot update banner before 20kb have been downloaded, they won't see the notes, but this won't prevent the download.

})
}

// 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)

Check warning on line 82 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L81-L82

Added lines #L81 - L82 were not covered by tests
const logMessage = isReleaseNotesDownload ? 'release notes' : 'system files'

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

Check warning on line 85 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L85

Added line #L85 was not covered by tests

// 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, {

Check warning on line 89 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L89

Added line #L89 was not covered by tests
onProgress,
})

return move(tempDir, directory, { overwrite: true }).then(() => ({
system: systemPath,
releaseNotes: notesPath,
}))
return req.then(() => {
return move(tempPath, path, { overwrite: true })

Check warning on line 94 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L93-L94

Added lines #L93 - L94 were not covered by tests
.then(() => {
if (isReleaseNotesDownload) {
return readFile(path, 'utf8').then(releaseNotes =>
dispatch({

Check warning on line 98 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L97-L98

Added lines #L97 - L98 were not covered by tests
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({

Check warning on line 106 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L106

Added line #L106 was not covered by tests
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This dispatch occurs after the system files are downloaded but not yet cleaned up. The redux store will update the robot's session from "waiting on download to finish" to done if the robot is of the same version as the file that finished downloading.

All the file cleanup can happen asynchronously without any problem.

type: 'robotUpdate:DOWNLOAD_DONE',
payload: target,
})
}
})
.then(() => path)

Check warning on line 112 in app-shell/src/robot-update/release-files.ts

View check run for this annotation

Codecov / codecov/patch

app-shell/src/robot-update/release-files.ts#L112

Added line #L112 was not covered by tests
})
}

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 {
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 @@
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 @@
progressPercent
)

let modalBodyText = t('installing_update')
let modalBodyText = ''
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unless we can prove exhaustion in the if cases after this I think we should give this a meaningful default value

let modalBodyText = t('downloading_update')
if (updateStep === 'install') modalBodyText = t('downloading_update')

or even const it and do a chained ternay assign, or have a function that takes the step and returns the text

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

Check warning on line 107 in app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx

View check run for this annotation

Codecov / codecov/patch

app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx#L106-L107

Added lines #L106 - L107 were not covered by tests
case 'download':
modalBodyText = t('downloading_update')
break

Check warning on line 110 in app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx

View check run for this annotation

Codecov / codecov/patch

app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx#L109-L110

Added lines #L109 - L110 were not covered by tests
case 'install':
modalBodyText = t('installing_update')
break
case 'restart':
modalBodyText = t('restarting_robot')
if (letUserExitUpdate) {
subProgressBarText = t('restart_taking_too_long', { robotName })

Check warning on line 117 in app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx

View check run for this annotation

Codecov / codecov/patch

app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx#L117

Added line #L117 was not covered by tests
}
break
default:
modalBodyText = t('installing_update')
}

return (
Expand Down Expand Up @@ -209,7 +234,7 @@
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
Loading