chore: move flash step to React

Changelog-entry: chore: move flash step to React
Change-type: patch
Signed-off-by: Stevche Radevski <stevche@balena.io>
This commit is contained in:
Stevche Radevski 2019-12-03 11:24:08 +01:00 committed by Alexis Svinartchouk
parent 5cd3c5fcc0
commit 1d15d582d9
10 changed files with 302 additions and 329 deletions

View File

@ -0,0 +1,287 @@
/*
* Copyright 2016 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const React = require('react')
const _ = require('lodash')
const messages = require('../../../../shared/messages')
const flashState = require('../../models/flash-state')
const driveScanner = require('../../modules/drive-scanner')
const progressStatus = require('../../modules/progress-status')
const notification = require('../../os/notification')
const analytics = require('../../modules/analytics')
const imageWriter = require('../../modules/image-writer')
const path = require('path')
const store = require('../../models/store')
const constraints = require('../../../../shared/drive-constraints')
const availableDrives = require('../../models/available-drives')
const selection = require('../../models/selection-state')
const SvgIcon = require('../../components/svg-icon/svg-icon.jsx')
const ProgressButton = require('../../components/progress-button/progress-button.jsx')
const COMPLETED_PERCENTAGE = 100
const SPEED_PRECISION = 2
/**
* @summary Spawn a confirmation warning modal
* @function
* @public
*
* @param {Array<String>} warningMessages - warning messages
* @param {Object} WarningModalService - warning modal service
* @returns {Promise} warning modal promise
*
* @example
* confirmationWarningModal([ 'Hello, World!' ])
*/
const confirmationWarningModal = (warningMessages, WarningModalService) => {
return WarningModalService.display({
confirmationLabel: 'Continue',
rejectionLabel: 'Change',
description: [
warningMessages.join('\n\n'),
'Are you sure you want to continue?'
].join(' ')
})
}
/**
* @summary Display warning tailored to the warning of the current drives-image pair
* @function
* @public
*
* @param {Array<Object>} drives - list of drive objects
* @param {Object} image - image object
* @param {Object} WarningModalService - warning modal service
* @returns {Promise<Boolean>}
*
* @example
* displayTailoredWarning(drives, image).then((ok) => {
* if (ok) {
* console.log('No warning was shown or continue was pressed')
* } else {
* console.log('Change was pressed')
* }
* })
*/
const displayTailoredWarning = async (drives, image, WarningModalService) => {
const warningMessages = []
for (const drive of drives) {
if (constraints.isDriveSizeLarge(drive)) {
warningMessages.push(messages.warning.largeDriveSize(drive))
} else if (!constraints.isDriveSizeRecommended(drive, image)) {
warningMessages.push(messages.warning.unrecommendedDriveSize(image, drive))
}
// TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode
}
if (!warningMessages.length) {
return true
}
return confirmationWarningModal(warningMessages, WarningModalService)
}
/**
* @summary Flash image to drives
* @function
* @public
*
* @param {Object} $timeout - angular's timeout object
* @param {Object} $state - angular's state object
* @param {Object} WarningModalService - warning modal service
* @param {Object} DriveSelectorService - drive selector service
* @param {Object} FlashErrorModalService - flash error modal service
*
* @example
* flashImageToDrive($timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService)
*/
const flashImageToDrive = async ($timeout, $state,
WarningModalService, DriveSelectorService, FlashErrorModalService) => {
const devices = selection.getSelectedDevices()
const image = selection.getImage()
const drives = _.filter(availableDrives.getDrives(), (drive) => {
return _.includes(devices, drive.device)
})
// eslint-disable-next-line no-magic-numbers
if (drives.length === 0) {
return
}
const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image)
if (hasDangerStatus) {
if (!(await displayTailoredWarning(drives, image, WarningModalService))) {
DriveSelectorService.open()
return
}
}
if (flashState.isFlashing()) {
return
}
// Trigger Angular digests along with store updates, as the flash state
// updates. The angular components won't update without it.
// TODO: Remove once moved entirely to React
const unsubscribe = store.observe($timeout)
// Stop scanning drives when flashing
// otherwise Windows throws EPERM
driveScanner.stop()
const iconPath = '../../../assets/icon.png'
const basename = path.basename(image.path)
try {
await imageWriter.flash(image.path, drives)
if (!flashState.wasLastFlashCancelled()) {
const flashResults = flashState.getFlashResults()
notification.send('Flash complete!', {
body: messages.info.flashComplete(basename, drives, flashResults.results.devices),
icon: iconPath
})
$state.go('success')
}
} catch (error) {
// When flashing is cancelled before starting above there is no error
if (!error) {
return
}
notification.send('Oops! Looks like the flash failed.', {
body: messages.error.flashFailure(path.basename(image.path), drives),
icon: iconPath
})
// TODO: All these error codes to messages translations
// should go away if the writer emitted user friendly
// messages on the first place.
if (error.code === 'EVALIDATION') {
FlashErrorModalService.show(messages.error.validation())
} else if (error.code === 'EUNPLUGGED') {
FlashErrorModalService.show(messages.error.driveUnplugged())
} else if (error.code === 'EIO') {
FlashErrorModalService.show(messages.error.inputOutput())
} else if (error.code === 'ENOSPC') {
FlashErrorModalService.show(messages.error.notEnoughSpaceInDrive())
} else if (error.code === 'ECHILDDIED') {
FlashErrorModalService.show(messages.error.childWriterDied())
} else {
FlashErrorModalService.show(messages.error.genericFlashError())
error.image = basename
analytics.logException(error)
}
} finally {
availableDrives.setDrives([])
driveScanner.start()
unsubscribe()
}
}
/**
* @summary Get progress button label
* @function
* @public
*
* @returns {String} progress button label
*
* @example
* const label = FlashController.getProgressButtonLabel()
*/
const getProgressButtonLabel = () => {
if (!flashState.isFlashing()) {
return 'Flash!'
}
return progressStatus.fromFlashState(flashState.getFlashState())
}
const formatSeconds = (totalSeconds) => {
if (!totalSeconds && !_.isNumber(totalSeconds)) {
return ''
}
// eslint-disable-next-line no-magic-numbers
const minutes = Math.floor(totalSeconds / 60)
// eslint-disable-next-line no-magic-numbers
const seconds = Math.floor(totalSeconds - minutes * 60)
return `${minutes}m${seconds}s`
}
const Flash = ({
shouldFlashStepBeDisabled, lastFlashErrorCode, progressMessage,
$timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService
}) => {
// This is a hack to re-render the component whenever the global state changes. Remove once we get rid of angular and use redux correctly.
// eslint-disable-next-line no-magic-numbers
const setRefresh = React.useState(false)[1]
const state = flashState.getFlashState()
const isFlashing = flashState.isFlashing()
const isFlashStepDisabled = shouldFlashStepBeDisabled()
const flashErrorCode = lastFlashErrorCode()
React.useEffect(() => {
return store.observe(() => {
setRefresh((ref) => !ref)
})
}, [])
return <div className="box text-center">
<div className="center-block">
<SvgIcon paths={[ '../../assets/flash.svg' ]} disabled={isFlashStepDisabled}/>
</div>
<div className="space-vertical-large">
<ProgressButton
tabindex="3"
striped={state.type === 'verifying'}
active={isFlashing}
percentage={state.percentage}
label={getProgressButtonLabel()}
disabled={Boolean(flashErrorCode) || isFlashStepDisabled}
callback={() =>
flashImageToDrive($timeout, $state, WarningModalService, DriveSelectorService, FlashErrorModalService)}>
</ProgressButton>
{
isFlashing && <button className="button button-link button-abort-write" onClick={imageWriter.cancel}>
<span className="glyphicon glyphicon-remove-sign"></span>
</button>
}
{
!_.isNil(state.speed) && state.percentage !== COMPLETED_PERCENTAGE &&
<p className="step-footer step-footer-split">
{Boolean(state.speed) && <span >{`${state.speed.toFixed(SPEED_PRECISION)} MB/s`}</span>}
{!_.isNil(state.eta) && <span>{`ETA: ${formatSeconds(state.eta)}` }</span>}
</p>
}
{
Boolean(state.failed) && <div className="target-status-wrap">
<div className="target-status-line target-status-failed">
<span className="target-status-dot"></span>
<span className="target-status-quantity">{state.failed}</span>
<span className="target-status-message">{progressMessage.failed(state.failed)} </span>
</div>
</div>
}
</div>
</div>
}
module.exports = Flash

View File

@ -1,222 +0,0 @@
/*
* Copyright 2016 resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict'
const _ = require('lodash')
const messages = require('../../../../../shared/messages')
const flashState = require('../../../models/flash-state')
const driveScanner = require('../../../modules/drive-scanner')
const progressStatus = require('../../../modules/progress-status')
const notification = require('../../../os/notification')
const analytics = require('../../../modules/analytics')
const imageWriter = require('../../../modules/image-writer')
const path = require('path')
const store = require('../../../models/store')
const constraints = require('../../../../../shared/drive-constraints')
const availableDrives = require('../../../models/available-drives')
const selection = require('../../../models/selection-state')
module.exports = function (
$state,
$timeout,
FlashErrorModalService,
WarningModalService,
DriveSelectorService
) {
/**
* @summary Spawn a confirmation warning modal
* @function
* @public
*
* @param {Array<String>} warningMessages - warning messages
* @returns {Promise} warning modal promise
*
* @example
* confirmationWarningModal([ 'Hello, World!' ])
*/
const confirmationWarningModal = (warningMessages) => {
return WarningModalService.display({
confirmationLabel: 'Continue',
rejectionLabel: 'Change',
description: [
warningMessages.join('\n\n'),
'Are you sure you want to continue?'
].join(' ')
})
}
/**
* @summary Display warning tailored to the warning of the current drives-image pair
* @function
* @public
*
* @param {Array<Object>} drives - list of drive objects
* @param {Object} image - image object
* @returns {Promise<Boolean>}
*
* @example
* displayTailoredWarning(drives, image).then((ok) => {
* if (ok) {
* console.log('No warning was shown or continue was pressed')
* } else {
* console.log('Change was pressed')
* }
* })
*/
const displayTailoredWarning = async (drives, image) => {
const warningMessages = []
for (const drive of drives) {
if (constraints.isDriveSizeLarge(drive)) {
warningMessages.push(messages.warning.largeDriveSize(drive))
} else if (!constraints.isDriveSizeRecommended(drive, image)) {
warningMessages.push(messages.warning.unrecommendedDriveSize(image, drive))
}
// TODO(Shou): we should consider adding the same warning dialog for system drives and remove unsafe mode
}
if (!warningMessages.length) {
return true
}
return confirmationWarningModal(warningMessages)
}
/**
* @summary Flash image to drives
* @function
* @public
*
* @example
* FlashController.flashImageToDrive({
* path: 'rpi.img',
* size: 1000000000,
* compressedSize: 1000000000,
* isSizeEstimated: false,
* }, [
* '/dev/disk2',
* '/dev/disk5'
* ])
*/
this.flashImageToDrive = async () => {
const devices = selection.getSelectedDevices()
const image = selection.getImage()
const drives = _.filter(availableDrives.getDrives(), (drive) => {
return _.includes(devices, drive.device)
})
// eslint-disable-next-line no-magic-numbers
if (drives.length === 0) {
return
}
const hasDangerStatus = constraints.hasListDriveImageCompatibilityStatus(drives, image)
if (hasDangerStatus) {
if (!(await displayTailoredWarning(drives, image))) {
DriveSelectorService.open()
return
}
}
if (flashState.isFlashing()) {
return
}
// Trigger Angular digests along with store updates, as the flash state
// updates. Without this there is essentially no progress to watch.
const unsubscribe = store.observe($timeout)
// Stop scanning drives when flashing
// otherwise Windows throws EPERM
driveScanner.stop()
const iconPath = '../../../assets/icon.png'
const basename = path.basename(image.path)
try {
await imageWriter.flash(image.path, drives)
if (!flashState.wasLastFlashCancelled()) {
const flashResults = flashState.getFlashResults()
notification.send('Flash complete!', {
body: messages.info.flashComplete(basename, drives, flashResults.results.devices),
icon: iconPath
})
$state.go('success')
}
} catch (error) {
// When flashing is cancelled before starting above there is no error
if (!error) {
return
}
notification.send('Oops! Looks like the flash failed.', {
body: messages.error.flashFailure(path.basename(image.path), drives),
icon: iconPath
})
// TODO: All these error codes to messages translations
// should go away if the writer emitted user friendly
// messages on the first place.
if (error.code === 'EVALIDATION') {
FlashErrorModalService.show(messages.error.validation())
} else if (error.code === 'EUNPLUGGED') {
FlashErrorModalService.show(messages.error.driveUnplugged())
} else if (error.code === 'EIO') {
FlashErrorModalService.show(messages.error.inputOutput())
} else if (error.code === 'ENOSPC') {
FlashErrorModalService.show(messages.error.notEnoughSpaceInDrive())
} else if (error.code === 'ECHILDDIED') {
FlashErrorModalService.show(messages.error.childWriterDied())
} else {
FlashErrorModalService.show(messages.error.genericFlashError())
error.image = basename
analytics.logException(error)
}
} finally {
availableDrives.setDrives([])
driveScanner.start()
unsubscribe()
}
}
/**
* @summary Get progress button label
* @function
* @public
*
* @returns {String} progress button label
*
* @example
* const label = FlashController.getProgressButtonLabel()
*/
this.getProgressButtonLabel = () => {
if (!flashState.isFlashing()) {
return 'Flash!'
}
return progressStatus.fromFlashState(flashState.getFlashState())
}
/**
* @summary Abort write process
* @function
* @public
*
* @example
* FlashController.cancelFlash()
*/
this.cancelFlash = imageWriter.cancel
}

View File

@ -23,15 +23,11 @@
*/
const angular = require('angular')
const { react2angular } = require('react2angular')
const MODULE_NAME = 'Etcher.Pages.Main'
require('angular-moment')
const MainPage = angular.module(MODULE_NAME, [
'angularMoment',
require('angular-ui-router'),
require('angular-seconds-to-date'),
require('../../components/drive-selector/drive-selector'),
require('../../components/tooltip-modal/tooltip-modal'),
@ -54,7 +50,9 @@ const MainPage = angular.module(MODULE_NAME, [
MainPage.controller('MainController', require('./controllers/main'))
MainPage.controller('DriveSelectionController', require('./controllers/drive-selection'))
MainPage.controller('FlashController', require('./controllers/flash'))
MainPage.component('flash', react2angular(require('./Flash.jsx'),
[ 'shouldFlashStepBeDisabled', 'lastFlashErrorCode', 'progressMessage' ],
[ '$timeout', '$state', 'WarningModalService', 'DriveSelectorService', 'FlashErrorModalService' ]))
MainPage.config(($stateProvider) => {
$stateProvider

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
svg-icon > img[disabled] {
img[disabled] {
opacity: $disabled-opacity;
}

View File

@ -56,45 +56,11 @@
></reduced-flashing-infos>
</div>
<div class="col-xs" ng-controller="FlashController as flash">
<div class="box text-center">
<div class="center-block">
<svg-icon paths="[ '../../assets/flash.svg' ]"
disabled="main.shouldFlashStepBeDisabled()"></svg-icon>
</div>
<div class="space-vertical-large">
<progress-button
tabindex="3"
striped="main.state.getFlashState().type == 'verifying'"
active = "main.state.isFlashing()"
percentage="main.state.getFlashState().percentage"
label="flash.getProgressButtonLabel()"
disabled="!!main.state.getLastFlashErrorCode() || main.shouldFlashStepBeDisabled()"
callback="flash.flashImageToDrive" >
</progress-button>
<button class="button button-link button-abort-write"
ng-if="main.state.isFlashing()"
ng-click="flash.cancelFlash()">
<span class="glyphicon glyphicon-remove-sign"></span>
</button>
<p class="step-footer step-footer-split" ng-if="main.state.getFlashState().speed != null && main.state.getFlashState().percentage != 100">
<span ng-bind="main.state.getFlashState().speed.toFixed(2) + ' MB/s'"></span>
<span ng-if="main.state.getFlashState().eta != null">ETA: {{ main.state.getFlashState().eta | secondsToDate | amDateFormat:'m[m]ss[s]' }}</span>
</p>
<div class="target-status-wrap" ng-if="main.state.getFlashState().failed">
<div class="target-status-line target-status-failed">
<span class="target-status-dot"></span>
<span class="target-status-quantity">{{ main.state.getFlashState().failed }}</span>
<span class="target-status-message">{{
main.progressMessage.failed(main.state.getFlashState().failed)
}}</span>
</div>
</div>
</div>
</div>
<div class="col-xs">
<flash
should-flash-step-be-disabled="main.shouldFlashStepBeDisabled"
last-flash-error-code="main.state.getLastFlashErrorCode"
progress-image="main.progressImage"
></flash>
</div>
</div>

View File

@ -6368,8 +6368,9 @@ svg-icon {
* See the License for the specific language governing permissions and
* limitations under the License.
*/
svg-icon > img[disabled] {
opacity: 0.2; }
img[disabled] {
opacity: 0.2;
}
.page-main {
flex: 1;

16
npm-shrinkwrap.json generated
View File

@ -1568,22 +1568,6 @@
"integrity": "sha512-t3eQmuAZczdOVdOQj7muCBwH+MBNwd+/FaAsV1SNp+597EQVWABQwxI6KXE0k0ZlyJ5JbtkNIKU8kGAj1znxhw==",
"dev": true
},
"angular-moment": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/angular-moment/-/angular-moment-1.3.0.tgz",
"integrity": "sha512-KG8rvO9MoaBLwtGnxTeUveSyNtrL+RNgGl1zqWN36+HDCCVGk2DGWOzqKWB6o+eTTbO3Opn4hupWKIElc8XETA==",
"requires": {
"moment": ">=2.8.0 <3.0.0"
}
},
"angular-seconds-to-date": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/angular-seconds-to-date/-/angular-seconds-to-date-1.0.1.tgz",
"integrity": "sha1-mTi6xPKkeyvJVc0h0TwU8s3odj0=",
"requires": {
"angular": "^1.5.6"
}
},
"angular-ui-bootstrap": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/angular-ui-bootstrap/-/angular-ui-bootstrap-2.5.6.tgz",

View File

@ -46,8 +46,6 @@
"@fortawesome/react-fontawesome": "^0.1.7",
"angular": "1.7.6",
"angular-if-state": "^1.0.0",
"angular-moment": "^1.0.1",
"angular-seconds-to-date": "^1.0.0",
"angular-ui-bootstrap": "^2.5.0",
"angular-ui-router": "^0.4.2",
"bindings": "^1.3.0",

View File

@ -26,7 +26,7 @@ angularValidate.validate(
],
{
customtags: [
'settings'
'settings', 'flash'
],
customattrs: [

View File

@ -20,7 +20,6 @@ const m = require('mochainon')
const _ = require('lodash')
const fs = require('fs')
const angular = require('angular')
const flashState = require('../../../lib/gui/app/models/flash-state')
const availableDrives = require('../../../lib/gui/app/models/available-drives')
const selectionState = require('../../../lib/gui/app/models/selection-state')
@ -165,44 +164,6 @@ describe('Browser: MainPage', function () {
})
})
describe('FlashController', function () {
let $controller
beforeEach(angular.mock.inject(function (_$controller_) {
$controller = _$controller_
}))
describe('.getProgressButtonLabel()', function () {
it('should return "Flash!" given a clean state', function () {
const controller = $controller('FlashController', {
$scope: {}
})
flashState.resetState()
m.chai.expect(controller.getProgressButtonLabel()).to.equal('Flash!')
})
it('should display the flashing progress', function () {
const controller = $controller('FlashController', {
$scope: {}
})
flashState.setFlashingFlag()
flashState.setProgressState({
flashing: 1,
verifying: 0,
successful: 0,
failed: 0,
percentage: 85,
eta: 15,
speed: 1000,
totalSpeed: 2000
})
m.chai.expect(controller.getProgressButtonLabel()).to.equal('85% Flashing')
})
})
})
describe('DriveSelectionController', function () {
let $controller
let DriveSelectionController